<?php
/**
 * PayPal Webhook Handler
 *
 * REST endpoint that receives PayPal webhook events and converts them into
 * ambassador-fundraiser donation status updates and automation triggers.
 *
 * ## Endpoint
 *
 *   POST /wp-json/ambassadorhub/v1/webhook/paypal
 *
 * ## Security
 *
 * This endpoint does not verify PayPal's webhook transmission signature
 * (PAYPAL-TRANSMISSION-SIG et al. / the /v1/notifications/verify-webhook-signature
 * API) — see the TODO in handle(). What it does instead: the incoming
 * event_type is used only to decide whether a payload looks actionable at
 * all, never to decide what status to persist. The order referenced by the
 * payload is re-fetched from PayPal using our own stored credentials, and
 * the status actually written to the donation is derived exclusively from
 * that verified order's own status/capture data (see
 * derive_status_from_verified_order()). This closes the main forgery path
 * (a caller claiming an event for an order that hasn't actually reached that
 * state) without requiring signature verification, though verifying the
 * transmission signature remains the more robust fix and is recommended as
 * a follow-up.
 *
 * ## Relevant PayPal event types
 *
 *  CHECKOUT.ORDER.APPROVED          — buyer approved the order (pre-capture)
 *  PAYMENT.CAPTURE.COMPLETED        — payment captured and settled
 *  PAYMENT.CAPTURE.PENDING          — payment under review
 *  PAYMENT.CAPTURE.DENIED           — payment declined
 *  PAYMENT.CAPTURE.REVERSED         — payment reversed / refunded
 *  CHECKOUT.ORDER.CANCELLED         — buyer cancelled the order
 *
 * @package AmbassadorHub\Connectors\PayPal
 */

declare( strict_types=1 );

namespace AmbassadorHub\Connectors\PayPal;

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

use WP_REST_Request;
use WP_REST_Response;

use function current_time;
use function do_action;
use function is_wp_error;
use function register_rest_route;
use function sanitize_text_field;
use function wp_remote_get;
use function wp_remote_post;
use function wp_remote_retrieve_body;
use function wp_remote_retrieve_response_code;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Registers and handles the PayPal webhook REST endpoint.
 */
final class WebhookHandler {

	/** REST namespace. */
	private const NAMESPACE = \AmbassadorHub\Api\ApiModule::NAMESPACE;

	/** REST route. */
	private const ROUTE = '/webhook/paypal';

	/**
	 * PayPal event type → internal donation status mapping.
	 *
	 * Events not listed here are acknowledged but not acted upon.
	 */
	private const EVENT_STATUS_MAP = array(
		'PAYMENT.CAPTURE.COMPLETED' => 'completed',
		'CHECKOUT.ORDER.APPROVED'   => 'pending',
		'PAYMENT.CAPTURE.PENDING'   => 'pending',
		'PAYMENT.CAPTURE.DENIED'    => 'failed',
		'PAYMENT.CAPTURE.REVERSED'  => 'failed',
		'CHECKOUT.ORDER.CANCELLED'  => 'failed',
	);

	// -------------------------------------------------------------------------
	// Bootstrap
	// -------------------------------------------------------------------------

	/**
	 * Register the REST route with WordPress.
	 *
	 * Called via the `ambh_register_rest_routes` action from PayPalPspConnector::on_registration().
	 *
	 * @return void
	 */
	public static function register_route(): void {
		register_rest_route(
			self::NAMESPACE,
			self::ROUTE,
			array(
				'methods'             => 'POST',
				'callback'            => array( self::class, 'handle' ),
				'permission_callback' => '__return_true', // Security via PayPal API fetch below.
			)
		);
	}

	// -------------------------------------------------------------------------
	// Handler
	// -------------------------------------------------------------------------

