<?php

declare(strict_types=1);

namespace SecurWP\Modules\Scanner\Security;

// If this file is called directly, abort.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Hash Cache Proof (Ed25519)
 *
 * Verifies cloud-signed verdict proofs so local DB cache cannot be forged.
 *
 * Proof format (binary, v1):
 *   [0]   u8   version (1)
 *   [1]   u8   key_id
 *   [2:6] u32  issued_at (big-endian unix seconds)
 *   [6]   u8   verdict_code (1=safe, 2=verified)
 *   [7]   u8   sig_version_len (0..255)
 *   [...] bytes sig_version (ASCII, may be empty for verified)
 *   [...] 64B  ed25519 signature (detached)
 *
 * Signature message (bytes):
 *   "SPHC" + [version] + [hash_bytes(32)] + [key_id] + [issued_at u32be] + [verdict_code] + [sig_version_len] + [sig_version]
 *
 */
class HashCacheProof {

	private const PROOF_VERSION = 1;

	// Keep this list minimal; rotate via key_id.
	// Public keys are safe to ship in the plugin.
	private const PUBLIC_KEYS_B64 = [
		// key_id => base64(raw_32B_ed25519_public_key)
		1 => '7L4sTdGZPOtFe6bpLg9iOaXOufrafl2G88GlAGr4zN0=',
	];

	private const VERDICT_CODES = [
		'safe'     => 1,
		'verified' => 2,
	];

	/** @var array<int,string>|null In-process cache of resolved public keys. */
	private static $public_keys_cache = null;

	/**
	 * Decode a proof blob.
	 *
	 * @param string $proof Binary proof.
	 * @return array{version:int,key_id:int,issued_at:int,verdict:string,sig_version:string,signature:string}|null
	 */
	public static function decode( string $proof ): ?array {
		if ( strlen( $proof ) < 8 + 64 ) {
			return null;
		}

		$version = ord( $proof[0] );
		$key_id  = ord( $proof[1] );
		$issued  = unpack( 'N', substr( $proof, 2, 4 ) );
		$issued_at = (int) ( $issued[1] ?? 0 );
		$verdict_code = ord( $proof[6] );
		$sigv_len = ord( $proof[7] );

		$min_len = 8 + $sigv_len + 64;
		if ( strlen( $proof ) < $min_len ) {
			return null;
		}

		$sig_version = $sigv_len > 0 ? substr( $proof, 8, $sigv_len ) : '';
		$signature   = substr( $proof, 8 + $sigv_len, 64 );

		$verdict = array_search( $verdict_code, self::VERDICT_CODES, true );
		if ( ! $verdict ) {
			return null;
		}

		return [
			'version'     => $version,
			'key_id'      => $key_id,
			'issued_at'   => $issued_at,
			'verdict'     => $verdict,
			'sig_version' => $sig_version,
			'signature'   => $signature,
		];
	}

	/**
	 * Verify a proof against a file hash.
	 *
	 * @param string $file_hash Hex SHA-256 (64 chars).
	 * @param string $proof     Binary proof.
	 * @return array{verdict:string,sig_version:string,issued_at:int,key_id:int}|null Verified details or null.
	 */
	public static function verify( string $file_hash, string $proof ): ?array {
		$decoded = self::decode( $proof );
		if ( ! $decoded ) {
			return null;
		}

		if ( $decoded['version'] !== self::PROOF_VERSION ) {
			return null;
		}

		$public_keys = self::get_public_keys();
		if ( empty( $public_keys[ $decoded['key_id'] ] ) ) {
			return null;
		}

		if ( ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) {
			// Can't verify; refuse to trust cached verdict.
			return null;
		}

		$hash_bytes = @hex2bin( $file_hash );
		if ( false === $hash_bytes || strlen( $hash_bytes ) !== 32 ) {
			return null;
		}

		$pub = base64_decode( $public_keys[ $decoded['key_id'] ], true );
		if ( false === $pub || strlen( $pub ) !== 32 ) {
			return null;
		}

		$msg = self::build_message(
			$hash_bytes,
			$decoded['key_id'],
			$decoded['issued_at'],
			self::VERDICT_CODES[ $decoded['verdict'] ] ?? 0,
			$decoded['sig_version']
		);
		if ( ! $msg ) {
			return null;
		}

		$ok = sodium_crypto_sign_verify_detached( $decoded['signature'], $msg, $pub );
		if ( ! $ok ) {
			return null;
		}

		return [
			'verdict'     => $decoded['verdict'],
			'sig_version' => $decoded['sig_version'],
			'issued_at'   => $decoded['issued_at'],
			'key_id'      => $decoded['key_id'],
		];
	}

	/**
	 * Validate whether a public key payload is acceptable for the given key id
	 * without mutating scanner settings.
	 *
	 * @param int    $key_id Key id (0..255).
	 * @param string $public_key_b64 Base64 raw 32B public key.
	 * @return bool True when the key is valid for this site.
	 */
	public static function accepts_public_key( int $key_id, string $public_key_b64 ): bool {
		return self::store_or_validate_public_key( $key_id, $public_key_b64, false );
	}

	/**
	 * Store a verified public key for hash cache proofs.
	 *
	 * Allows key rotation without shipping a new plugin build while
	 * refusing overrides of compiled-in trust anchors.
	 *
	 * @param int    $key_id Key id (0..255).
	 * @param string $public_key_b64 Base64 raw 32B public key.
	 * @return bool True when stored or already present.
	 */
	public static function store_public_key( int $key_id, string $public_key_b64 ): bool {
		return self::store_or_validate_public_key( $key_id, $public_key_b64, true );
	}

