<?php
/**
 * Stripe Webhook Handler
 *
 * REST endpoint that receives Stripe webhook events and converts them into
 * ambassador-fundraiser donation status updates.
 *
 * ## Endpoint
 *
 *   POST /wp-json/ambassadorhub/v1/webhook/stripe
 *
 * Add this URL as a webhook endpoint in the Stripe Dashboard under
 * Developers → Webhooks.  Select at least the following event types:
 *   - checkout.session.completed
 *   - checkout.session.expired
 *   - payment_intent.payment_failed
 *
 * ## Security
 *
 * Every incoming request is authenticated by verifying the Stripe-Signature
 * header using the webhook signing secret (whsec_…) stored in settings.
 * The raw request body is used as-is for the HMAC-SHA256 computation — JSON
 * must not be parsed before verification.  Requests without a valid signature
 * are rejected with a 401 response.
 *
 * @package AmbassadorHub\Connectors\Stripe
 */

declare( strict_types=1 );

namespace AmbassadorHub\Connectors\Stripe;

use WP_REST_Request;
use WP_REST_Response;

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

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

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

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

	/**
	 * Stripe event type → internal donation status mapping.
	 *
	 * Events not listed here are acknowledged but not acted upon.
	 */
	private const EVENT_STATUS_MAP = array(
		'checkout.session.completed'    => 'completed',
		'checkout.session.expired'      => 'failed',
		'payment_intent.payment_failed' => 'failed',
	);

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

	/**
	 * Register the REST route with WordPress.
	 *
	 * Called via the `ambh_register_rest_routes` action from
	 * StripePspConnector::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 HMAC signature below.
			)
		);
	}

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

	/**
	 * Process the incoming Stripe webhook event.
	 *
	 * Stripe POSTs a JSON body and signs it with the webhook signing secret via
	 * the Stripe-Signature header.  This handler verifies the signature before
	 * acting on any event, preventing spoofed callbacks.
	 *
	 * @param WP_REST_Request $request Incoming REST request.
	 * @return WP_REST_Response
	 */
	public static function handle( WP_REST_Request $request ): WP_REST_Response {
		$raw_body  = $request->get_body();
		$signature = $request->get_header( 'stripe_signature' );

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

		if ( ! self::verify_signature( $raw_body, (string) $signature ) ) {
			return new WP_REST_Response( array( 'error' => 'Invalid webhook signature.' ), 401 );
		}

		$event = json_decode( $raw_body, true );
		if ( ! is_array( $event ) ) {
			return new WP_REST_Response( array( 'error' => 'Could not parse event JSON.' ), 400 );
		}

		$event_type = sanitize_text_field( $event['type'] ?? '' );
		if ( empty( $event_type ) ) {
			return new WP_REST_Response( array( 'error' => 'Missing event type.' ), 400 );
		}

		// Map the event type to an internal status.
		$new_status = self::EVENT_STATUS_MAP[ $event_type ] ?? null;
		if ( null === $new_status ) {
			// Not an event we care about — acknowledge and move on.
			return new WP_REST_Response( array( 'message' => 'Event not actionable: ' . $event_type ), 200 );
		}

		$session = $event['data']['object'] ?? array();

		// Resolve the donation ID: prefer metadata[donation_id], fall back to
		// client_reference_id (which we also set on session creation).
		$donation_id = (int) ( $session['metadata']['donation_id'] ?? $session['client_reference_id'] ?? 0 );
		$session_id  = sanitize_text_field( $session['id'] ?? '' );

		$donation = self::resolve_donation( $donation_id, $session_id );
		if ( ! $donation ) {
			return new WP_REST_Response(
				array( 'error' => 'Donation not found for session: ' . $session_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' => $session_id,
				)
			);
		} else {
			global $wpdb;
			$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
				$wpdb->prefix . 'ambh_donations',
				array(
					'status'         => $new_status,
					'transaction_id' => $session_id,
					'updated_at'     => current_time( 'mysql' ),
				),
				array( 'id' => (int) $donation->id ),
				array( '%s', '%s', '%s' ),
				array( '%d' )
			);
		}

		/**
		 * Fires after a Stripe 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 $session_id   Stripe Checkout Session ID.
		 * @param string $event_type   Stripe event type string.
		 */
		do_action(
			'ambh_donation_status_changed',
			(int) $donation->id,
			$new_status,
			$old_status,
			$session_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
		);
	}

	// -------------------------------------------------------------------------
	// Signature verification
	// -------------------------------------------------------------------------

	/**
	 * Verify the Stripe-Signature header against the raw request body.
	 *
	 * Stripe signs each webhook with HMAC-SHA256, embedding the signature in
	 * the Stripe-Signature header as: t=<timestamp>,v1=<hex_signature>
	 *
	 * The signed payload is: "<timestamp>.<raw_body>"
	 *
	 * @param string $raw_body  Raw (un-parsed) request body.
	 * @param string $signature Value of the Stripe-Signature header.
	 * @return bool True when the signature is valid.
	 */
	private static function verify_signature( string $raw_body, string $signature ): bool {
		$signing_secret = (string) get_option( StripePspConnector::OPTION_WEBHOOK_SECRET, '' );
		if ( empty( $signing_secret ) ) {
			// No secret configured — reject to avoid processing unverified events.
			return false;
		}

		// Parse timestamp and v1 signature from the header.
		$timestamp = '';
		$v1        = '';

		foreach ( explode( ',', $signature ) as $part ) {
			if ( str_starts_with( $part, 't=' ) ) {
				$timestamp = substr( $part, 2 );
			} elseif ( str_starts_with( $part, 'v1=' ) ) {
				$v1 = substr( $part, 3 );
			}
		}

		if ( empty( $timestamp ) || empty( $v1 ) ) {
			return false;
		}

		// Replay-attack guard: reject events older than 5 minutes.
		if ( abs( time() - (int) $timestamp ) > 300 ) {
			return false;
		}

		$signed_payload = $timestamp . '.' . $raw_body;
		$expected       = hash_hmac( 'sha256', $signed_payload, $signing_secret );

		return hash_equals( $expected, $v1 );
	}

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

	/**
	 * Resolve a donation row from a donation_id or a transaction_id lookup.
	 *
	 * @param int    $donation_id Donation row ID (from metadata; 0 if absent).
	 * @param string $session_id  Stripe Checkout Session ID (fallback lookup).
	 * @return object|null
	 */
	private static function resolve_donation( int $donation_id, string $session_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",
				$session_id
			)
		);
	}
}
