<?php
/**
 * File Path: includes/payment/class-mbs-paypal-adapter.php
 *
 * PayPal Payment Adapter
 *
 * Implements MBS_Payment_Processor for PayPal IPN (Instant Payment
 * Notification) events.
 *
 * Webhook setup (production):
 *   1. In the PayPal account under Account Settings → Notifications → IPN,
 *      enable IPN and set the notification URL to:
 *      https://yoursite.com/wp-json/mbs/v1/webhook/paypal
 *   2. In each PayPal button/product, set the custom field to encode the tier:
 *      custom = mbs_tier:pro   (for the PRO product)
 *      custom = mbs_tier:pub   (for the Publisher product)
 *   3. Toggle sandbox mode in Settings → License → Payment → PayPal Sandbox.
 *      E-002: Defaults to false (live). Enable sandbox mode explicitly in
 *      settings for local/staging testing.
 *
 * Signature verification (IPN protocol):
 *   PayPal IPN does not use a shared secret or HMAC. Instead, verification
 *   requires POSTing the raw IPN body back to PayPal's IPN verification URL
 *   with "cmd=_notify-validate" prepended. PayPal responds with either
 *   "VERIFIED" or "INVALID". Only VERIFIED events are processed.
 */

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

class MBS_PayPal_Adapter implements MBS_Payment_Processor {

	/** PayPal live IPN verification endpoint. */
	const VERIFY_URL_LIVE    = 'https://ipnpb.paypal.com/cgi-bin/webscr';

	/** PayPal sandbox IPN verification endpoint. */
	const VERIFY_URL_SANDBOX = 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr';

	/** wp_options key for sandbox toggle. */
	const SANDBOX_OPTION = 'mbs_setting_payment_paypal_sandbox';

	/**
	 * 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 PayPal IPN REST endpoint.
	 * Hook: rest_api_init
	 */
	/**
	 * Register the rest_api_init hook for the PayPal 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/paypal', 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 an IPN event by posting back to PayPal's verification endpoint.
	 *
	 * Returns true only when PayPal responds exactly "VERIFIED".
	 * Any network failure, non-200 response, or "INVALID" response returns false.
	 *
	 */
	public static function verify_signature( WP_REST_Request $request ): bool {
		$raw_body = $request->get_body();

		if ( empty( $raw_body ) ) {
			return false;
		}

		// E-002: Default is false (live). Previously defaulted to true (sandbox),
		// which caused all real payments on a fresh deployment to be verified
		// against the sandbox endpoint, returning INVALID and issuing no license.
		$use_sandbox = (bool) get_option( self::SANDBOX_OPTION, false );
		$verify_url  = $use_sandbox ? self::VERIFY_URL_SANDBOX : self::VERIFY_URL_LIVE;

		$response = wp_remote_post( $verify_url, array(
			'body'    => 'cmd=_notify-validate&' . $raw_body,
			'timeout' => 20,
			'headers' => array(
				'Content-Type' => 'application/x-www-form-urlencoded',
				'User-Agent'   => 'MyBookShowroom-IPN/' . MBS_VERSION,
			),
		) );

		if ( is_wp_error( $response ) ) {
			// Network failure — return false. handle() will still return 200 per
			// IPN protocol; the event will be retried by PayPal automatically.
			return false;
		}

		return 'VERIFIED' === trim( wp_remote_retrieve_body( $response ) );
	}

	/**
	 * Extract normalized payment data from a PayPal IPN POST body.
	 * Returns null when payment_status is not "Completed" or required fields
	 * are absent — these events are acknowledged but not processed.
	 *
	 * Tier is read from the IPN `custom` field using the convention:
	 *   custom = mbs_tier:pro   or   custom = mbs_tier:pub
	 *
	 * @return array|null
	 */
	public static function extract_payment_data( WP_REST_Request $request ): ?array {
		$ipn = array();
		parse_str( $request->get_body(), $ipn );

		$payment_status = sanitize_text_field( $ipn['payment_status'] ?? '' );

		if ( 'Completed' !== $payment_status ) {
			return null;
		}

		$email    = sanitize_email( $ipn['payer_email'] ?? '' );
		$tx_id    = sanitize_text_field( $ipn['txn_id'] ?? '' );
		$amount   = (float) ( $ipn['mc_gross'] ?? 0 );
		$currency = strtoupper( sanitize_text_field( $ipn['mc_currency'] ?? 'USD' ) );
		$custom   = sanitize_text_field( $ipn['custom'] ?? '' );

		// Parse tier from custom field: "mbs_tier:pro", "mbs_tier:pub", or "mbs_tier:dev".
		// dev is a sandbox/testing alias that maps to pub tier.
		$tier = '';
		if ( preg_match( '/mbs_tier:(pro|pub|dev)/i', $custom, $m ) ) {
			$tier = strtolower( $m[1] );
			if ( 'dev' === $tier ) {
				$tier = 'pub'; // dev tier maps to pub for provisioning purposes
			}
		}

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

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

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

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

	/**
	 * Handle an incoming PayPal IPN POST.
	 * Called by WordPress REST API on POST /wp-json/mbs/v1/webhook/paypal.
	 *
	 * Per PayPal IPN protocol, always return HTTP 200 — even for invalid or
	 * unrecognized events — otherwise PayPal will retry for up to 4 days.
	 *
	 * @return WP_REST_Response Always 200.
	 */
	public static function handle( WP_REST_Request $request ): WP_REST_Response {
		if ( ! static::verify_signature( $request ) ) {
			// Must still return 200 per IPN protocol, but do not process.
			return new WP_REST_Response( null, 200 );
		}

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

		if ( null === $data ) {
			return new WP_REST_Response( null, 200 );
		}

		// F-08: replay/idempotency guard. PayPal IPN has no timestamp/replay window,
		// so a captured valid body can be re-POSTed. Short-circuit a txn_id we have
		// already provisioned BEFORE provision()/mbs_payment_completed re-fire.
		$event_key = 'paypal:' . (string) ( $data['tx_id'] ?? '' );
		if ( '' !== (string) ( $data['tx_id'] ?? '' ) && self::is_duplicate_event( $event_key ) ) {
			return new WP_REST_Response( null, 200 );
		}

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

		if ( ! $ok ) {
			// F-01 (Fourth audit): a VERIFIED IPN whose provisioning failed returns a
			// non-2xx so PayPal retries. Safe now that server provisioning is idempotent
			// on txn_id; the event is NOT recorded on failure. Non-actionable / unverified
			// events above still return 200 per the IPN protocol.
			return new WP_REST_Response( null, 502 );
		}

		self::record_event( $event_key );
		do_action( 'mbs_payment_completed', $data['email'], $data['tier'], 'paypal', $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( null, 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.
	 *
	 * On failure, fires do_action( 'mbs_provision_failed', 'paypal', $data, $code )
	 * so the failure can be handled (e.g. stored as a transient for admin notice)
	 * 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
				'paypal',
				$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', 'paypal', $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', 'paypal', $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;
	}
}