	/**
	 * Validate a public key payload and optionally persist it.
	 *
	 * @param int    $key_id Key id (0..255).
	 * @param string $public_key_b64 Base64 raw 32B public key.
	 * @param bool   $persist Whether to store a new dynamic key.
	 * @return bool True when valid.
	 */
	private static function store_or_validate_public_key( int $key_id, string $public_key_b64, bool $persist ): bool {
		if ( $key_id < 0 || $key_id > 255 ) {
			return false;
		}

		$public_key_b64 = trim( $public_key_b64 );
		if ( $public_key_b64 === '' ) {
			return false;
		}

		$raw = base64_decode( $public_key_b64, true );
		if ( false === $raw || strlen( $raw ) !== 32 ) {
			return false;
		}

		if ( isset( self::PUBLIC_KEYS_B64[ $key_id ] ) ) {
			// Refuse to override baked-in trust anchors.
			return hash_equals( self::PUBLIC_KEYS_B64[ $key_id ], $public_key_b64 );
		}

		$stored = self::get_dynamic_public_keys_from_settings();
		$key = (string) $key_id;
		if ( isset( $stored[ $key ] ) && is_string( $stored[ $key ] ) ) {
			return hash_equals( $stored[ $key ], $public_key_b64 );
		}

		if ( ! $persist ) {
			return true;
		}

		$stored[ $key ] = $public_key_b64;

		if ( ! class_exists( '\SecurWP\Modules\Scanner\Service\ScannerSettings' ) ) {
			require_once dirname( __DIR__ ) . '/Service/ScannerSettings.php';
		}
		if ( class_exists( '\SecurWP\Modules\Scanner\Service\ScannerSettings' ) ) {
			return (bool) \SecurWP\Modules\Scanner\Service\ScannerSettings::set_internal_setting( 'hash_cache_keys', $stored );
		}

		return false;
	}

	/**
	 * Build the signed message bytes.
	 *
	 * @param string $hash_bytes 32B.
	 * @param int    $key_id
	 * @param int    $issued_at
	 * @param int    $verdict_code
	 * @param string $sig_version ASCII string.
	 * @return string|null
	 */
	private static function build_message( string $hash_bytes, int $key_id, int $issued_at, int $verdict_code, string $sig_version ): ?string {
		$sigv = (string) $sig_version;
		if ( ! self::is_ascii( $sigv ) ) {
			return null;
		}
		if ( strlen( $sigv ) > 255 ) {
			return null;
		}
		if ( $verdict_code <= 0 || $verdict_code > 255 ) {
			return null;
		}

		return 'SPHC'
			. chr( self::PROOF_VERSION )
			. $hash_bytes
			. chr( $key_id & 0xFF )
			. pack( 'N', $issued_at )
			. chr( $verdict_code & 0xFF )
			. chr( strlen( $sigv ) & 0xFF )
			. $sigv;
	}

	private static function is_ascii( string $s ): bool {
		return ! preg_match( '/[^\\x20-\\x7E]/', $s );
	}

	/**
	 * Resolve the current public key map (static + runtime).
	 *
	 * Runtime keys are used for rotation without weakening built-in anchors.
	 *
	 * @return array<int, string>
	 */
	private static function get_public_keys(): array {
		if ( self::$public_keys_cache !== null ) {
			return self::$public_keys_cache;
		}
		$dynamic = self::get_dynamic_public_keys();
		// Union preserves static keys when ids collide.
		self::$public_keys_cache = self::PUBLIC_KEYS_B64 + $dynamic;
		return self::$public_keys_cache;
	}

	/**
	 * Load dynamic public keys from options.
	 *
	 * @return array<int, string>
	 */
	private static function get_dynamic_public_keys(): array {
		$stored = self::get_dynamic_public_keys_from_settings();
		$valid = [];
		foreach ( $stored as $key_id => $public_key_b64 ) {
			$kid = (int) $key_id;
			if ( $kid < 0 || $kid > 255 ) {
				continue;
			}
			if ( ! is_string( $public_key_b64 ) ) {
				continue;
			}
			$public_key_b64 = trim( $public_key_b64 );
			if ( $public_key_b64 === '' ) {
				continue;
			}
			$raw = base64_decode( $public_key_b64, true );
			if ( false === $raw || strlen( $raw ) !== 32 ) {
				continue;
			}
			$valid[ $kid ] = $public_key_b64;
		}

		return $valid;
	}

	/**
	 * Load dynamic public keys from scanner settings.
	 *
	 * @return array
	 */
	private static function get_dynamic_public_keys_from_settings(): array {
		if ( ! class_exists( '\SecurWP\Modules\Scanner\Service\ScannerSettings' ) ) {
			require_once dirname( __DIR__ ) . '/Service/ScannerSettings.php';
		}
		if ( ! class_exists( '\SecurWP\Modules\Scanner\Service\ScannerSettings' ) ) {
			return [];
		}

		$settings = \SecurWP\Modules\Scanner\Service\ScannerSettings::get_all();
		$keys = $settings['hash_cache_keys'] ?? [];
		if ( ! is_array( $keys ) ) {
			$keys = [];
		}

		// One-time migration from legacy option if present and table is empty.
		if ( empty( $keys ) ) {
			$legacy = get_option( 'securwp_hash_cache_keys', [] );
			if ( is_array( $legacy ) && ! empty( $legacy ) ) {
				\SecurWP\Modules\Scanner\Service\ScannerSettings::set_internal_setting( 'hash_cache_keys', $legacy );
				delete_option( 'securwp_hash_cache_keys' );
				$keys = $legacy;
			}
		}

		return $keys;
	}
}
