<?php
/**
 * File Path: includes/payment/class-mbs-stripe-adapter.php
 *
 * Stripe Payment Adapter
 *
 * Implements MBS_Payment_Processor for Stripe Checkout webhook events.
 *
 * Webhook setup (production):
 *   1. In the Stripe dashboard, create a webhook endpoint pointing to:
 *      https://yoursite.com/wp-json/mbs/v1/webhook/stripe
 *   2. Enable only the event: checkout.session.completed
 *   3. Copy the signing secret (whsec_...) to:
 *      Settings → License → Payment → Stripe Webhook Secret
 *      (stored encrypted via MBS_Encryption, never logged)
 *   4. In each Stripe product's metadata, add:
 *      mbs_tier = pro   (for the PRO product)
 *      mbs_tier = pub   (for the Publisher product)
 *
 * Signature verification:
 *   Stripe signs every event with HMAC-SHA256 over the string
 *   "{timestamp}.{raw_payload}" using the endpoint signing secret.
 *   The Stripe-Signature header format:
 *     t=<unix_timestamp>,v1=<hex_hmac>[,v1=<hex_hmac>...]
 *   Events older than MBS_HMAC_WINDOW (300s) are rejected.
 */

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

class MBS_Stripe_Adapter implements MBS_Payment_Processor {

	/**
	 * wp_options key where the encrypted Stripe webhook secret is stored.
	 * Set via the admin payment settings tab (Sprint 9+).
	 */
	const WEBHOOK_SECRET_OPTION = 'mbs_setting_payment_stripe_webhook_secret_encrypted';

	/**
	 * wp_options key holding the encrypted shared global provisioning secret.
	 * Must equal the server's MBS_HMAC_SECRET; used to HMAC-sign /license/provision (F-01).
	 */
	const PROVISION_SECRET_OPTION = 'mbs_setting_payment_provision_secret_encrypted';

	// =========================================================================
	// Interface implementation
	// =========================================================================

	/**
	 * Register the Stripe webhook REST endpoint.
	 * Hook: rest_api_init
	 */
	/**
	 * Register the rest_api_init hook for the Stripe webhook endpoint.
	 * Moved from MBS_Plugin::load_payment_hooks() — REFACTOR-PLAN-ARCH-001 Batch E.
	 */
	public static function register_hooks(): void {
		add_action( 'rest_api_init', array( __CLASS__, 'register_webhook' ) );
	}

	public static function register_webhook(): void {
		register_rest_route( 'mbs/v1', '/webhook/stripe', array(
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => array( __CLASS__, 'handle' ),
			// Stripe requires an unauthenticated public endpoint; authentication is performed
			// via Stripe-Signature HMAC verification in verify_signature(). See SECURITY.md Rule S14-3.
			'permission_callback' => '__return_true',
		) );
	}

