<?php

declare(strict_types=1);

namespace SecurWP\Includes;

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

/**
 * RequestVerifier - Verifies v3 signed requests (HMAC + replay protection).
 *
 * Used for Console -> Plugin instant command delivery.
 */
class RequestVerifier {
	private const SIGNATURE_VERSION = '3';
	private const TIMEOUT_SECONDS   = 300; // 5 minutes
	private const TABLE_SUFFIX      = 'securwp_request_nonces';

	private static bool $table_ready = false;

	/**
	 * Verify a signed request and enforce replay protection.
	 *
	 * @param string $site_token_expected Site token stored locally.
	 * @param string $site_secret         Site secret stored locally.
	 * @param string $method              HTTP method.
	 * @param string $request_uri         Full request URI (path + query).
	 * @param string $body               Raw request body.
	 * @param array<string,string> $headers Normalized header map (lowercase keys).
	 * @return true|\WP_Error
	 */
	public static function verify(
		string $site_token_expected,
		string $site_secret,
		string $method,
		string $request_uri,
		string $body,
		array $headers
	) {
		if ( $site_token_expected === '' || $site_secret === '' ) {
			return new \WP_Error( 'not_linked', 'Site is not linked to console.', [ 'status' => 403 ] );
		}

		$site_token = (string) ( $headers['x-site-token'] ?? '' );
		if ( $site_token === '' || ! hash_equals( $site_token_expected, $site_token ) ) {
			return new \WP_Error( 'invalid_site_token', 'Invalid site token.', [ 'status' => 403 ] );
		}

		$signature         = (string) ( $headers['x-request-signature'] ?? '' );
		$timestamp         = (string) ( $headers['x-request-timestamp'] ?? '' );
		$signature_version = (string) ( $headers['x-request-signature-version'] ?? '' );
		$nonce             = (string) ( $headers['x-request-nonce'] ?? '' );
		$request_id        = (string) ( $headers['x-request-id'] ?? '' );
		$body_hash         = (string) ( $headers['x-request-body-hash'] ?? '' );

		if ( $signature === '' || $timestamp === '' || $signature_version === '' || $nonce === '' || $request_id === '' || $body_hash === '' ) {
			return new \WP_Error( 'missing_signature', 'Missing signature headers.', [ 'status' => 401 ] );
		}
		if ( $signature_version !== self::SIGNATURE_VERSION ) {
			return new \WP_Error( 'unsupported_signature', 'Unsupported signature version.', [ 'status' => 401 ] );
		}

		if ( strlen( $signature ) > 128 || strlen( $nonce ) > 128 || strlen( $request_id ) > 128 || strlen( $body_hash ) > 128 ) {
			return new \WP_Error( 'invalid_signature', 'Invalid signature header length.', [ 'status' => 401 ] );
		}

		$req_id_ok = function_exists( 'wp_is_uuid' ) ? wp_is_uuid( $request_id ) : ( preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $request_id ) === 1 );
		if ( ! $req_id_ok ) {
			return new \WP_Error( 'invalid_request_id', 'Invalid request id.', [ 'status' => 401 ] );
		}

		if ( ! ctype_digit( $timestamp ) ) {
			return new \WP_Error( 'invalid_timestamp', 'Invalid timestamp.', [ 'status' => 401 ] );
		}
		$ts = (int) $timestamp;
		if ( abs( time() - $ts ) > self::TIMEOUT_SECONDS ) {
			return new \WP_Error( 'expired', 'Request timestamp expired.', [ 'status' => 401 ] );
		}

		$actual_body_hash = hash( 'sha256', $body );
		if ( ! hash_equals( $body_hash, $actual_body_hash ) ) {
			return new \WP_Error( 'body_hash_mismatch', 'Body hash mismatch.', [ 'status' => 401 ] );
		}

		$path  = (string) ( wp_parse_url( $request_uri, PHP_URL_PATH ) ?: '/' );
		$query = (string) ( wp_parse_url( $request_uri, PHP_URL_QUERY ) ?: '' );

		$payload = self::build_canonical_payload( strtoupper( $method ), $path, $query, $timestamp, $nonce, $request_id, $body_hash );
		$expected = hash_hmac( 'sha256', $payload, $site_secret );
		if ( ! hash_equals( $expected, $signature ) ) {
			return new \WP_Error( 'invalid_signature', 'Invalid request signature.', [ 'status' => 403 ] );
		}

		// Replay protection (nonce + request_id).
		self::ensure_table();
		if ( ! self::check_and_store_nonce( $site_token_expected, $nonce, $request_id ) ) {
			return new \WP_Error( 'replay', 'Replay detected.', [ 'status' => 403 ] );
		}

