<?php
/**
 * Async-payment failure (charge.failed / payment_intent.payment_failed)
 * webhook handler — reverses orphaned vendor transfers.
 *
 * BUSINESS PROBLEM
 * ----------------
 * SPP uses Stripe's "separate charges and transfers" model with the
 * `source_transaction` parameter (the transfer is tied to the order's charge
 * and fires on `payment_intent.succeeded`). Per the Stripe docs
 * (https://docs.stripe.com/connect/separate-charges-and-transfers):
 *
 *   "Asynchronous payment methods, like ACH, can fail AFTER a subsequent
 *    transfer request is made. … When a payment used as a source_transaction
 *    fails, funds from your platform's account balance are transferred to the
 *    connected account to cover the payment. To recover these funds, REVERSE
 *    the transfer associated with the failed source_transaction."
 *
 * For delayed-notification methods (ACH Direct Debit, SEPA Debit, Bacs) the
 * charge can succeed-then-FAIL days later (NSF / bank return / revoked
 * authorization). By then SPP has already paid the vendor(s) out of the
 * PLATFORM balance — and unlike the destination-charge model, Stripe does NOT
 * auto-reverse those transfers for separate-charges-and-transfers. Without this
 * handler the platform silently eats the full charge amount for every async
 * return: real, unrecoverable money loss.
 *
 * WHAT THIS DOES
 * --------------
 * On `charge.failed` (or `payment_intent.payment_failed`):
 *   1. Resolve the WC order from the failed charge id.
 *   2. Reverse ALL vendor transfers for that order, IN FULL — the whole charge
 *      failed, so the whole payout must come back (refund ratio 100%).
 *   3. Add a clear order note + observability counter.
 *
 * SAFETY / IDEMPOTENCY
 * --------------------
 *   - Reuses TransferEngine::processRefund() with refundTotal == orderTotal, so
 *     each reversal carries the SAME deterministic `spp_rev_<order>_<tr>_<cents>`
 *     idempotency key the refund/dispute paths use. A re-delivered webhook, OR a
 *     future Stripe auto-reversal, therefore collapses to ONE reversal — never a
 *     double-reverse, never a clawback exceeding the transfer.
 *   - An event-specific order-meta marker (keyed on the Stripe event id) makes a
 *     re-delivery of the SAME event a no-op before we even reach Stripe.
 *   - Per-transfer exceptions inside processRefund() are caught and noted there;
 *     if Stripe already reversed a transfer (the 2025 SC&T auto-reversal rollout),
 *     our reversal of an already-reversed transfer fails safe and is logged — no
 *     money moves, the platform is made whole exactly once.
 *
 * @package SplitPay
 * @since 3.7.5
 */

declare( strict_types=1 );

namespace SplitPay\Integrations\WooCommerce;

use SplitPay\Core\ConnectedAccounts;
use SplitPay\Core\StripeService;
use SplitPay\Core\TransferEngine;
use SplitPay\Core\TransferLogger;
use SplitPay\Integrations\WooCommerce\Adapters\WCOrderAdapter;
use SplitPay\Integrations\WooCommerce\Gateways\WCStripeGateway;

defined( 'ABSPATH' ) || exit;

class ChargeFailedHandler {

