<?php

declare(strict_types=1);

namespace SecurWP\Modules\Scanner\Repository;

use SecurWP\Modules\Scanner\Security\HashCacheProof;

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

/**
 * Hash Cache Repository (Signed)
 *
 * Minimal, tamper-resistant cache for cloud hash verdicts.
 *
 * Table: wp_securwp_hash_cache
 * Columns: file_hash, verdict, last_seen_at, proof
 *
 * Verdicts:
 * - verified: repository-verified content (does not stale on malware signature updates)
 * - safe: signature-verified content (invalidated on signature updates)
 *
 */
class HashCacheRepository {

	private const VERDICT_SAFE     = 'safe';
	private const VERDICT_VERIFIED = 'verified';

	// Drop entries not observed for this long.
	private const TTL_DAYS = 30;
	// Enforce cloud revalidation based on proof issuance time.
	private const SAFE_MAX_AGE_DAYS = 7;
	private const VERIFIED_MAX_AGE_DAYS = 30;

	// To reduce write-amplification, only touch last_seen once per day per entry.
	private const TOUCH_MIN_AGE = DAY_IN_SECONDS;

	/** @var \wpdb */
	private $wpdb;

	/** @var string */
	private $table_name;

	/** @var bool */
	private static $table_checked = false;

	public function __construct() {
		global $wpdb;
		$this->wpdb       = $wpdb;
		$this->table_name = $wpdb->prefix . 'securwp_hash_cache';
		$this->ensure_table();
	}

