<?php
/**
 * AJAX handler for connecting a new PayPal account via GiveWP's onboarding.
 *
 * @since   1.1.0
 * @package DonationRouter
 */

declare( strict_types=1 );
namespace DonationRouter;

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

/**
 * Drives the "Connect with PayPal" flow on the add-account page. Unlike GiveWP
 * core (one account per mode), each successful connection is saved as a new
 * managed account in this plugin's own table, so unlimited PayPal Business
 * accounts can be connected — one per country, for example.
 *
 * The flow is driven by PayPal's partner JS SDK onboarding popup, plus a
 * `sendBeacon` call and a redirect-time completion:
 *
 *  1. `partner_link` — validates the operator's account fields, obtains a PayPal
 *     partner referral link (via {@see GiveWP_Onboarding_Bridge}, reusing GiveWP's
 *     approved partner) whose returnUrl carries a `state` token, and stashes the
 *     pending fields + seller nonce keyed by that token. Returns the link + token
 *     to the browser, which renders the PayPal onboarding button.
 *  2. `store_codes` — PayPal's onboarding callback fires inside the popup before
 *     the top window navigates to the returnUrl, so the browser uses
 *     `navigator.sendBeacon()` to post the `authCode` / `sharedId` codes with the
 *     state token. This is the only place the one-time auth code is exchanged
 *     (see {@see exchange_and_cache()}): the codes are merged into the pending
 *     record, immediately exchanged for the seller's REST credentials, and the
 *     result (credentials or error) is cached back onto the pending record —
 *     the raw codes are then discarded so nothing can re-exchange them.
 *  3. `complete_onboarding()` — invoked when the top window lands back on the
 *     returnUrl (carrying `merchantIdInPayPal` plus the `ador_state` query arg);
 *     once the pending record holds both the cached credentials and the
 *     merchant ID, this saves the account. It never exchanges the auth code
 *     itself, so it is safe to call repeatedly (e.g. redirect + poll).
 *
 * @since 1.1.0
 */
class PayPal_Connect_Handler {

	/**
	 * AJAX action: obtain a PayPal partner onboarding link.
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	public const ACTION_PARTNER_LINK = 'ador_connect_partner_link';

	/**
	 * AJAX action: persist the onboarding authorization codes (beacon).
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	public const ACTION_STORE_CODES = 'ador_connect_store_codes';

	/**
	 * Nonce action for both AJAX calls.
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	public const NONCE_ACTION = 'ador_connect';

	/**
	 * Pending-onboarding transient prefix.
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	private const PENDING_PREFIX = 'ador_connect_';

	/**
	 * Pending-onboarding TTL in seconds (15 minutes).
	 *
	 * @since 1.1.0
	 *
	 * @var int
	 */
	private const PENDING_TTL = 900;

	/**
	 * Account data source.
	 *
	 * @since 1.1.0
	 *
	 * @var Account_Repository_Interface
	 */
	private Account_Repository_Interface $account_repo;

	/**
	 * GiveWP onboarding adapter.
	 *
	 * @since 1.1.0
	 *
	 * @var GiveWP_Onboarding_Bridge
	 */
	private GiveWP_Onboarding_Bridge $bridge;

	/**
	 * Initialise dependencies.
	 *
	 * @since 1.1.0
	 *
	 * @param Account_Repository_Interface  $account_repo Account data source.
	 * @param GiveWP_Onboarding_Bridge|null $bridge       Onboarding adapter; defaults to a real one.
	 */
	public function __construct( Account_Repository_Interface $account_repo, ?GiveWP_Onboarding_Bridge $bridge = null ) {
		$this->account_repo = $account_repo;
		$this->bridge       = $bridge ?? new GiveWP_Onboarding_Bridge();
	}

	/**
	 * Register the AJAX hooks.
	 *
	 * @since 1.1.0
	 *
	 * @return void
	 */
	public function register_hooks(): void {
		add_action( 'wp_ajax_' . self::ACTION_PARTNER_LINK, array( $this, 'handle_partner_link' ) );
		add_action( 'wp_ajax_' . self::ACTION_STORE_CODES, array( $this, 'handle_store_codes' ) );
	}