	/**
	 * Process the incoming PayPal webhook event.
	 *
	 * PayPal POSTs a JSON body containing event_type and resource (the order/capture).
	 * The handler re-fetches the order from PayPal using the stored credentials and
	 * derives the persisted status from that verified data — never from event_type.
	 *
	 * TODO: verify the PayPal webhook transmission signature (PAYPAL-TRANSMISSION-ID/
	 * -TIME/-SIG/-CERT-URL headers via /v1/notifications/verify-webhook-signature)
	 * once a webhook ID is configured for this integration. That remains the fully
	 * robust fix; deriving status from the verified order (below) closes the
	 * concrete forgery path in the meantime without requiring new configuration.
	 *
	 * @param WP_REST_Request $request Incoming REST request.
	 * @return WP_REST_Response
	 */
	public static function handle( WP_REST_Request $request ): WP_REST_Response {
		$json_params = $request->get_json_params();

		if ( empty( $json_params ) ) {
			return new WP_REST_Response( array( 'error' => 'Empty request body.' ), 400 );
		}

		// $event_type is untrusted input (this endpoint has no signature
		// verification — see class docblock). It is used ONLY to decide
		// whether the payload looks like an event we handle at all, as an
		// early exit for noise. It must never be used to decide what status
		// to persist — see the "verified_order-derived" status below.
		$event_type = sanitize_text_field( $json_params['event_type'] ?? '' );
		if ( empty( $event_type ) || ! isset( self::EVENT_STATUS_MAP[ $event_type ] ) ) {
			return new WP_REST_Response( array( 'message' => 'Event not actionable: ' . $event_type ), 200 );
		}

		// Extract the order or capture ID from the resource object.
		$resource  = $json_params['resource'] ?? array();
		$order_id  = sanitize_text_field( self::extract_order_id( $resource ) );
		$custom_id = sanitize_text_field( $resource['purchase_units'][0]['custom_id'] ?? '' );

		if ( empty( $order_id ) ) {
			return new WP_REST_Response( array( 'error' => 'Could not determine order ID from event.' ), 400 );
		}

		// Authenticate: fetch the order from PayPal to verify ownership.
		// Note: an HTTP 200 here only proves the order exists and belongs to
		// us — it says nothing about whether it's actually been captured.
		// The order's own `status` (and its capture sub-status, checked in
		// derive_status_from_verified_order() below) is what tells us that;
		// the caller's claimed event_type is never trusted for it.
		$verified_order = self::fetch_order( $order_id );
		if ( null === $verified_order ) {
			return new WP_REST_Response( array( 'error' => 'Could not verify order with PayPal API.' ), 502 );
		}

		// If the webhook claims the order was approved, try capturing it —
		// but only if PayPal's own verified order data agrees it's actually
		// APPROVED (not yet captured). A forged "approved" claim for an
		// order that isn't in that state will just no-op here rather than
		// call PayPal's capture endpoint pointlessly.
		if ( 'CHECKOUT.ORDER.APPROVED' === $event_type ) {
			if ( 'APPROVED' === ( $verified_order['status'] ?? '' ) ) {
				self::capture_order( $order_id );
			}
			// After a real capture we'll receive a PAYMENT.CAPTURE.COMPLETED
			// event — return 200 now to let PayPal know we handled the approval.
			return new WP_REST_Response( array( 'message' => 'Order capture initiated.' ), 200 );
		}

		// Derive the status to persist from PayPal's own verified order data —
		// never from the request body. See derive_status_from_verified_order().
		$new_status = self::derive_status_from_verified_order( $verified_order );
		if ( null === $new_status ) {
			return new WP_REST_Response( array( 'message' => 'Verified order status not actionable.' ), 200 );
		}

		// Derive the donation_id: prefer custom_id from purchase_units, fall back to
		// the supplemental id in the verified order metadata.
		$donation_id_raw = $custom_id ? $custom_id : ( $verified_order['purchase_units'][0]['custom_id'] ?? '' );

		$donation_id = (int) $donation_id_raw;

		$donation = self::resolve_donation( $donation_id, $order_id );
		if ( ! $donation ) {
			return new WP_REST_Response(
				array( 'error' => 'Donation not found for order: ' . $order_id ),
				404
			);
		}

		$old_status = $donation->status;

		if ( $old_status === $new_status ) {
			return new WP_REST_Response( array( 'message' => 'Status unchanged.' ), 200 );
		}

		// Persist the status change.
		if ( class_exists( \AmbassadorHub\Donations\DonationsModule::class ) ) {
			\AmbassadorHub\Donations\DonationsModule::update(
				(int) $donation->id,
				array(
					'status'         => $new_status,
					'transaction_id' => $order_id,
				)
			);
		} else {
			global $wpdb;
			$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
				$wpdb->prefix . 'ambh_donations',
				array(
					'status'         => $new_status,
					'transaction_id' => $order_id,
					'updated_at'     => current_time( 'mysql' ),
				),
				array( 'id' => (int) $donation->id ),
				array( '%s', '%s', '%s' ),
				array( '%d' )
			);
		}

		/**
		 * Fires after a PayPal webhook updates a donation status.
		 *
		 * @param int    $donation_id  Donation row ID.
		 * @param string $new_status   New normalised status.
		 * @param string $old_status   Previous status.
		 * @param string $order_id     PayPal order ID.
		 * @param string $event_type   PayPal event type string.
		 */
		do_action(
			'ambh_donation_status_changed',
			(int) $donation->id,
			$new_status,
			$old_status,
			$order_id
		);