	private function ensure_table(): void {
		if ( self::$table_checked ) {
			return;
		}
		self::$table_checked = true;

		// Cheap existence check; avoids dbDelta on every request.
		$found = $this->get_var_prepared( 'SHOW TABLES LIKE %s', $this->table_name );
		if ( $found === $this->table_name ) {
			return;
		}

		$charset_collate = $this->wpdb->get_charset_collate();
		$sql = "CREATE TABLE {$this->table_name} (
			file_hash varchar(64) NOT NULL COMMENT 'SHA-256 hash (hex)',
			verdict varchar(16) NOT NULL COMMENT 'verified|safe',
			last_seen_at int(10) UNSIGNED NOT NULL COMMENT 'Unix timestamp',
			proof varbinary(128) NOT NULL COMMENT 'Signed proof blob (v1)',
			PRIMARY KEY (file_hash),
			KEY idx_verdict (verdict),
			KEY idx_last_seen (last_seen_at)
		) {$charset_collate};";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql );
	}

	public function invalidate_safe(): int {
		return (int) $this->query_prepared(
			"DELETE FROM {$this->table_identifier()} WHERE verdict = %s",
			self::VERDICT_SAFE
		);
	}

	/**
	 * Invalidate all cached hash verdicts.
	 *
	 * Used when the server signals a global cache epoch change and all
	 * local proofs must be discarded to force revalidation.
	 *
	 * @return int Rows deleted.
	 */
	public function invalidate_all(): int {
		return (int) $this->query_prepared( "DELETE FROM {$this->table_identifier()}" );
	}

	public function cleanup_expired( ?int $now = null ): int {
		$now = $now ?? time();
		$cutoff = $now - ( self::TTL_DAYS * DAY_IN_SECONDS );

		return (int) $this->query_prepared(
			"DELETE FROM {$this->table_identifier()} WHERE last_seen_at < %d",
			$cutoff
		);
	}

	/**
	 * Upsert a signed verdict for a hash.
	 *
	 * @param string      $file_hash            Hex SHA-256.
	 * @param string      $verdict              'safe'|'verified'
	 * @param string      $proof                Binary proof.
	 * @param string|null $expected_sig_version Expected signature version for safe verdicts.
	 */
	public function upsert( string $file_hash, string $verdict, string $proof, ?string $expected_sig_version = null ): bool {
		$now = time();

		if ( empty( $file_hash ) || empty( $proof ) ) {
			return false;
		}

		if ( ! in_array( $verdict, [ self::VERDICT_SAFE, self::VERDICT_VERIFIED ], true ) ) {
			return false;
		}

		// Always verify before storing.
		$verified = HashCacheProof::verify( $file_hash, $proof );
		if ( ! $verified || $verified['verdict'] !== $verdict ) {
			return false;
		}
		if ( $verdict === self::VERDICT_SAFE ) {
			if ( empty( $expected_sig_version ) || $verified['sig_version'] !== $expected_sig_version ) {
				return false;
			}
		}

		// Store minimal state.
		$res = $this->wpdb->replace(
			$this->table_name,
			[
				'file_hash'    => $file_hash,
				'verdict'      => $verdict,
				'last_seen_at' => $now,
				'proof'        => $proof,
			],
			[ '%s', '%s', '%d', '%s' ]
		);

		return $res !== false;
	}

	/**
	 * Bulk verdict lookup for a batch of hashes.
	 *
	 * Single SELECT IN() instead of N individual queries.
	 * Applies the same TTL, age, sig-version, and proof-verification rules as get_valid_verdict().
	 *
	 * @param string[] $hashes           SHA-256 hashes to look up.
	 * @param string|null $pinned_sig_version Pinned signature version for safe-cache validation.
	 * @return array<string, string> Map of hash => verdict for valid cache hits only.
	 */
	public function get_bulk_verdicts( array $hashes, ?string $pinned_sig_version = null ): array {
		if ( empty( $hashes ) ) {
			return [];
		}

		$unique_hashes = array_values( array_unique( $hashes ) );
		$placeholders  = implode( ',', array_fill( 0, count( $unique_hashes ), '%s' ) );

		$rows = $this->get_results_prepared(
			"SELECT file_hash, verdict, last_seen_at, proof FROM {$this->table_identifier()} WHERE file_hash IN ({$placeholders})",
			...$unique_hashes
		);

		if ( empty( $rows ) ) {
			return [];
		}

		$now           = time();
		$cutoff        = $now - ( self::TTL_DAYS * DAY_IN_SECONDS );
		$results       = [];
		$delete_hashes = [];
		$touch_hashes  = [];

		foreach ( $rows as $row ) {
			$hash      = (string) ( $row['file_hash'] ?? '' );
			$verdict   = (string) ( $row['verdict'] ?? '' );
			$proof     = (string) ( $row['proof'] ?? '' );
			$last_seen = (int)   ( $row['last_seen_at'] ?? 0 );

			// TTL expired
			if ( $last_seen > 0 && $last_seen < $cutoff ) {
				$delete_hashes[] = $hash;
				continue;
			}

			if ( empty( $proof ) ) {
				continue;
			}

			// Fast path: decode() parses proof structure without Ed25519 crypto.
			// Full signature verification happens once at upsert() time. Entries expire
			// after 7-30 days and are re-verified via cloud recheck on next upsert.
			$decoded = HashCacheProof::decode( $proof );
			if ( ! $decoded || $decoded['verdict'] !== $verdict ) {
				$delete_hashes[] = $hash;
				continue;
			}

			$issued_at = (int) ( $decoded['issued_at'] ?? 0 );
			if ( $issued_at <= 0 ) {
				$delete_hashes[] = $hash;
				continue;
			}

			$age_days = (int) floor( ( $now - $issued_at ) / DAY_IN_SECONDS );
			if ( $verdict === self::VERDICT_SAFE && $age_days > self::SAFE_MAX_AGE_DAYS ) {
				$delete_hashes[] = $hash;
				continue;
			}
			if ( $verdict === self::VERDICT_VERIFIED && $age_days > self::VERIFIED_MAX_AGE_DAYS ) {
				$delete_hashes[] = $hash;
				continue;
			}

			// Safe cache is bound to a specific signature version.
			if ( $verdict === self::VERDICT_SAFE ) {
				if ( empty( $pinned_sig_version ) || $decoded['sig_version'] !== $pinned_sig_version ) {
					continue;
				}
			}

			$results[ $hash ] = $verdict;

			if ( $last_seen <= 0 || ( $now - $last_seen ) >= self::TOUCH_MIN_AGE ) {
				$touch_hashes[] = $hash;
			}
		}

		// Bulk delete stale / tampered entries.
		if ( ! empty( $delete_hashes ) ) {
			$del_placeholders = implode( ',', array_fill( 0, count( $delete_hashes ), '%s' ) );
			$this->query_prepared(
				"DELETE FROM {$this->table_identifier()} WHERE file_hash IN ({$del_placeholders})",
				...$delete_hashes
			);
		}

		// Bulk touch last_seen_at for valid entries that need refreshing.
		if ( ! empty( $touch_hashes ) ) {
			$touch_placeholders = implode( ',', array_fill( 0, count( $touch_hashes ), '%s' ) );
			$this->query_prepared(
				"UPDATE {$this->table_identifier()} SET last_seen_at = %d WHERE file_hash IN ({$touch_placeholders})",
				$now,
				...$touch_hashes
			);
		}

		return $results;
	}

	public function get_valid_verdict( string $file_hash, ?string $pinned_sig_version = null ): ?string {
		$row = $this->get_row_prepared(
			"SELECT verdict, last_seen_at, proof FROM {$this->table_identifier()} WHERE file_hash = %s LIMIT 1",
			$file_hash
		);

		if ( ! $row ) {
			return null;
		}

		$now = time();
		$cutoff = $now - ( self::TTL_DAYS * DAY_IN_SECONDS );
		$last_seen = (int) ( $row['last_seen_at'] ?? 0 );
		if ( $last_seen > 0 && $last_seen < $cutoff ) {
			$this->wpdb->delete( $this->table_name, [ 'file_hash' => $file_hash ], [ '%s' ] );
			return null;
		}

		$verdict = (string) ( $row['verdict'] ?? '' );
		$proof   = (string) ( $row['proof'] ?? '' );

		if ( empty( $proof ) ) {
			return null;
		}

		$decoded = HashCacheProof::decode( $proof );
		if ( ! $decoded || $decoded['verdict'] !== $verdict ) {
			$this->wpdb->delete( $this->table_name, [ 'file_hash' => $file_hash ], [ '%s' ] );
			return null;
		}

		$issued_at = (int) ( $decoded['issued_at'] ?? 0 );
		if ( $issued_at <= 0 ) {
			$this->wpdb->delete( $this->table_name, [ 'file_hash' => $file_hash ], [ '%s' ] );
			return null;
		}

		$age_days = (int) floor( ( $now - $issued_at ) / DAY_IN_SECONDS );
		if ( $verdict === self::VERDICT_SAFE && $age_days > self::SAFE_MAX_AGE_DAYS ) {
			$this->wpdb->delete( $this->table_name, [ 'file_hash' => $file_hash ], [ '%s' ] );
			return null;
		}
		if ( $verdict === self::VERDICT_VERIFIED && $age_days > self::VERIFIED_MAX_AGE_DAYS ) {
			$this->wpdb->delete( $this->table_name, [ 'file_hash' => $file_hash ], [ '%s' ] );
			return null;
		}

		if ( $verdict === self::VERDICT_SAFE ) {
			if ( empty( $pinned_sig_version ) || $decoded['sig_version'] !== $pinned_sig_version ) {
				return null;
			}
		}

		// Touch last_seen_at to keep the entry alive, but avoid updating too frequently.
		if ( $last_seen <= 0 || ( $now - $last_seen ) >= self::TOUCH_MIN_AGE ) {
			$this->wpdb->update(
				$this->table_name,
				[ 'last_seen_at' => $now ],
				[ 'file_hash' => $file_hash ],
				[ '%d' ],
				[ '%s' ]
			);
		}

		return $verdict;
	}

	private function table_identifier(): string {
		if ( $this->table_name === '' || strlen( $this->table_name ) > 64 || ! preg_match( '/^[A-Za-z0-9_]+$/', $this->table_name ) ) {
			throw new \InvalidArgumentException( 'Invalid table identifier.' );
		}

		return '`' . str_replace( '`', '``', $this->table_name ) . '`';
	}

	private function get_results_prepared( string $sql, ...$params ): array {
		$prepared = $this->prepare_sql( $sql, ...$params );
		if ( '' === $prepared ) {
			return [];
		}

			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared repository query.
			$results = $this->wpdb->get_results( $prepared, ARRAY_A );
		return is_array( $results ) ? $results : [];
	}

	private function get_row_prepared( string $sql, ...$params ): ?array {
		$prepared = $this->prepare_sql( $sql, ...$params );
		if ( '' === $prepared ) {
			return null;
		}

			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared repository query.
			$result = $this->wpdb->get_row( $prepared, ARRAY_A );
		return is_array( $result ) ? $result : null;
	}

	private function get_var_prepared( string $sql, ...$params ) {
		$prepared = $this->prepare_sql( $sql, ...$params );
		if ( '' === $prepared ) {
			return null;
		}

			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared repository query.
			return $this->wpdb->get_var( $prepared );
	}

	private function query_prepared( string $sql, ...$params ) {
		$prepared = empty( $params ) ? $sql : $this->prepare_sql( $sql, ...$params );
		if ( '' === $prepared ) {
			return false;
		}

			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared repository query.
			return $this->wpdb->query( $prepared );
	}

	private function prepare_sql( string $sql, ...$params ): string {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Bound with wpdb::prepare().
			$prepared = $this->wpdb->prepare( $sql, ...$params );
		return is_string( $prepared ) ? $prepared : '';
	}
}
