<?php
/**
 * Request_Gate — registry + dispatcher for checkout-gating rules.
 *
 * @package TrustLens
 * @since   1.2.2
 */

defined( 'ABSPATH' ) || exit;

final class TrustLens_Request_Gate {

	private static ?TrustLens_Request_Gate $instance = null;

	/** @var array<int,array{id:string,callback:callable,priority:int,seq:int}> */
	private array $rules = array();

	/** @var callable[] */
	private array $bypass_callbacks = array();

	private int $registration_seq = 0;

	/** @var callable|null */
	private $exception_handler = null;

	public static function instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	public static function reset_instance_for_tests(): void {
		self::$instance = null;
	}

	private function __construct() {}

	public function register_rule( string $id, callable $callback, int $priority = 10 ): void {
		foreach ( $this->rules as $existing ) {
			if ( $existing['id'] === $id ) {
				throw new InvalidArgumentException( esc_html( "Gate rule '{$id}' already registered." ) );
			}
		}

		$this->rules[] = array(
			'id'       => $id,
			'callback' => $callback,
			'priority' => $priority,
			'seq'      => $this->registration_seq++,
		);
	}

	/**
	 * Register a bypass callback that can force-allow a request before
	 * any blocking rules run. Callback receives Gate_Context and returns
	 * bool — true means "force allow, short-circuit to null". Exceptions
	 * are swallowed and treated as no-bypass (fail-open).
	 *
	 * @since 1.2.2
	 */
	public function register_bypass( callable $callback ): void {
		$this->bypass_callbacks[] = $callback;
	}

	public function set_exception_handler_for_tests( callable $handler ): void {
		$this->exception_handler = $handler;
	}

	public function evaluate( TrustLens_Gate_Context $ctx ): ?TrustLens_Gate_Block {
		if ( function_exists( 'current_user_can' ) && current_user_can( 'manage_woocommerce' ) ) {
			$this->fire_evaluated_action( $ctx, null );
			return null;
		}

		// Pro allowlist bypass — runs before blocking rules.
		foreach ( $this->bypass_callbacks as $cb ) {
			try {
				if ( true === (bool) $cb( $ctx ) ) {
					$this->fire_evaluated_action( $ctx, null );
					return null;
				}
			} catch ( \Throwable $e ) {
				$this->report_exception( 'bypass_callback', $e );
			}
		}

		$sorted = $this->rules;
		usort( $sorted, function ( $a, $b ) {
			return ( $a['priority'] <=> $b['priority'] ) ?: ( $a['seq'] <=> $b['seq'] );
		} );

		$block = null;
		foreach ( $sorted as $rule ) {
			try {
				$result = ( $rule['callback'] )( $ctx );
			} catch ( \Throwable $e ) {
				$this->report_exception( $rule['id'], $e );
				continue;
			}

			if ( $result instanceof TrustLens_Gate_Block ) {
				$block = $result;
				break;
			}
		}

		$this->fire_evaluated_action( $ctx, $block );
		return $block;
	}

	private function fire_evaluated_action( TrustLens_Gate_Context $ctx, ?TrustLens_Gate_Block $block ): void {
		if ( function_exists( 'do_action' ) ) {
			/**
			 * Fires after the gate evaluates a checkout request.
			 *
			 * @since 1.2.2
			 * @param TrustLens_Gate_Context      $ctx
			 * @param TrustLens_Gate_Block|null   $block
			 */
			do_action( 'trustlens/gate_evaluated', $ctx, $block );
		}
	}

	private function report_exception( string $rule_id, \Throwable $e ): void {
		if ( $this->exception_handler ) {
			( $this->exception_handler )( $rule_id, $e );
			return;
		}

		if ( function_exists( 'wc_get_logger' ) ) {
			wc_get_logger()->error(
				sprintf( 'Gate rule "%s" threw: %s', $rule_id, $e->getMessage() ),
				array(
					'source'    => 'trustlens-gate',
					'exception' => $e,
				)
			);
		}
	}

	/**
	 * Register WordPress / WooCommerce hooks. Called once during plugin init.
	 */
	public function register_hooks(): void {
		add_filter( 'rest_pre_dispatch', array( $this, 'intercept_store_api' ), 10, 3 );
		add_action( 'woocommerce_checkout_process', array( $this, 'intercept_classic_checkout' ) );
		add_filter( 'woocommerce_add_to_cart_validation', array( $this, 'intercept_classic_add_to_cart' ), 10, 2 );
	}

