<?php
/**
 * Payout failure classifier.
 *
 * Cross-method registry that maps method-specific error codes/messages to a
 * machine-readable failure class. The drawer dispatch, the auto-email
 * pipeline, and the daily auto-retry sweep all read this class to decide
 * what to do with a failed payout.
 *
 * Per-method classifiers register their callable via `register()` at init
 * time. When a payout fails, the method processor calls `classify()` with
 * its WP_Error code + message and gets back one of the 5 class strings.
 *
 * @package    AffiliateWP
 * @subpackage Payouts
 * @since      2.33.0
 */

namespace AffWP\Payouts;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Failure-class enum (string constants).
 *
 * Kept as a discrete class so a misspelled class string in a switch fails
 * at parse time rather than silently dropping into the `unknown` branch.
 *
 * @since 2.33.0
 */
final class Failure_Class {
	/*
	 * Temporary, retryable error from the payment provider. Network blip,
	 * rate limit, transient API failure. Retry button stays enabled.
	 */
	const TRANSIENT = 'transient';

	/*
	 * The affiliate needs to take action before the payout can succeed.
	 * Examples: hasn't completed Stripe Connect onboarding, PayPal recipient
	 * email never claimed, invalid PayPal email. Auto-email goes out
	 * (respecting cooldown), auto-retry sweep watches for the blocker to
	 * clear, Retry button is disabled until then.
	 */
	const AFFILIATE_ACTION_REQUIRED = 'affiliate_action_required';

	/*
	 * The admin needs to take action. Examples: insufficient platform
	 * balance, capability not granted on the Stripe account, API key
	 * problem. Retry stays disabled until admin clears the blocker.
	 */
	const ADMIN_ACTION_REQUIRED = 'admin_action_required';

	/*
	 * Data on the affiliate's profile is malformed. Examples: invalid
	 * PayPal email format, currency mismatch. Retry disabled; admin
	 * needs to edit the affiliate profile.
	 */
	const DATA_ERROR = 'data_error';

	/*
	 * Classifier couldn't categorize. Retry stays enabled (admin
	 * judgement). The drawer surfaces the raw error message and links
	 * to the provider dashboard so the admin can investigate.
	 */
	const UNKNOWN = 'unknown';

	/**
	 * Whether the given class is retryable without intervention.
	 *
	 * Used by the drawer to gate the Retry button. Transient and unknown
	 * are the two retryable classes: transient because it's a network
	 * blip, unknown because we don't have enough info to disable it
	 * confidently.
	 *
	 * @since 2.33.0
	 *
	 * @param string $class One of the Failure_Class constants.
	 * @return bool
	 */
	public static function is_retryable( $class ) {
		return in_array( $class, array( self::TRANSIENT, self::UNKNOWN ), true );
	}

	/**
	 * Returns all valid class strings. Used to validate user input and
	 * for the daily sweep's WHERE-IN clause.
	 *
	 * @since 2.33.0
	 *
	 * @return string[]
	 */
	public static function all() {
		return array(
			self::TRANSIENT,
			self::AFFILIATE_ACTION_REQUIRED,
			self::ADMIN_ACTION_REQUIRED,
			self::DATA_ERROR,
			self::UNKNOWN,
		);
	}
}

/**
 * Method-keyed classifier registry.
 *
 * Per-method init files call Failure_Classifier::register( 'stripe', $callable ).
 * The processor calls Failure_Classifier::classify( 'stripe', $code, $msg ) and
 * gets back a class string suitable for persisting on the payout row.
 *
 * @since 2.33.0
 */
final class Failure_Classifier {

	/**
	 * Method slug => classifier callable.
	 *
	 * @since 2.33.0
	 * @var array<string, callable>
	 */
	private static $classifiers = array();

	/**
	 * Register a classifier for a payout method.
	 *
	 * Idempotent in the sense that re-registering the same method overwrites
	 * the previous callable; intentional, so customer-site customizations
	 * can hook later (after the method's init file runs) and win.
	 *
	 * @since 2.33.0
	 *
	 * @param string   $method     Method slug (e.g. 'stripe', 'paypal').
	 * @param callable $classifier Callable receiving ( $error_code, $error_message )
	 *                             and returning one of the Failure_Class constants.
	 * @return void
	 */
	public static function register( $method, $classifier ) {
		if ( ! is_callable( $classifier ) ) {
			return;
		}
		self::$classifiers[ (string) $method ] = $classifier;
	}

	/**
	 * Classify a payout failure into one of the Failure_Class buckets.
	 *
	 * Calls the registered classifier for the method; if none is registered
	 * or the classifier returns an invalid class, falls through to UNKNOWN.
	 * Result passes through the `affwp_payout_failure_class` filter so
	 * add-ons can override per-payout.
	 *
	 * @since 2.33.0
	 *
	 * @param string                   $method        Method slug.
	 * @param string|\WP_Error         $error_code    WP_Error code, or raw string code.
	 * @param string                   $error_message Human-readable error message.
	 * @param \AffWP\Affiliate\Payout|null $payout    Optional payout context for filter.
	 * @return string One of the Failure_Class constants.
	 */
	public static function classify( $method, $error_code, $error_message = '', $payout = null ) {

		$class = Failure_Class::UNKNOWN;

		if ( $error_code instanceof \WP_Error ) {
			$error_message = $error_code->get_error_message() ?: $error_message;
			$error_code    = $error_code->get_error_code();
		}

		$method = (string) $method;

		if ( isset( self::$classifiers[ $method ] ) ) {
			$result = call_user_func( self::$classifiers[ $method ], $error_code, $error_message );
			if ( in_array( $result, Failure_Class::all(), true ) ) {
				$class = $result;
			}
		}

		/**
		 * Filters the classified failure class for a payout.
		 *
		 * Lets add-ons override classification per-payout. Use when the
		 * method-level classifier is too coarse for a specific failure
		 * mode (e.g. a customer-site-specific error string that maps to
		 * a different class).
		 *
		 * @since 2.33.0
		 *
		 * @param string                       $class         One of the Failure_Class constants.
		 * @param string                       $method        Method slug.
		 * @param string                       $error_code    The classified error code.
		 * @param string                       $error_message Human-readable error message.
		 * @param \AffWP\Affiliate\Payout|null $payout        Payout context (may be null when called early).
		 */
		return apply_filters( 'affwp_payout_failure_class', $class, $method, $error_code, $error_message, $payout );
	}
}
