<?php
/**
 * PayPal payout failure classifier.
 *
 * Maps PayPal-specific signals (batch-level statuses, per-item transaction
 * statuses, and HTTP-level errors from the SDK) onto the cross-method
 * Failure_Class enum.
 *
 * Called from `check_payout_status()` when persisting a terminal failure
 * status to a payout row. The classifier receives the worst signal we can
 * derive (e.g. `DENIED` for a batch-level denial, or `UNCLAIMED` for an
 * item that timed out unclaimed) and returns the matching class.
 *
 * Registered via `AffWP\Payouts\Failure_Classifier::register( 'paypal', ... )`
 * at init time.
 *
 * @package    AffiliateWP
 * @subpackage Payouts/Methods/PayPal
 * @since      2.33.0
 */

use AffWP\Payouts\Failure_Class;

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

/**
 * Classifies a PayPal payout error into a Failure_Class bucket.
 *
 * Signals (in priority order):
 *   1. Batch-level statuses from PayPal's payout response: `DENIED`
 *      (entire batch rejected), other terminal statuses.
 *   2. Per-item transaction statuses (when stored in the code):
 *      `UNCLAIMED` (recipient never claimed), `RETURNED` (recipient
 *      refused), `BLOCKED` (account issue), `FAILED` (provider failure),
 *      `REFUNDED`, `REVERSED`.
 *   3. HTTP status codes from the PayPal SDK: 401/403 (admin auth),
 *      429 (transient rate limit), 500 (transient provider issue).
 *
 * Caller is expected to pass the worst-signal code first (e.g. when a
 * batch is partially failed, pass the item status, not the batch status).
 *
 * @since 2.33.0
 *
 * @param string $error_code    Code-like signal: a PayPal status string ('DENIED',
 *                              'UNCLAIMED', 'FAILED', etc.) or HTTP code as string.
 * @param string $error_message Human-readable message; may include PayPal's error_message field.
 * @return string One of the Failure_Class constants.
 */
function affwp_paypal_payouts_classify_failure( $error_code, $error_message ) {

	$code = strtoupper( (string) $error_code );

	/*
	 * PayPal item-level / batch-level terminal statuses. These are the
	 * canonical signals when a payout transitions to 'failed' through
	 * the status-poller path.
	 */
	switch ( $code ) {
		case 'DENIED':
			/*
			 * PayPal rejected the payout during processing; funds were
			 * NOT deducted from the sender's account. Per PayPal's docs,
			 * "the recipient cannot take direct action to receive payment
			 * when DENIED. The sender must investigate the specific
			 * error message... and potentially resubmit the payout with
			 * corrected information." Common causes: sender account
			 * restrictions, currency mismatch, recipient eligibility
			 * issues. This is an admin-side problem, not an affiliate
			 * one — the affiliate has no path to fix it themselves.
			 *
			 * Routing this to admin_action_required (rather than
			 * affiliate_action_required) means:
			 *   - The auto-email registry won't fire a "complete your
			 *     PayPal setup" email to the affiliate (they can't).
			 *   - The auto-retry sweep won't pick it up (the affiliate
			 *     will never "become ready" — the admin has to act).
			 *   - The drawer's Next Steps card surfaces an "open PayPal
			 *     dashboard" CTA pointing the admin at the failure
			 *     details.
			 */
			return Failure_Class::ADMIN_ACTION_REQUIRED;

		case 'UNCLAIMED':
			/*
			 * Per PayPal's docs, "the recipient for this payout does not
			 * have a PayPal account. A link to sign up for a PayPal
			 * account was sent to the recipient. They have 30 days to
			 * sign up; otherwise funds return to the sender's account."
			 * Recoverable by recipient action — the affiliate creates a
			 * PayPal account using the payout email, then the funds
			 * drop in. Correctly classified as affiliate_action_required.
			 */
			return Failure_Class::AFFILIATE_ACTION_REQUIRED;

		case 'RETURNED':
			/*
			 * Recipient refused or returned the payment. Affiliate action.
			 */
			return Failure_Class::AFFILIATE_ACTION_REQUIRED;

		case 'BLOCKED':
			/*
			 * PayPal blocked the transaction at their end (compliance,
			 * fraud, account hold). Affiliate's PayPal account needs
			 * attention; affiliate action.
			 */
			return Failure_Class::AFFILIATE_ACTION_REQUIRED;

		case 'REFUNDED':
		case 'REVERSED':
			/*
			 * Payment went through then got reversed. Could be either side
			 * (admin issued a refund, or the affiliate disputed). Surface
			 * to admin to investigate.
			 */
			return Failure_Class::ADMIN_ACTION_REQUIRED;

		case 'SELF_PAY_NOT_ALLOWED':
			/*
			 * The affiliate's PayPal payout email is the same account that
			 * owns the sender credentials — PayPal won't let an account pay
			 * itself. Fixable by correcting the affiliate's PayPal email, so
			 * route to data_error (Edit affiliate profile), not the generic
			 * admin "check your dashboard" path.
			 */
			return Failure_Class::DATA_ERROR;

		case 'FAILED':
			/*
			 * Generic PayPal failure — could be any number of reasons.
			 * Without more signal, surface as unknown so admin can
			 * investigate in the PayPal dashboard.
			 */
			return Failure_Class::UNKNOWN;
	}

	/*
	 * HTTP-level errors from the SDK (passed as string-coerced code).
	 */
	if ( '401' === $code || '403' === $code ) {
		/*
		 * Auth / permission problem at the platform level. Admin must
		 * fix the PayPal API credentials in settings.
		 */
		return Failure_Class::ADMIN_ACTION_REQUIRED;
	}

	if ( '429' === $code ) {
		return Failure_Class::TRANSIENT;
	}

	if ( '500' === $code || '502' === $code || '503' === $code || '504' === $code ) {
		/*
		 * Provider-side outage; retry once their systems recover.
		 */
		return Failure_Class::TRANSIENT;
	}

	if ( 'VALIDATION_ERROR' === $code ) {
		/*
		 * PayPal couldn't parse our request payload. Almost always means
		 * the affiliate's email is malformed or the amount is invalid.
		 */
		return Failure_Class::DATA_ERROR;
	}

	/*
	 * Fall-through: check the error message for hints when the code
	 * isn't a recognized status string. Matches the "validation error"
	 * substring on the message form too (the SDK isn't always consistent
	 * about which field carries the code vs the description).
	 */
	$message = strtolower( (string) $error_message );

	if ( false !== strpos( $message, 'rate limit' ) || false !== strpos( $message, 'too many requests' ) ) {
		return Failure_Class::TRANSIENT;
	}

	if ( false !== strpos( $message, 'invalid email' ) || false !== strpos( $message, 'invalid receiver' ) ) {
		return Failure_Class::DATA_ERROR;
	}

	if ( false !== strpos( $message, 'payment to self' ) ) {
		return Failure_Class::DATA_ERROR;
	}

	return Failure_Class::UNKNOWN;
}