		return true;
	}

	/**
	 * Option key that gates DDL to run only once (set after first successful CREATE).
	 * Schema is stable so no version-keying is needed; clearing this option forces
	 * a re-create (e.g. after manual table drop).
	 */
	private const TABLE_READY_OPTION = 'securwp_nonce_table_v1';

	/**
	 * @return void
	 */
	private static function ensure_table(): void {
		if ( self::$table_ready ) {
			return;
		}

		if ( get_option( self::TABLE_READY_OPTION ) !== false ) {
			if ( self::table_exists() ) {
				self::$table_ready = true;
				return;
			}
			delete_option( self::TABLE_READY_OPTION );
		}

		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_SUFFIX;
		$charset_collate = $wpdb->get_charset_collate();

		$sql = "CREATE TABLE IF NOT EXISTS {$table} (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			site_token varchar(128) NOT NULL,
			nonce varchar(128) NOT NULL,
			request_id varchar(128) NOT NULL,
			created_at int(10) unsigned NOT NULL,
			PRIMARY KEY  (id),
			UNIQUE KEY uk_site_nonce (site_token, nonce),
			UNIQUE KEY uk_site_request (site_token, request_id),
			KEY idx_created (created_at)
		) {$charset_collate};";

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.NotPrepared -- Creates a plugin-owned replay-protection table; placeholders are not supported for SQL identifiers or DDL.
		$wpdb->query( $sql );
		update_option( self::TABLE_READY_OPTION, 1, true );
		self::$table_ready = true;
	}

	/**
	 * @return bool
	 */
	private static function table_exists(): bool {
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_SUFFIX;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Runs only once per process when the ready option exists.
		$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
		return is_string( $found ) && strtolower( $found ) === strtolower( $table );
	}

	/**
	 * @return bool
	 */
	private static function check_and_store_nonce( string $site_token, string $nonce, string $request_id ): bool {
		global $wpdb;
		$table = self::table_identifier( $wpdb->prefix . self::TABLE_SUFFIX );
		$cutoff = time() - self::TIMEOUT_SECONDS;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Deletes expired rows from a plugin-owned replay table using an escaped identifier.
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %d", $cutoff ) );

		$now = time();
		// Use INSERT IGNORE to handle unique constraint without leaking state.
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$sql = $wpdb->prepare(
			"INSERT IGNORE INTO {$table} (site_token, nonce, request_id, created_at) VALUES (%s, %s, %s, %d)",
			$site_token,
			$nonce,
			$request_id,
			$now
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Executes the prepared insert statement assembled above for a plugin-owned replay table.
		$result = $wpdb->query( $sql );
		return (int) $result === 1;
	}

	/**
	 * Escape a plugin-owned table identifier for direct interpolation.
	 */
	private static function table_identifier( string $table ): string {
		if ( $table === '' || strlen( $table ) > 64 || ! preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) {
			throw new \InvalidArgumentException( 'Invalid table identifier.' );
		}

		return '`' . str_replace( '`', '``', $table ) . '`';
	}

	private static function build_canonical_payload(
		string $method,
		string $path,
		string $query,
		string $timestamp,
		string $nonce,
		string $request_id,
		string $body_hash
	): string {
		$canonical_query = self::canonicalize_query( $query );
		$normalized_path = self::normalize_path( $path );
		return implode(
			"\n",
			[
				$method,
				$normalized_path,
				$canonical_query,
				$timestamp,
				$nonce,
				$request_id,
				$body_hash,
			]
		);
	}

	/**
	 * Normalize a URL path: collapse '//', resolve '..', ensure leading '/'.
	 */
	private static function normalize_path( string $path ): string {
		if ( $path === '' ) {
			return '/';
		}

		// Decode unreserved percent-encoded characters (RFC 3986 2.3).
		$path = preg_replace_callback(
			'/%([0-9A-Fa-f]{2})/',
			static function ( array $m ): string {
				$char = chr( (int) hexdec( $m[1] ) );
				// Unreserved: A-Z a-z 0-9 - . _ ~
				if ( ctype_alnum( $char ) || strpos( '-._~', $char ) !== false ) {
					return $char;
				}
				return strtoupper( $m[0] );
			},
			$path
		);

		// Collapse consecutive slashes.
		$path = preg_replace( '#/{2,}#', '/', $path );

		// Resolve '..' and '.' segments.
		$segments = explode( '/', $path );
		$stack    = [];
		foreach ( $segments as $seg ) {
			if ( $seg === '..' ) {
				array_pop( $stack );
			} elseif ( $seg !== '.' && $seg !== '' ) {
				$stack[] = $seg;
			}
		}

		return '/' . implode( '/', $stack );
	}

	public static function canonicalize_query( string $query ): string {
		if ( $query === '' ) {
			return '';
		}
		$pairs = [];
		foreach ( explode( '&', $query ) as $segment ) {
			if ( $segment === '' ) {
				continue;
			}
			$kv = explode( '=', $segment, 2 );
			$key = urldecode( $kv[0] );
			$value = urldecode( $kv[1] ?? '' );
			$pairs[] = [ rawurlencode( $key ), rawurlencode( $value ) ];
		}
		usort(
			$pairs,
			static function ( $a, $b ) {
				if ( $a[0] === $b[0] ) {
					return $a[1] <=> $b[1];
				}
				return $a[0] <=> $b[0];
			}
		);
		$out = [];
		foreach ( $pairs as $p ) {
			$out[] = $p[0] . '=' . $p[1];
		}
		return implode( '&', $out );
	}
}