	/**
	 * Process an async-payment-failure webhook payload.
	 *
	 * Called from the webhook entry point AFTER signature verification has
	 * already passed. Idempotent: re-delivery of the same event_id on the same
	 * order is a no-op.
	 *
	 * @param string $eventType One of charge.failed | payment_intent.payment_failed.
	 * @param object $eventBody json_decode'd webhook body — Stripe Event object.
	 * @return array{status:string, message?:string}
	 */
	public static function handle( string $eventType, $eventBody ): array {
		$obj = $eventBody->data->object ?? null;
		if ( ! $obj ) {
			return [ 'status' => 'error', 'message' => 'Missing event object' ];
		}

		// Only act on a genuinely failed/canceled payment. payment_intent
		// events for non-failed states (e.g. requires_action) are ignored —
		// the dispatcher only routes the *failed variants here, but we guard
		// defensively so an unexpected status never triggers a reversal.
		if ( ! self::isTerminalFailure( $eventType, $obj ) ) {
			return [ 'status' => 'ignored', 'message' => 'Event is not a terminal payment failure' ];
		}

		$chargeId = self::resolveChargeId( $eventType, $obj );
		if ( '' === $chargeId ) {
			return [ 'status' => 'no_charge', 'message' => 'Could not resolve a charge id from the failure event' ];
		}

		if ( ! class_exists( '\WC_Stripe_Helper' ) ) {
			return [ 'status' => 'error', 'message' => 'WC Stripe helper unavailable; cannot resolve order' ];
		}

		$order = \WC_Stripe_Helper::get_order_by_source_id( $chargeId );
		if ( ! $order ) {
			// No order for this charge id — also try the payment-intent id (some
			// gateways store the PI as the source id rather than the charge).
			$piId = (string) ( $obj->payment_intent ?? ( 'payment_intent' === ( $obj->object ?? '' ) ? ( $obj->id ?? '' ) : '' ) );
			if ( '' !== $piId ) {
				$order = \WC_Stripe_Helper::get_order_by_source_id( $piId );
			}
		}
		if ( ! $order ) {
			return [ 'status' => 'order_not_found', 'message' => 'No WC order matches charge ' . $chargeId ];
		}

		$adapter = new WCOrderAdapter( $order, '' );
		$orderId = $adapter->getOrderId();

		// Idempotency: an event-specific meta marker so a re-delivered failure
		// webhook (Stripe at-least-once delivery) doesn't re-run the reversal.
		$idemKey = 'spp_chargefail_handled_' . md5( ( $eventBody->id ?? '' ) . '|' . $chargeId );
		if ( ! empty( $adapter->getOrderMeta( $idemKey ) ) ) {
			return [ 'status' => 'already_handled', 'message' => 'Event already processed' ];
		}

		$gateway = new WCStripeGateway();
		$apiKey  = $gateway->getTransferSecretKey();
		if ( '' === $apiKey ) {
			$adapter->addOrderNote( sprintf(
				'Split Pay: ASYNC PAYMENT FAILED on order %d (charge %s) — the payment was reversed by the bank AFTER vendor transfers were already paid from your platform balance, but no Stripe API key is configured so the vendor transfers could NOT be auto-reversed. Reverse them manually on the Transfers tab to recover your funds.',
				$orderId,
				$chargeId
			) );
			$adapter->setOrderMeta( $idemKey, '1' );
			if ( function_exists( 'do_action' ) ) {
				do_action( 'pts_increment_counter', 'async_failure_reversal_no_api_key' );
			}
			return [ 'status' => 'error', 'message' => 'no API key' ];
		}

		$failureReason = self::resolveFailureReason( $obj );

		$stripe   = new StripeService( $apiKey );
		$logger   = new TransferLogger();
		$accounts = new ConnectedAccounts();
		$engine   = new TransferEngine( $stripe, $logger, $accounts );

		$orderTotal = (float) $adapter->getRawOrder()->get_total();

		// audit — async-failure observability for the suite spec. Fires BEFORE
		// the reversal so a partial failure inside processRefund() doesn't
		// suppress the counter; the spec asserts the increment per terminal
		// failure event regardless of downstream outcome.
		if ( function_exists( 'do_action' ) ) {
			do_action( 'pts_increment_counter', 'async_failure_reversed' );
		}

		$adapter->addOrderNote( sprintf(
			'Split Pay: ASYNC PAYMENT FAILED (%s) on charge %s — the payment was returned by the bank after vendor transfers were already paid from your platform balance. Reversing ALL vendor transfers for order %d to recover your funds (Stripe does not auto-reverse these for separate charges and transfers).',
			$failureReason,
			$chargeId,
			$orderId
		) );

		// FULL reversal: refundTotal == orderTotal => isFullRefund => every
		// logged transfer is reversed in full with its deterministic
		// spp_rev_<order>_<tr>_<cents> idempotency key (exactly-once safe).
		$engine->processRefund( $adapter, $orderTotal, $orderTotal, 'woocommerce' );

		// Mark handled so a Stripe re-delivery of this event is a no-op.
		$adapter->setOrderMeta( $idemKey, '1' );

		return [ 'status' => 'reversed', 'message' => 'vendor transfers reversed in full for failed async payment' ];
	}

	/**
	 * True when the event represents a TERMINAL payment failure that orphaned a
	 * transfer. For charge.failed: status === 'failed'. For
	 * payment_intent.payment_failed: the PI moved to a non-successful terminal
	 * status (canceled / requires_payment_method after an async return). We
	 * treat the dispatcher's choice of event type as authoritative but still
	 * sanity-check the object so a malformed payload can't trigger a reversal.
	 */
	private static function isTerminalFailure( string $eventType, $obj ): bool {
		if ( 'charge.failed' === $eventType ) {
			return ( $obj->status ?? '' ) === 'failed';
		}
		if ( 'payment_intent.payment_failed' === $eventType ) {
			$status = (string) ( $obj->status ?? '' );
			// A delayed async return surfaces the PI as requires_payment_method
			// (the charge failed) or canceled; either way the prior transfer is
			// now unbacked. 'succeeded'/'processing' must never reverse.
			return in_array( $status, [ 'requires_payment_method', 'canceled', 'requires_action', '' ], true )
				&& ( $obj->status ?? '' ) !== 'succeeded'
				&& ( $obj->status ?? '' ) !== 'processing';
		}
		return false;
	}

	/**
	 * Resolve the failed charge id from either event shape.
	 *   - charge.failed:                  data.object.id (the charge itself).
	 *   - payment_intent.payment_failed:  data.object.latest_charge, else the
	 *     first entry of data.object.charges.data[].
	 */
	private static function resolveChargeId( string $eventType, $obj ): string {
		if ( 'charge.failed' === $eventType ) {
			return (string) ( $obj->id ?? '' );
		}

		// payment_intent.payment_failed
		$latest = (string) ( $obj->latest_charge ?? '' );
		if ( '' !== $latest ) {
			return $latest;
		}
		if ( isset( $obj->charges->data ) && is_array( $obj->charges->data ) && ! empty( $obj->charges->data ) ) {
			$first = $obj->charges->data[0];
			return (string) ( $first->id ?? '' );
		}
		return '';
	}

	/**
	 * Best-effort human-readable failure reason for the order note.
	 */
	private static function resolveFailureReason( $obj ): string {
		$msg = (string) ( $obj->failure_message ?? '' );
		if ( '' !== $msg ) {
			return $msg;
		}
		$code = (string) ( $obj->failure_code ?? '' );
		if ( '' !== $code ) {
			return $code;
		}
		if ( isset( $obj->last_payment_error->message ) ) {
			return (string) $obj->last_payment_error->message;
		}
		if ( isset( $obj->last_payment_error->code ) ) {
			return (string) $obj->last_payment_error->code;
		}
		return 'payment failed';
	}

	/**
	 * Event types this handler accepts. Used by the webhook dispatcher to decide
	 * whether to delegate to us.
	 *
	 * @return string[]
	 */
	public static function handledEventTypes(): array {
		return [
			'charge.failed',
			'payment_intent.payment_failed',
		];
	}
}