AffWP\Payouts\Failure_Classifier::register( 'paypal', 'affwp_paypal_payouts_classify_failure' );

/*
 * Default body for the PayPal variant of the cross-method "Payout failed,
 * action required" email. Leads with the action that actually releases the
 * money — claiming PayPal's held payment via the link PayPal emails the
 * recipient — and offers the settings fix only for a genuinely wrong email.
 * (Changing the affiliate's payout email never re-routes an in-flight payout;
 * it only applies to the next one.) Composed only from the cross-method merge
 * tags so the unified dispatcher resolves it. Editable in Settings → Emails.
 */
AffWP\Payouts\Failure_Email_Registry::register(
	'paypal',
	array(
		'label' => __( 'PayPal', 'affiliate-wp' ),
		'body'  => __( "Hi {name},\n\nWe sent your {amount} commission from {site_name} to PayPal, but it hasn't been claimed yet.\n\nTo receive it, check your inbox for an email from PayPal and follow the link to claim your payment. If you don't have a PayPal account, you can create one for free using the same email address. PayPal holds the funds for 30 days.\n\nIf that's the wrong email address, update it in your affiliate settings and we'll use it for your next payment:\n{affiliate_payout_settings_url}\n\nQuestions? Just reply to this email.\n\nThanks,\n{site_name}", 'affiliate-wp' ),
	)
);

/**
 * Readiness predicate for the auto-retry sweep — PayPal.
 *
 * Returns true when the affiliate has a PayPal email stored on user meta
 * that passes is_email() validation. Doesn't verify with PayPal's API —
 * the next retry will surface the real verification failure if the email
 * is wrong, and the auto-email loop kicks in again.
 *
 * @since 2.33.0
 *
 * @param bool   $is_ready     Default from the filter chain.
 * @param string $method       Method slug.
 * @param int    $affiliate_id Affiliate ID.
 * @return bool
 */
function affwp_paypal_payouts_filter_is_affiliate_ready( $is_ready, $method, $affiliate_id ) {
	if ( 'paypal' !== $method ) {
		return $is_ready;
	}
	$affiliate = affwp_get_affiliate( $affiliate_id );
	if ( ! $affiliate ) {
		return $is_ready;
	}
	if ( empty( $affiliate->payment_email ) || ! is_email( $affiliate->payment_email ) ) {
		return $is_ready;
	}
	return true;
}

add_filter( 'affwp_payout_method_is_affiliate_ready', 'affwp_paypal_payouts_filter_is_affiliate_ready', 10, 3 );

/**
 * Supplies the terminal-failure ("returned") body for PayPal.
 *
 * The editable `payout_failure_paypal_body` setting addresses the live UNCLAIMED
 * state — funds PayPal is holding that the affiliate can still claim via the link
 * PayPal emailed them. Once a payout has terminally failed (PayPal RETURNED the
 * funds after the 30-day unclaimed window, or BLOCKED the transaction) that "check
 * your inbox and claim it" copy is stale: the funds are already back with the
 * sender. Swap in copy that tells the affiliate to fix the PayPal email on their
 * account so the (now double-pay-safe) retry sweep can re-pay.
 *
 * Deliberately code-level rather than a second editable field — the terminal state
 * is rare and the wording is generic. Hooks the `affwp_payout_failure_email_body`
 * filter raised in affwp_maybe_send_failure_email().
 *
 * @since 2.34.0
 *
 * @param string $body   Current body (the editable / registered default).
 * @param object $payout Payout being emailed about.
 * @param string $method Payout method slug.
 * @param string $state  'unclaimed' (live, still claimable) or 'failed' (terminal).
 * @return string
 */
function affwp_paypal_payouts_failure_email_returned_body( $body, $payout, $method, $state ) {
	if ( 'paypal' !== $method || 'failed' !== $state ) {
		return $body;
	}

	return __( "Hi {name},\n\nWe couldn't deliver your {amount} commission from {site_name} via PayPal, so it's been returned to us.\n\nThis usually means the PayPal email on your affiliate account isn't ready to receive payments. Please open your settings and make sure it's correct and confirmed with PayPal:\n{affiliate_payout_settings_url}\n\nOnce it's updated, we'll automatically try sending your commission again. Questions? Just reply to this email.\n\nThanks,\n{site_name}", 'affiliate-wp' );
}

add_filter( 'affwp_payout_failure_email_body', 'affwp_paypal_payouts_failure_email_returned_body', 10, 4 );