	/**
	 * AJAX: validate the operator's fields and return a PayPal onboarding link.
	 *
	 * @since 1.1.0
	 *
	 * @return void
	 */
	public function handle_partner_link(): void {
		$this->guard();

		// Nonce is verified in guard() above.
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing
		$fields = $this->sanitize_fields( $_POST );
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing
		$mode = $this->sanitize_mode( (string) wp_unslash( $_POST['mode'] ?? 'live' ) );

		$error = $this->validate_fields( $fields );
		if ( null !== $error ) {
			wp_send_json_error( array( 'message' => $error ) );
		}

		$state      = bin2hex( random_bytes( 16 ) );
		$return_url = add_query_arg( 'ador_state', $state, $this->accounts_url() );
		$referral   = $this->bridge->partner_referral( $mode, $return_url );

		if ( is_wp_error( $referral ) ) {
			wp_send_json_error( array( 'message' => $referral->get_error_message() ) );
		}

		set_transient(
			self::PENDING_PREFIX . $state,
			array(
				'fields' => $fields,
				'mode'   => $mode,
				'nonce'  => (string) $referral['nonce'],
			),
			self::PENDING_TTL
		);

		wp_send_json_success(
			array(
				'actionUrl' => (string) $referral['action_url'],
				'state'     => $state,
			)
		);
	}

	/**
	 * AJAX (beacon): persist the onboarding codes so they survive the top-window
	 * redirect. Called from adorPaypalOnboarded via navigator.sendBeacon.
	 *
	 * @since 1.1.0
	 *
	 * @return void
	 */
	public function handle_store_codes(): void {
		$this->guard();

		// phpcs:disable WordPress.Security.NonceVerification.Missing -- verified in guard().
		$state     = sanitize_text_field( (string) wp_unslash( $_POST['state'] ?? '' ) );
		$auth_code = sanitize_text_field( (string) wp_unslash( $_POST['authCode'] ?? '' ) );
		$shared_id = sanitize_text_field( (string) wp_unslash( $_POST['sharedId'] ?? '' ) );
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		if ( '' === $auth_code || '' === $shared_id || ! $this->store_codes( $state, $auth_code, $shared_id ) ) {
			status_header( 400 );
			wp_die();
		}

		$this->exchange_and_cache( $state );

		status_header( 204 );
		wp_die();
	}

	/**
	 * Capability + nonce guard shared by both AJAX handlers.
	 *
	 * @since 1.1.0
	 *
	 * @return void
	 */
	private function guard(): void {
		if ( ! current_user_can( Admin_Settings::CAPABILITY ) ) {
			wp_send_json_error( array( 'message' => __( 'You do not have permission to perform this action.', 'alamin-donation-router' ) ), 403 );
		}

		check_ajax_referer( self::NONCE_ACTION, 'nonce' );
	}

	/**
	 * The accounts list URL, used as the onboarding return URL and post-save redirect.
	 *
	 * @since 1.1.0
	 *
	 * @return string
	 */
	public function accounts_url(): string {
		return add_query_arg(
			array(
				'post_type' => 'give_forms',
				'page'      => Admin_Settings::MENU_SLUG,
				'tab'       => Admin_Settings::TAB_ACCOUNTS,
			),
			admin_url( 'edit.php' )
		);
	}

	/**
	 * Read the pending onboarding record for a state token.
	 *
	 * @since 1.1.0
	 *
	 * @param string $state State token.
	 *
	 * @return array<string,mixed>|null  The record, or null when absent/expired.
	 */
	private function get_pending( string $state ): ?array {
		if ( '' === $state ) {
			return null;
		}
		$pending = get_transient( self::PENDING_PREFIX . $state );
		return is_array( $pending ) ? $pending : null;
	}

	/**
	 * Write the pending onboarding record for a state token.
	 *
	 * @since 1.1.0
	 *
	 * @param string              $state State token.
	 * @param array<string,mixed> $data  Record to store.
	 *
	 * @return void
	 */
	private function put_pending( string $state, array $data ): void {
		set_transient( self::PENDING_PREFIX . $state, $data, self::PENDING_TTL );
	}

