<?php
/**
 * Module_Card_Testing — the card-testing defense module.
 *
 * Registers a rule on the gate at priority 5 (runs before email_blocklist
 * at priority 10). When a fingerprint's rolling-window decline/submission
 * counts cross thresholds, targets that fingerprint for a 90-second block
 * and taints it so linked customers get a negative signal on their trust
 * score (via get_signal()).
 *
 * @package TrustLens
 * @since   1.2.2
 */

defined( 'ABSPATH' ) || exit;

class TrustLens_Module_Card_Testing extends TrustLens_Module {

	protected string $id          = 'card_testing';
	protected string $name        = 'Card-Testing Defense';
	protected string $description = 'Detects and blocks card-testing attacks behaviorally before requests reach the payment gateway.';
	protected bool   $is_pro      = false;

	public const TARGET_TTL_SECONDS = 90;
	public const SIGNAL_WEIGHT      = -30;

	public function register_hooks(): void {
		TrustLens_Request_Gate::instance()->register_rule(
			'card_testing_velocity',
			array( $this, 'evaluate' ),
			5
		);
		add_action( 'woocommerce_order_status_completed', array( $this, 'stamp_attack_window_signal' ), 10, 2 );

		// Subsidiary services — only run when this module is enabled so
		// disabled installs don't waste DB writes / scheduled events.
		( new TrustLens_Velocity_Recorder() )->register_hooks();
		( new TrustLens_Decline_Hooks() )->register_hooks();
		( new TrustLens_Panic_Button_AJAX() )->register_hooks();
		( new TrustLens_Card_Testing_Retention_Cron() )->register_hooks();

		// Pro-tier features — self-gated (checks wstl_can_use_pro inside).
		if ( class_exists( 'TrustLens_Card_Testing_Pro_Bootstrap' ) ) {
			( new TrustLens_Card_Testing_Pro_Bootstrap() )->register_hooks();
		}
	}

	public function evaluate( TrustLens_Gate_Context $ctx ): ?TrustLens_Gate_Block {
		// Card-testing defense protects the payment gateway from stolen-card
		// attack bots. Cart writes (add-item, apply-coupon, etc.) are not
		// payment attempts and don't reach the gateway — running velocity
		// detection on them would pay 2 COUNT queries per cart click for
		// zero protective value. The email_blocklist rule (different priority,
		// different concern) still runs on cart writes to enforce known-bad
		// customer blocks per the 1.3.0 design.
		if ( TrustLens_Gate_Context::INTENT_CHECKOUT_SUBMIT !== $ctx->intent ) {
			return null;
		}

		$state = new TrustLens_Lockdown_State();

		// Panic freeze wins first — blocks everyone regardless of VIP status.
		if ( $state->is_panic_active() ) {
			return $this->build_block( 'panic_freeze', __( 'Checkout is temporarily paused. Please try again shortly.', 'trustlens' ) );
		}

		// VIP bypass — protects legit repeat customers from targeted blocks.
		if ( $this->is_vip_customer( $ctx->email ) ) {
			return null;
		}

		// Already-targeted fingerprint → block immediately. Check BOTH the
		// primary (client-asserted) hash AND the server-fallback hash so an
		// attacker can't escape a lockdown by rotating their JS-side hash.
		if ( $state->is_fingerprint_blocked( $ctx->fingerprint_hash ) ) {
			return $this->build_block( 'velocity_threshold', __( 'Too many attempts. Please wait a moment before trying again.', 'trustlens' ) );
		}
		if ( '' !== $ctx->server_fingerprint_hash
		     && $state->is_fingerprint_blocked( $ctx->server_fingerprint_hash ) ) {
			return $this->build_block( 'velocity_threshold', __( 'Too many attempts. Please wait a moment before trying again.', 'trustlens' ) );
		}

		// Not currently targeted — evaluate recent window counts on the
		// primary first, then the server fallback. If either crosses threshold
		// we target the specific hash that crossed and block.
		$thresholds = $this->thresholds( $ctx->fingerprint_hash );
		$detector   = new TrustLens_Velocity_Detector( $thresholds );

		$primary_hash = $ctx->fingerprint_hash;
		$counts       = $this->count_recent( $primary_hash );
		$target_hash  = null;
		if ( $detector->should_target_block( $counts ) ) {
			$target_hash = $primary_hash;
		} elseif ( '' !== $ctx->server_fingerprint_hash ) {
			// Primary didn't cross threshold (likely because the attacker rotated
			// the client hash) — check the server-fallback hash which accumulates
			// declines across rotated client hashes for the same IP + UA.
			$server_counts = $this->count_recent( $ctx->server_fingerprint_hash );
			if ( $detector->should_target_block( $server_counts ) ) {
				$target_hash = $ctx->server_fingerprint_hash;
			}
		}

		if ( null !== $target_hash ) {
			$state->target_fingerprint( $target_hash, self::TARGET_TTL_SECONDS );
			$this->taint_fingerprint( $target_hash );

			/**
			 * Fires when a fingerprint is newly targeted by velocity detection.
			 *
			 * @since 1.2.2
			 * @param string                 $fingerprint_hash
			 * @param TrustLens_Gate_Context $ctx
			 */
			do_action( 'trustlens/card_testing/attack_detected', $ctx->fingerprint_hash, $ctx );

			// Bridge to the generic `trustlens/checkout_blocked` pipeline so
			// Notifications / Automation / Webhooks can react — but only once
			// per newly-targeted fingerprint (not on every subsequent attempt
			// during the 90s TTL), and only when a real email is present
			// (bot attacks with no email stay silent to avoid channel floods).
			if ( ! empty( $ctx->email ) ) {
				$email_hash = wstl_get_email_hash( $ctx->email );
				if ( ! empty( $email_hash ) ) {
					do_action( 'trustlens/checkout_blocked', $email_hash, $ctx->email );
				}
			}

			return $this->build_block( 'velocity_threshold', __( 'Too many attempts. Please wait a moment before trying again.', 'trustlens' ) );
		}

		return null;
	}