	/** Verify the Stripe-Signature header using HMAC-SHA256. */
	public static function verify_signature( WP_REST_Request $request ): bool {
		$raw_body = $request->get_body();
		$header   = $request->get_header( 'Stripe-Signature' );
		$secret   = mbs_decrypt_option( self::WEBHOOK_SECRET_OPTION );

		if ( empty( $secret ) || empty( $header ) || empty( $raw_body ) ) {
			return false;
		}

		$timestamp  = '';
		$signatures = array();

		foreach ( explode( ',', $header ) as $part ) {
			$pair = explode( '=', $part, 2 );
			if ( count( $pair ) !== 2 ) {
				continue;
			}
			if ( 't' === $pair[0] ) {
				$timestamp = $pair[1];
			} elseif ( 'v1' === $pair[0] ) {
				$signatures[] = $pair[1];
			}
		}

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

		// Reject events outside the 5-minute replay window (MBS_HMAC_WINDOW).
		if ( abs( time() - (int) $timestamp ) > MBS_HMAC_WINDOW ) {
			return false;
		}

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

		foreach ( $signatures as $sig ) {
			if ( hash_equals( $expected, $sig ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Extract normalized payment data from a Stripe checkout.session.completed event.
	 * Returns null for any other event type.
	 *
	 * @return array|null
	 */
	public static function extract_payment_data( WP_REST_Request $request ): ?array {
		$event = json_decode( $request->get_body(), true );

		if ( ! is_array( $event ) || ( $event['type'] ?? '' ) !== 'checkout.session.completed' ) {
			return null;
		}

		$session  = $event['data']['object'] ?? array();
		$email    = sanitize_email( $session['customer_details']['email'] ?? '' );
		$tier     = sanitize_key( $session['metadata']['mbs_tier'] ?? '' );
		$tx_id    = sanitize_text_field( $session['id'] ?? '' );
		$amount   = absint( $session['amount_total'] ?? 0 ) / 100; // Stripe: pence/cents
		$currency = strtoupper( sanitize_text_field( $session['currency'] ?? 'USD' ) );

		if ( empty( $email ) || ! in_array( $tier, array( 'pro', 'pub' ), true ) || empty( $tx_id ) ) {
			return null;
		}

		return array(
			'email'     => $email,
			'tier'      => $tier,
			'tx_id'     => $tx_id,
			'amount'    => $amount,
			'currency'  => $currency,
			'processor' => 'stripe',
		);
	}

	/** Provision a license on the API server after a verified Stripe payment. */
	public static function provision( array $data ): bool {
		return self::call_provision_endpoint( $data );
	}

	// =========================================================================
	// Webhook handler (REST callback)
	// =========================================================================

	/**
	 * Handle an incoming Stripe webhook POST.
	 * Called by WordPress REST API on POST /wp-json/mbs/v1/webhook/stripe.
	 *
	 * @return WP_REST_Response
	 */
	public static function handle( WP_REST_Request $request ): WP_REST_Response {
		if ( ! static::verify_signature( $request ) ) {
			return new WP_REST_Response( array( 'error' => 'Invalid signature.' ), 401 );
		}

		$data = static::extract_payment_data( $request );

		if ( null === $data ) {
			// Event type we don't act on — acknowledge receipt and move on.
			return new WP_REST_Response( array( 'received' => true ), 200 );
		}

		// F-08: replay/idempotency guard. A valid Stripe event replayed inside its
		// 300s signature window would re-provision. Short-circuit an event/session id
		// we have already provisioned BEFORE provision()/mbs_payment_completed re-fire.
		$event_key = 'stripe:' . (string) ( $data['tx_id'] ?? '' );
		if ( '' !== (string) ( $data['tx_id'] ?? '' ) && self::is_duplicate_event( $event_key ) ) {
			return new WP_REST_Response( array( 'received' => true, 'duplicate' => true ), 200 );
		}

		$ok = static::provision( $data );

		if ( ! $ok ) {
			// F-01 (Fourth audit): return a non-2xx so Stripe RETRIES later. Safe now
			// that server provisioning is idempotent on the session id — a retry cannot
			// double-mint. The event is NOT recorded on failure (see below), so a
			// successful retry provisions normally. The failure is still surfaced via
			// the mbs_provision_failed action hook for admin handling.
			return new WP_REST_Response( array( 'received' => false, 'provisioned' => false ), 502 );
		}

		self::record_event( $event_key );
		do_action( 'mbs_payment_completed', $data['email'], $data['tier'], 'stripe', $data['tx_id'], $data['amount'] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

		return new WP_REST_Response( array( 'received' => true, 'provisioned' => true ), 200 );
	}

	/**
	 * F-08: has this payment event/transaction already been provisioned?
	 *
	 * @param string $key Processor-scoped key, e.g. 'stripe:cs_123'.
	 * @return bool
	 */
	private static function is_duplicate_event( string $key ): bool {
		$seen = get_option( 'mbs_processed_payment_events', array() );
		return is_array( $seen ) && isset( $seen[ $key ] );
	}

	/**
	 * F-08: record a processed payment event/transaction (idempotency ledger).
	 * The ledger is purged by TTL (MBS_HMAC_WINDOW) to bound option growth.
	 *
	 * @param string $key Processor-scoped key.
	 * @return void
	 */
	private static function record_event( string $key ): void {
		$seen = get_option( 'mbs_processed_payment_events', array() );
		if ( ! is_array( $seen ) ) {
			$seen = array();
		}
		$now = time();
		// F-08 (Fourth audit): TTL purge instead of a count cap. An event older than
		// the HMAC replay window (MBS_HMAC_WINDOW) can never be re-verified, so it is
		// safe to drop; this bounds the option by time, not an arbitrary 1000 rows.
		// MULTISITE NOTE: wp_options is per-blog, so this ledger is scoped to the blog
		// that received the webhook. Point the processor webhook at the network's main
		// site (or accept per-sub-site scoping) on multisite installs.
		foreach ( $seen as $k => $ts ) {
			if ( ! is_int( $ts ) || $ts < ( $now - MBS_HMAC_WINDOW ) ) {
				unset( $seen[ $k ] );
			}
		}
		$seen[ $key ] = $now;
		update_option( 'mbs_processed_payment_events', $seen, false );
	}

	// =========================================================================
	// Private helpers
	// =========================================================================

	/**
	 * POST payment data to the API server's /license/provision endpoint.
	 *
	 * The server creates the paid license row, logs the transaction in
	 * mbs_transactions, and emails the license key to the customer.
	 *
	 * On failure, fires do_action( 'mbs_provision_failed', 'stripe', $data,
	 * $code, $detail ) so the failure can be handled without writing customer
	 * PII to server logs.
	 *
	 */
	/**
	 * F-01: Canonical provisioning HMAC string. MUST byte-match the server's
	 * LicenseProvisionController::canonical():
	 *   processor|tx_id|email|tier|amount|currency|timestamp|nonce
	 * amount is normalised to two decimals so float formatting cannot desync.
	 *
	 * @param array  $data      Normalized payment data.
	 * @param int    $timestamp Request timestamp.
	 * @param string $nonce     Per-request nonce.
	 * @return string
	 */
	public static function provision_canonical( array $data, int $timestamp, string $nonce ): string {
		return implode( '|', array(
			(string) ( $data['processor'] ?? '' ),
			(string) ( $data['tx_id'] ?? '' ),
			(string) ( $data['email'] ?? '' ),
			(string) ( $data['tier'] ?? '' ),
			number_format( (float) ( $data['amount'] ?? 0 ), 2, '.', '' ),
			strtoupper( (string) ( $data['currency'] ?? 'USD' ) ),
			(string) $timestamp,
			$nonce,
		) );
	}

	/**
	 * F-01: HMAC-SHA256 signature over the provisioning canonical string.
	 *
	 * @param array  $data
	 * @param int    $timestamp
	 * @param string $nonce
	 * @param string $secret    Shared global provisioning secret.
	 * @return string
	 */
	public static function provision_signature( array $data, int $timestamp, string $nonce, string $secret ): string {
		return hash_hmac( 'sha256', self::provision_canonical( $data, $timestamp, $nonce ), $secret );
	}

	/**
	 * F-01: Decrypt the shared global provisioning secret from settings. Empty
	 * string when unconfigured (the server then rejects the unsigned call).
	 *
	 * @return string
	 */
	public static function provision_secret(): string {
		return (string) mbs_decrypt_option( self::PROVISION_SECRET_OPTION );
	}

	private static function call_provision_endpoint( array $data ): bool {
		// E-001: Customer PII must not travel over plain HTTP in production.
		if ( ! str_starts_with( MBS_API_BASE, 'https://' ) ) {
			do_action(
				'mbs_provision_failed', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
				'stripe',
				$data,
				0,
				'Provision aborted: MBS_API_BASE must use https:// in production.'
			);
			return false;
		}

		// F-01 (Fourth audit): authenticate the provision call with a shared-secret
		// HMAC + timestamp + nonce. The server's POST /license/provision rejects
		// unsigned / expired / mismatched requests, so it only mints for a request
		// this adapter actually signed with the shared global provisioning secret.
		$timestamp = time();
		$nonce     = function_exists( 'wp_generate_password' )
			? wp_generate_password( 16, false, false )
			: bin2hex( random_bytes( 8 ) );
		$secret    = self::provision_secret();

		$payload              = $data;
		$payload['timestamp'] = $timestamp;
		$payload['nonce']     = $nonce;
		$payload['hmac']      = '' !== $secret
			? self::provision_signature( $data, $timestamp, $nonce, $secret )
			: '';

		$response = wp_remote_post( MBS_API_BASE . '/license/provision', array(
			'headers' => array( 'Content-Type' => 'application/json' ),
			'body'    => wp_json_encode( $payload ),
			'timeout' => 20,
		) );

		if ( is_wp_error( $response ) ) {
			do_action( 'mbs_provision_failed', 'stripe', $data, 0, $response->get_error_message() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
			return false;
		}

		$code = wp_remote_retrieve_response_code( $response );
		$body = json_decode( wp_remote_retrieve_body( $response ), true );

		if ( 201 !== $code || empty( $body['success'] ) ) {
			do_action( 'mbs_provision_failed', 'stripe', $data, $code, wp_remote_retrieve_body( $response ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
			return false;
		}

		return true;
	}
}