	/**
	 * Delete the pending onboarding record for a state token.
	 *
	 * @since 1.1.0
	 *
	 * @param string $state State token.
	 *
	 * @return void
	 */
	private function forget_pending( string $state ): void {
		delete_transient( self::PENDING_PREFIX . $state );
	}

	/**
	 * Merge the onboarding authorization codes into the pending record.
	 *
	 * @since 1.1.0
	 *
	 * @param string $state     State token.
	 * @param string $auth_code PayPal authorization code.
	 * @param string $shared_id PayPal shared ID.
	 *
	 * @return bool  True when merged; false when no pending record exists.
	 */
	public function store_codes( string $state, string $auth_code, string $shared_id ): bool {
		$pending = $this->get_pending( $state );
		if ( null === $pending ) {
			return false;
		}
		$pending['authCode'] = $auth_code;
		$pending['sharedId'] = $shared_id;
		$this->put_pending( $state, $pending );
		return true;
	}

	/**
	 * Exchange the pending auth code for seller credentials and cache them on the
	 * pending record, so the redirect-side completion never re-exchanges the
	 * one-time code. Runs in the single beacon request; idempotent.
	 *
	 * @since 1.1.0
	 *
	 * @param string $state State token.
	 *
	 * @return void
	 */
	private function exchange_and_cache( string $state ): void {
		$pending = $this->get_pending( $state );
		if ( null === $pending ) {
			return;
		}
		// Already exchanged (creds or a recorded error) — do nothing.
		if ( isset( $pending['credentials'] ) || isset( $pending['exchange_error'] ) ) {
			return;
		}
		$auth_code = (string) ( $pending['authCode'] ?? '' );
		$shared_id = (string) ( $pending['sharedId'] ?? '' );
		if ( '' === $auth_code || '' === $shared_id ) {
			return;
		}
		$mode        = (string) ( $pending['mode'] ?? 'live' );
		$credentials = $this->bridge->exchange_credentials( $mode, $auth_code, $shared_id, (string) ( $pending['nonce'] ?? '' ) );
		if ( is_wp_error( $credentials ) ) {
			$pending['exchange_error'] = $credentials->get_error_message();
		} else {
			$pending['credentials'] = $credentials;
		}
		// Consume the one-time codes so nothing can re-exchange them.
		unset( $pending['authCode'], $pending['sharedId'] );
		$this->put_pending( $state, $pending );
	}

	/**
	 * Merge the PayPal merchant ID into the pending record.
	 *
	 * @since 1.1.0
	 *
	 * @param string $state       State token.
	 * @param string $merchant_id merchantIdInPayPal from the redirect.
	 *
	 * @return bool  True when merged; false when no pending record exists.
	 */
	public function store_merchant( string $state, string $merchant_id ): bool {
		$pending = $this->get_pending( $state );
		if ( null === $pending ) {
			return false;
		}
		$pending['merchantIdInPayPal'] = $merchant_id;
		$this->put_pending( $state, $pending );
		return true;
	}