	/**
	 * Intercept Store API checkout and cart routes before WooCommerce runs them.
	 *
	 * @param mixed           $result  Result from prior filters (null by default).
	 * @param WP_REST_Server  $server  REST server instance.
	 * @param WP_REST_Request $request Incoming request.
	 * @return mixed
	 */
	public function intercept_store_api( $result, $server, $request ) {
		if ( null !== $result ) {
			return $result;
		}
		if ( ! ( $request instanceof WP_REST_Request ) ) {
			return $result;
		}

		// Only gate write methods. READ operations (GET) hydrate cart/checkout
		// state and must not be blocked — the user hasn't even submitted anything.
		$method = strtoupper( (string) $request->get_method() );
		if ( ! in_array( $method, array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true ) ) {
			return $result;
		}

		$route = $request->get_route();
		if ( ! $this->is_store_api_gated_route( $route ) ) {
			return $result;
		}

		$intent = $this->resolve_store_api_intent( $route );
		$ctx    = $this->build_context_from_rest_request( $request, $intent );
		$block  = $this->evaluate( $ctx );

		if ( null === $block ) {
			return $result;
		}

		$response = new WP_REST_Response(
			array(
				'code'    => $block->reason_code,
				'message' => $block->user_message,
				'data'    => array( 'status' => $block->http_status ),
			),
			$block->http_status
		);

		if ( $block->retry_after_seconds > 0 ) {
			$response->header( 'Retry-After', (string) $block->retry_after_seconds );
		}

		return $response;
	}

	public function intercept_classic_checkout(): void {
		$ctx   = $this->build_context_from_classic_checkout();
		$block = $this->evaluate( $ctx );

		if ( null === $block ) {
			return;
		}

		if ( function_exists( 'wc_add_notice' ) ) {
			wc_add_notice( $block->user_message, 'error' );
		}
	}

	/**
	 * @param bool|mixed $passed
	 * @param int        $product_id
	 * @return bool
	 */
	public function intercept_classic_add_to_cart( $passed, $product_id ) {
		$ctx   = $this->build_context_from_classic_cart( (int) $product_id );
		$block = $this->evaluate( $ctx );

		if ( null === $block ) {
			return $passed;
		}

		if ( function_exists( 'wc_add_notice' ) ) {
			wc_add_notice( $block->user_message, 'error' );
		}
		return false;
	}