		return new WP_REST_Response(
			array(
				'message'     => 'Donation status updated.',
				'donation_id' => (int) $donation->id,
				'old_status'  => $old_status,
				'new_status'  => $new_status,
			),
			200
		);
	}

	// -------------------------------------------------------------------------
	// Helpers
	// -------------------------------------------------------------------------

	/**
	 * Extract the PayPal order ID from a webhook resource object.
	 *
	 * The resource can be an order (has top-level `id`) or a capture object
	 * (has `supplementary_data.related_ids.order_id`).
	 *
	 * @param array<string, mixed> $resource_data Webhook resource object.
	 * @return string Order ID or empty string.
	 */
	private static function extract_order_id( array $resource_data ): string {
		// Order event — top-level id is the order ID.
		if ( ( ! empty( $resource_data['id'] ) && str_starts_with( (string) $resource_data['id'], 'EC-' ) ) ||
			! empty( $resource_data['intent'] ) ) {
			return (string) ( $resource_data['id'] ?? '' );
		}

		// Capture event — order_id is nested.
		$order_id = $resource_data['supplementary_data']['related_ids']['order_id']
			?? $resource_data['id'] // PayPal sometimes uses the order ID directly even on captures.
			?? '';

		return (string) $order_id;
	}

	/**
	 * Derive the donation status from PayPal's own verified order data.
	 *
	 * This is the actual authorization check for status changes: it never
	 * trusts the webhook payload's event_type, only what PayPal's API itself
	 * reports for this order right now. A capture sub-status takes priority
	 * over the top-level order status when present, since that's the more
	 * specific/authoritative signal for whether funds actually moved.
	 *
	 * @param array<string, mixed> $verified_order Order object returned by fetch_order().
	 * @return string|null Internal status ('pending'|'completed'|'failed'), or
	 *                      null if nothing actionable can be derived.
	 */
	private static function derive_status_from_verified_order( array $verified_order ): ?string {
		$capture_status = $verified_order['purchase_units'][0]['payments']['captures'][0]['status'] ?? null;

		if ( null !== $capture_status ) {
			return match ( $capture_status ) {
				'COMPLETED'                        => 'completed',
				'PENDING'                          => 'pending',
				'DECLINED', 'FAILED'                => 'failed',
				'REFUNDED', 'PARTIALLY_REFUNDED'   => 'failed',
				default                             => null,
			};
		}

		$order_status = $verified_order['status'] ?? null;

		return match ( $order_status ) {
			'COMPLETED'                                        => 'completed',
			'APPROVED', 'CREATED', 'SAVED', 'PAYER_ACTION_REQUIRED' => 'pending',
			'VOIDED'                                            => 'failed',
			default                                              => null,
		};
	}

	/**
	 * Fetch a PayPal order from the API to verify it exists and is owned by us.
	 *
	 * @param string $order_id PayPal order ID.
	 * @return array<string, mixed>|null Decoded order object, or null on failure.
	 */
	private static function fetch_order( string $order_id ): ?array {
		$access_token = Actions::get_access_token();
		if ( empty( $access_token ) ) {
			return null;
		}

		// External service call — see readme.txt's "External Services" section
		// (PayPal). Triggered when a PayPal webhook event arrives; re-fetches
		// the order to verify it (see class docblock — never trusts the
		// webhook payload's own claimed status).
		$response = wp_remote_get(
			trailingslashit( PayPalPspConnector::get_api_base() ) . 'v2/checkout/orders/' . rawurlencode( $order_id ),
			array(
				'headers' => array(
					'Authorization' => 'Bearer ' . $access_token,
					'Content-Type'  => 'application/json',
				),
				'timeout' => 10,
			)
		);

		if ( is_wp_error( $response ) ) {
			return null;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		if ( 200 !== $code ) {
			return null;
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );
		return is_array( $body ) ? $body : null;
	}

	/**
	 * Capture an approved PayPal order.
	 *
	 * Called when CHECKOUT.ORDER.APPROVED is received so the funds are settled
	 * immediately. The resulting PAYMENT.CAPTURE.COMPLETED event will update the
	 * donation status in a subsequent webhook call.
	 *
	 * @param string $order_id PayPal order ID.
	 * @return bool True if capture request was accepted (201), false otherwise.
	 */
	private static function capture_order( string $order_id ): bool {
		$access_token = Actions::get_access_token();
		if ( empty( $access_token ) ) {
			return false;
		}

		// External service call — see readme.txt's "External Services" section
		// (PayPal). Triggered by a verified CHECKOUT.ORDER.APPROVED webhook
		// event, to actually capture the payment.
		$response = wp_remote_post(
			trailingslashit( PayPalPspConnector::get_api_base() ) . 'v2/checkout/orders/' . rawurlencode( $order_id ) . '/capture',
			array(
				'headers' => array(
					'Authorization' => 'Bearer ' . $access_token,
					'Content-Type'  => 'application/json',
				),
				'body'    => '{}',
				'timeout' => 15,
			)
		);

		if ( is_wp_error( $response ) ) {
			return false;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		return 201 === $code || ( $code >= 200 && $code < 300 );
	}

	/**
	 * Resolve a donation row from a donation_id or a transaction_id lookup.
	 *
	 * @param int    $donation_id  Donation row ID (from custom_id; 0 if absent).
	 * @param string $order_id     PayPal order ID (used as fallback lookup).
	 * @return object|null
	 */
	private static function resolve_donation( int $donation_id, string $order_id ): ?object {
		global $wpdb;

		if ( $donation_id > 0 ) {
			if ( class_exists( \AmbassadorHub\Donations\DonationsModule::class ) ) {
				$row = \AmbassadorHub\Donations\DonationsModule::get( $donation_id );
				if ( $row ) {
					return $row;
				}
			}
		}

		// Fall back to transaction_id lookup.
		return $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}ambh_donations
				 WHERE transaction_id = %s
				 ORDER BY id DESC
				 LIMIT 1",
				$order_id
			)
		);
	}
}