	/**
	 * Complete a connection once the pending record holds cached credentials
	 * (exchanged once by the beacon handler, see {@see exchange_and_cache()})
	 * and the merchantIdInPayPal from the redirect: save the account.
	 *
	 * @since 1.1.0
	 *
	 * @param string $state State token.
	 *
	 * @return array{status:string,redirect?:string,message?:string}
	 */
	public function complete_onboarding( string $state ): array {
		$pending = $this->get_pending( $state );
		if ( null === $pending ) {
			return array(
				'status'  => 'error',
				'message' => __( 'Your onboarding session expired. Please try connecting again.', 'alamin-donation-router' ),
			);
		}

		if ( isset( $pending['exchange_error'] ) ) {
			return array(
				'status'  => 'error',
				'message' => (string) $pending['exchange_error'],
			);
		}

		$credentials = is_array( $pending['credentials'] ?? null ) ? $pending['credentials'] : null;
		$merchant_id = (string) ( $pending['merchantIdInPayPal'] ?? '' );
		if ( null === $credentials || '' === $merchant_id ) {
			return array( 'status' => 'pending' );
		}

		$mode   = (string) ( $pending['mode'] ?? 'live' );
		$fields = is_array( $pending['fields'] ?? null ) ? $pending['fields'] : array();

		try {
			$this->account_repo->find_by_merchant_id_and_mode( $merchant_id, $mode );
			$this->forget_pending( $state );
			return array(
				'status'  => 'error',
				'message' => sprintf(
					/* translators: %s: PayPal environment (live/sandbox). */
					__( 'This PayPal account is already managed by the plugin for %s mode.', 'alamin-donation-router' ),
					$mode
				),
			);
		} catch ( Account_Not_Found_Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Not yet managed — proceed.
		}

		$credentials['merchantIdInPayPal'] = $merchant_id;

		try {
			$now     = new \DateTimeImmutable();
			$account = new PayPal_Account(
				id: null,
				account_label: (string) ( $fields['account_label'] ?? '' ),
				merchant_id: $merchant_id,
				account_country: (string) ( $fields['account_country'] ?? 'US' ),
				legal_entity_name: (string) ( $fields['legal_entity_name'] ?? '' ),
				legal_entity_tax_id: (string) ( $fields['legal_entity_tax_id'] ?? '' ),
				legal_entity_address: (string) ( $fields['legal_entity_address'] ?? '' ),
				credentials: $credentials,
				webhook_id: null,
				is_active: true,
				created_at: $now,
				updated_at: $now,
				mode: $mode,
			);
			$this->account_repo->save( $account );
		} catch ( \InvalidArgumentException | Persistence_Exception $e ) {
			return array(
				'status'  => 'error',
				'message' => $e->getMessage(),
			);
		}

		$this->forget_pending( $state );
		return array(
			'status'   => 'done',
			'redirect' => $this->accounts_url(),
		);
	}

	/**
	 * Normalise an environment string.
	 *
	 * @since 1.1.0
	 *
	 * @param string $mode Raw mode value.
	 *
	 * @return string 'sandbox' or 'live'.
	 */
	private function sanitize_mode( string $mode ): string {
		return 'sandbox' === strtolower( trim( $mode ) ) ? 'sandbox' : 'live';
	}

	/**
	 * Sanitize the account fields from the request.
	 *
	 * @since 1.1.0
	 *
	 * @param array<string,mixed> $post Raw POST data.
	 *
	 * @return array<string,string>
	 */
	private function sanitize_fields( array $post ): array {
		return array(
			'account_label'        => sanitize_text_field( wp_unslash( (string) ( $post['account_label'] ?? '' ) ) ),
			'account_country'      => strtoupper( sanitize_text_field( wp_unslash( (string) ( $post['account_country'] ?? '' ) ) ) ),
			'legal_entity_name'    => sanitize_text_field( wp_unslash( (string) ( $post['legal_entity_name'] ?? '' ) ) ),
			'legal_entity_tax_id'  => sanitize_text_field( wp_unslash( (string) ( $post['legal_entity_tax_id'] ?? '' ) ) ),
			'legal_entity_address' => sanitize_textarea_field( wp_unslash( (string) ( $post['legal_entity_address'] ?? '' ) ) ),
		);
	}

	/**
	 * Validate the sanitized account fields.
	 *
	 * @since 1.1.0
	 *
	 * @param array<string,string> $fields Sanitized fields.
	 *
	 * @return string|null Error message, or null when valid.
	 */
	private function validate_fields( array $fields ): ?string {
		if ( '' === $fields['account_label'] ) {
			return __( 'Account Label is required.', 'alamin-donation-router' );
		}

		$country = $fields['account_country'];
		if ( 1 !== preg_match( '/^[A-Z]{2}$/', $country ) || ! array_key_exists( $country, Account_Form::get_paypal_countries() ) ) {
			return __( 'Please select a valid account country.', 'alamin-donation-router' );
		}

		if ( '' === $fields['legal_entity_name'] ) {
			return __( 'Legal Entity Name is required.', 'alamin-donation-router' );
		}

		if ( '' === $fields['legal_entity_tax_id'] ) {
			return __( 'Tax ID / EIN is required.', 'alamin-donation-router' );
		}

		if ( '' === $fields['legal_entity_address'] ) {
			return __( 'Legal Entity Address is required.', 'alamin-donation-router' );
		}

		return null;
	}
}