	/**
	 * Log a during_attack_window event on orders completed while the
	 * lockdown state is TARGETED or PANIC. Purely an audit signal —
	 * no score impact (order succeeded; gate didn't block the fingerprint).
	 *
	 * @param int      $order_id
	 * @param WC_Order $order
	 */
	public function stamp_attack_window_signal( int $order_id, $order ): void {
		if ( ! ( $order instanceof WC_Order ) ) {
			return;
		}

		$state   = new TrustLens_Lockdown_State();
		$current = $state->current_state();
		if ( 'IDLE' === $current ) {
			return;
		}

		$email = $order->get_billing_email();
		if ( empty( $email ) ) {
			return;
		}
		$email_hash = wstl_get_email_hash( $email );
		if ( empty( $email_hash ) ) {
			return;
		}

		wstl_log_event(
			$email_hash,
			'during_attack_window',
			array(
				'state'     => $current,
				'order_id'  => $order_id,
				'logged_by' => 'card_testing',
			),
			$order_id
		);
	}

	public function get_signal( string $email_hash, ?object $customer = null ): array {
		global $wpdb;
		$table = $wpdb->prefix . 'trustlens_fingerprints';

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe; real-time fraud signal, no caching.
		$tainted_count = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$table} WHERE email_hash = %s AND taint_flag = 1",
				$email_hash
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( $tainted_count < 1 ) {
			return array( 'score' => 0, 'reason' => '' );
		}

		return array(
			'score'  => self::SIGNAL_WEIGHT,
			'reason' => sprintf(
				/* translators: %d: number of tainted device fingerprints. */
				__( 'Linked to %d device fingerprint(s) involved in a card-testing attack', 'trustlens' ),
				$tainted_count
			),
		);
	}

	private function count_recent( string $fingerprint_hash ): array {
		global $wpdb;
		$table = $wpdb->prefix . 'trustlens_velocity_events';

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe; real-time velocity counts, no caching.
		$declines_60s = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$table} WHERE fingerprint_hash = %s AND event_type = 'decline' AND created_at > DATE_SUB(UTC_TIMESTAMP(), INTERVAL 60 SECOND)",
				$fingerprint_hash
			)
		);
		$submissions_60s = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$table} WHERE fingerprint_hash = %s AND event_type = 'submission' AND created_at > DATE_SUB(UTC_TIMESTAMP(), INTERVAL 60 SECOND)",
				$fingerprint_hash
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		return array(
			'declines_60s'    => $declines_60s,
			'submissions_60s' => $submissions_60s,
		);
	}

	private function thresholds( ?string $fingerprint_hash = null ): array {
		$global = get_option( 'trustlens_card_testing_thresholds', array() );
		if ( ! is_array( $global ) ) {
			$global = array();
		}
		$merged = array_merge( TrustLens_Velocity_Detector::DEFAULT_THRESHOLDS, $global );

		// Pro-only per-fingerprint override. Stored as
		// [ fp_hash => [ 'declines_60s' => N, ... ], ... ].
		if ( $fingerprint_hash && function_exists( 'wstl_can_use_pro' ) && wstl_can_use_pro() ) {
			$overrides = get_option( 'trustlens_card_testing_fp_overrides', array() );
			if ( is_array( $overrides ) && isset( $overrides[ $fingerprint_hash ] ) && is_array( $overrides[ $fingerprint_hash ] ) ) {
				$merged = array_merge( $merged, $overrides[ $fingerprint_hash ] );
			}
		}

		return $merged;
	}

	private function is_vip_customer( ?string $email ): bool {
		if ( empty( $email ) ) {
			return false;
		}
		if ( ! get_option( 'trustlens_card_testing_bypass_vip_customers', true ) ) {
			return false;
		}
		if ( ! function_exists( 'wstl_get_customer_by_email' ) ) {
			return false;
		}
		$customer = wstl_get_customer_by_email( $email );
		if ( ! $customer ) {
			return false;
		}

		// One successful order is not enough to grant permanent velocity-block immunity —
		// a fraud actor who completed a single order would otherwise sail through forever.
		// Require the same minimum-orders threshold used for confident scoring, AND exclude
		// risky segments so chargeback-prone repeat customers don't bypass card-testing defense.
		$min_orders = (int) get_option( 'trustlens_min_orders', 3 );
		if ( (int) ( $customer->total_orders ?? 0 ) < $min_orders ) {
			return false;
		}

		$segment = (string) ( $customer->segment ?? 'normal' );
		return ! in_array( $segment, array( 'critical', 'risk' ), true );
	}

	private function taint_fingerprint( string $fingerprint_hash ): void {
		global $wpdb;
		$table = $wpdb->prefix . 'trustlens_fingerprints';

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe; write to a custom table.
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table}
				 SET taint_flag = 1, taint_reason = %s, tainted_at = UTC_TIMESTAMP()
				 WHERE fingerprint_hash = %s AND taint_flag = 0",
				'card_testing_velocity',
				$fingerprint_hash
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
	}

	private function build_block( string $reason_code, string $message ): TrustLens_Gate_Block {
		return new TrustLens_Gate_Block( 'card_testing_velocity', $reason_code, $message, 429, 60 );
	}
}