	private function is_store_api_gated_route( string $route ): bool {
		// Checkout route: /wc/store/v1/checkout (POST submits, PUT updates).
		// Cart writes: /wc/store/v1/cart/add-item, /cart/update-item, /cart/remove-item,
		// /cart/apply-coupon, /cart/select-shipping-rate, /cart/update-customer.
		// We explicitly exclude the bare /wc/store/v1/cart (GET reads cart state).
		if ( 0 === strpos( $route, '/wc/store/v1/checkout' ) ) {
			return true;
		}
		if ( 0 === strpos( $route, '/wc/store/v1/cart/' ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Map a gated Store API route to the shopper's intent.
	 *
	 * /wc/store/v1/checkout*  → CHECKOUT_SUBMIT (real payment attempt)
	 * /wc/store/v1/cart/*     → CART_WRITE     (mutating cart, no payment yet)
	 *
	 * Callers must only invoke this for routes is_store_api_gated_route()
	 * accepts; other routes are not classified here.
	 *
	 * @since 1.2.4
	 */
	private function resolve_store_api_intent( string $route ): string {
		if ( 0 === strpos( $route, '/wc/store/v1/checkout' ) ) {
			return TrustLens_Gate_Context::INTENT_CHECKOUT_SUBMIT;
		}
		return TrustLens_Gate_Context::INTENT_CART_WRITE;
	}

	private function build_context_from_rest_request( WP_REST_Request $request, string $intent ): TrustLens_Gate_Context {
		$payload = $request->get_json_params();
		if ( empty( $payload ) ) {
			$payload = $request->get_body_params();
		}
		if ( ! is_array( $payload ) ) {
			$payload = array();
		}

		$email = null;
		if ( isset( $payload['billing_address']['email'] ) ) {
			$email = sanitize_email( (string) $payload['billing_address']['email'] );
		} elseif ( isset( $payload['billing_email'] ) ) {
			$email = sanitize_email( (string) $payload['billing_email'] );
		}

		$ip              = $this->get_ip_for_context();
		$user_agent      = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		$accept_language = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : '';

		$fp = $this->resolve_and_record_fingerprint_rest( $request, $ip, $user_agent, $accept_language );

		return new TrustLens_Gate_Context(
			$fp['hash'],
			$ip,
			$email ? $email : null,
			get_current_user_id() ?: null,
			TrustLens_Gate_Context::CHECKOUT_BLOCKS,
			$payload,
			$fp['server_hash'],
			$intent
		);
	}

	private function build_context_from_classic_checkout(): TrustLens_Gate_Context {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce handles nonces at this hook.
		$email = isset( $_POST['billing_email'] ) ? sanitize_email( wp_unslash( (string) $_POST['billing_email'] ) ) : null;
		// phpcs:ignore WordPress.Security.NonceVerification.Missing
		$post = is_array( $_POST ) ? wp_unslash( $_POST ) : array();

		$ip              = $this->get_ip_for_context();
		$user_agent      = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		$accept_language = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : '';

		$fp = $this->resolve_and_record_fingerprint_classic( $post, $ip, $user_agent, $accept_language );

		return new TrustLens_Gate_Context(
			$fp['hash'],
			$ip,
			$email ? $email : null,
			get_current_user_id() ?: null,
			TrustLens_Gate_Context::CHECKOUT_CLASSIC,
			array_map( 'sanitize_text_field', $post ),
			$fp['server_hash'],
			TrustLens_Gate_Context::INTENT_CHECKOUT_SUBMIT
		);
	}

	private function build_context_from_classic_cart( int $product_id ): TrustLens_Gate_Context {
		$email = null;
		if ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) {
			$user  = wp_get_current_user();
			$email = $user ? ( $user->user_email ?: null ) : null;
		}

		$ip              = $this->get_ip_for_context();
		$user_agent      = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		$accept_language = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : '';

		$fp = $this->resolve_and_record_fingerprint_classic( array(), $ip, $user_agent, $accept_language );

		return new TrustLens_Gate_Context(
			$fp['hash'],
			$ip,
			$email,
			get_current_user_id() ?: null,
			TrustLens_Gate_Context::CHECKOUT_CLASSIC,
			array( 'product_id' => $product_id ),
			$fp['server_hash'],
			TrustLens_Gate_Context::INTENT_CART_WRITE
		);
	}

	/**
	 * Resolve and record fingerprint for REST requests. Never throws — any
	 * exception is swallowed and a server fallback hash returned. A crashing
	 * fingerprint persistence layer must not nuke checkout for all shoppers.
	 *
	 * @return array{hash: string, source: string, server_hash: string}
	 */
	private function resolve_and_record_fingerprint_rest( WP_REST_Request $request, string $ip, string $user_agent, string $accept_language ): array {
		if ( ! class_exists( 'TrustLens_Fingerprint_Service' ) ) {
			return array( 'hash' => 'server_fallback_pending', 'source' => 'server', 'server_hash' => 'server_fallback_pending' );
		}
		try {
			$service = new TrustLens_Fingerprint_Service();
			$fp      = $service->resolve_from_rest_request( $request, $ip, $user_agent, $accept_language );
			$service->record( $fp['hash'], $fp['source'] );
			return $fp;
		} catch ( \Throwable $e ) {
			$this->report_exception( 'fingerprint_service', $e );
			$fallback = ( new TrustLens_Fingerprint_Service() )->server_fallback_hash( $ip, $user_agent, $accept_language, '' );
			return array( 'hash' => $fallback, 'source' => 'server', 'server_hash' => $fallback );
		}
	}

	/**
	 * Resolve and record fingerprint for Classic requests. Never throws.
	 *
	 * @param array $post POST data, may be empty.
	 * @return array{hash: string, source: string, server_hash: string}
	 */
	private function resolve_and_record_fingerprint_classic( array $post, string $ip, string $user_agent, string $accept_language ): array {
		if ( ! class_exists( 'TrustLens_Fingerprint_Service' ) ) {
			return array( 'hash' => 'server_fallback_pending', 'source' => 'server', 'server_hash' => 'server_fallback_pending' );
		}
		try {
			$service = new TrustLens_Fingerprint_Service();
			$fp      = $service->resolve_from_classic_post( $post, $ip, $user_agent, $accept_language );
			$service->record( $fp['hash'], $fp['source'] );
			return $fp;
		} catch ( \Throwable $e ) {
			$this->report_exception( 'fingerprint_service', $e );
			$fallback = ( new TrustLens_Fingerprint_Service() )->server_fallback_hash( $ip, $user_agent, $accept_language, '' );
			return array( 'hash' => $fallback, 'source' => 'server', 'server_hash' => $fallback );
		}
	}

	private function get_ip_for_context(): string {
		return wstl_get_client_ip();
	}
}
