<?php
/**
 * File Path: includes/api/class-mbs-tamper-detector.php
 *
 * My Book Showroom — Tamper Detector
 *
 * File integrity monitoring and anti-tamper protection.
 *
 *
 */

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

class MBS_Tamper_Detector {

	/** The exact 17 monitored files (relative to plugin root). */
	private static array $monitored_files = array(
		// Core bootstrap and autoloader.
		'bookshowroom.php',
		'includes/class-mbs-plugin.php',
		'includes/class-mbs-autoloader.php',
		// API / security layer.
		'includes/api/class-mbs-license-client.php',
		'includes/api/license/class-mbs-license-ping.php',
		'includes/api/class-mbs-hmac.php',
		'includes/api/class-mbs-encryption.php',
		'includes/api/class-mbs-tamper-detector.php',
		// Admin / settings / license UI.
		'includes/admin/class-mbs-settings.php',
		'includes/admin/tabs/class-mbs-tab-license.php',
		// Watermark and renderers.
		'templates/partials/watermark.php',
		'includes/helpers/class-mbs-cover-renderer.php',
		// Front-end display and assets.
		'includes/class-mbs-book-display.php',
		'includes/class-mbs-front-assets.php',
		// Metaboxes.
		'includes/admin/class-mbs-metabox-arc.php',
		'includes/metaboxes/class-mbs-metabox-universe.php',
		// Export / IO.
		'includes/export/class-mbs-settings-io-keys.php',
	);

	/**
	 * Build local file hashes for the ping payload.
	 *
	 * @return array
	 */
	public function build_file_hashes(): array {
		$hashes     = array();
		$plugin_dir = MBS_DIR; // Uses the canonical constant (includes trailing slash)

		foreach ( self::$monitored_files as $relative_path ) {
			$abs_path                  = $plugin_dir . $relative_path;
			$hashes[ $relative_path ] = file_exists( $abs_path )
				? hash_file( 'sha256', $abs_path )
				: 'FILE_MISSING';
		}

		set_transient( 'mbs_file_hashes_cache', wp_json_encode( $hashes ), DAY_IN_SECONDS );
		return $hashes;
	}

	/**
	 * Fetch and verify the manifest from the server.
	 *
	 * On failure, stores a sanitized error message in 'mbs_manifest_last_error'
	 * so the admin UI can surface the failure without writing to server logs.
	 *
	 * @return bool True on success.
	 */
	public function fetch_and_verify_manifest(): bool {
		if ( MBS_VERSION === get_option( 'mbs_manifest_version' ) ) {
			return true;
		}

		// D-3 (April 2026): Manifest is now authenticated POST with api_secret.
		// S-01: MBS_HMAC::sign_request() and MBS_HMAC::verify_response() now wired.
		// MBS_MANIFEST_KEY constant has been removed.
		$api_secret_enc = get_option( 'mbs_api_secret_encrypted', '' );
		$api_secret_raw = '';
		$license_key    = '';
		if ( ! empty( $api_secret_enc ) ) {
			$api_secret_raw  = MBS_Encryption::decrypt( $api_secret_enc ) ?: ''; // R1-authorized: license credential machinery -- value not logged.
			$license_key_enc = get_option( 'mbs_license_key_encrypted', '' );
			if ( ! empty( $license_key_enc ) ) {
				$license_key = MBS_Encryption::decrypt( $license_key_enc ) ?: ''; // R1-authorized: license credential machinery -- value not logged.
			}
		}

		if ( empty( $license_key ) ) {
			// B-007: Return true (skip fetch) without caching mbs_manifest_version.
			// Previously wrote MBS_VERSION here, which permanently blocked re-fetch
			// after a fresh install activated its first license — the version would
			// appear cached and fetch_and_verify_manifest() would early-return forever.
			return true;
		}

		// B-006: License key must not travel over plain HTTP in production.
		if ( ! str_starts_with( MBS_API_BASE, 'https://' ) ) {
			update_option(
				'mbs_manifest_last_error',
				'Manifest fetch skipped: MBS_API_BASE must use https:// in production.'
			);
			return false;
		}

		$timestamp = time();
		$nonce     = get_option( 'mbs_api_nonce', wp_generate_uuid4() );
		$domain    = wp_parse_url( home_url(), PHP_URL_HOST );
		$url       = MBS_API_BASE . '/update/manifest';

		$post_body = wp_json_encode( array(
			'license_key'    => $license_key,
			'domain'         => $domain,
			'plugin_version' => MBS_VERSION,
			'timestamp'      => $timestamp,
			'nonce'          => $nonce,
			'hmac'           => '' !== $api_secret_raw // S-01: Sign manifest request.
				? MBS_HMAC::sign_request( $license_key, $domain, $timestamp, $nonce, $api_secret_raw )
				: '',
		) );

		$response = wp_remote_post( $url, array(
			'timeout' => 15,
			'body'    => $post_body,
			'headers' => array( 'Content-Type' => 'application/json' ),
		) );

		if ( is_wp_error( $response ) ) {
			update_option(
				'mbs_manifest_last_error',
				sanitize_text_field( 'Manifest fetch failed: ' . $response->get_error_message() )
			);
			return false;
		}

		$http_code = wp_remote_retrieve_response_code( $response );
		$parsed    = json_decode( wp_remote_retrieve_body( $response ), true );

		if ( 200 !== (int) $http_code || empty( $parsed['files'] ) ) {
			// S-01 not yet deployed — graceful unavailability.
			update_option( 'mbs_manifest_version', MBS_VERSION );
			delete_option( 'mbs_manifest_last_error' );
			return true;
		}

		// S-01: Verify manifest response signature before trusting parsed['files'].
		$canonical_payload = array(
			'version'   => $parsed['version']   ?? '',
			'issued_at' => $parsed['issued_at'] ?? '',
			'files'     => $parsed['files'],
		);
		$canonical_json = wp_json_encode( $canonical_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
		$expected_sig   = '' !== $api_secret_raw
			? hash_hmac( 'sha256', $canonical_json, $api_secret_raw )
			: '';
		if ( ! MBS_HMAC::verify_response( $expected_sig, $parsed['signature'] ?? null, $timestamp ) ) {
			update_option(
				'mbs_manifest_last_error',
				'Manifest signature verification failed — response may be forged.'
			);
			return false;
		}
		delete_option( 'mbs_manifest_last_error' );
		set_transient( 'mbs_manifest_cache', $parsed, DAY_IN_SECONDS );
		update_option( 'mbs_manifest_version', MBS_VERSION );
		return true;
	}

	/**
	 * Apply tamper flag received from server.
	 *
	 * @param int $flag Server-provided tamper_flag.
	 */
	public function apply_tamper_flag( int $flag ): void {
		update_option( 'mbs_tamper_flag', $flag );
	}

	/**
	 * Alias for apply_tamper_flag() to satisfy story 5-7 acceptance criteria.
	 * Escalation count is owned by the server.
	 */
	public function escalate( int $server_flag ): void {
		$this->apply_tamper_flag( $server_flag );
	}

	/**
	 * Get admin notice HTML if tamper flag is active.
	 *
	 * @return string|null
	 */
	public function get_admin_notice(): ?string {
		$flag = (int) get_option( 'mbs_tamper_flag', MBS_TAMPER_CLEAN );
		if ( $flag < MBS_TAMPER_WARN ) {
			return null;
		}

		$notice  = '<div class="notice notice-error"><p>';
		$notice .= esc_html__( 'Your My Book Showroom license has a violation.', 'my-book-showroom' ) . ' ';
		$notice .= '<a href="' . esc_url( MBS_UPGRADE_URL ) . '" target="_blank">';
		$notice .= esc_html__( 'See FAQ', 'my-book-showroom' );
		$notice .= '</a>.';
		$notice .= '</p></div>';

		return $notice;
	}


	/**
	 * Register the tamper admin notice hook.
	 *
	 * @return void
	 * @since 1.3.5
	 */
	public static function register_notice_hooks(): void {
		add_action( 'admin_notices', static function() {
			$notice = (new self())->get_admin_notice();
			if ( $notice ) {
				echo $notice; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			}
		} );
	}

	/**
	 * Register the SEC-3 admin_init hook for file integrity checking.
	 * Moved from MBS_Plugin::init() — SEC-3 / REFACTOR-PLAN-ARCH-001 Batch A.
	 */
	public static function register_hooks(): void {
		add_action( 'admin_init', array( new self(), 'maybe_check_file_integrity' ) );
	}

	/**
	 * SEC-3: Local file integrity check at admin_init.
	 * Compares current file hashes to the manifest cached by the daily server ping.
	 * Fires only once per hour (rate-limited via transient) to avoid per-page overhead.
	 * Moved from MBS_Plugin — REFACTOR-PLAN-ARCH-001 Batch A.
	 *
	 * @return void
	 */
	public function maybe_check_file_integrity(): void {
		// Rate limit: only run once per hour.
		if ( get_transient( 'mbs_integrity_check_ran' ) ) {
			return;
		}
		set_transient( 'mbs_integrity_check_ran', 1, HOUR_IN_SECONDS );

		// Only run on keyed installs (no manifest = keyless FREE; no manifest to verify).
		$manifest_cache = get_transient( 'mbs_manifest_cache' );
		if ( empty( $manifest_cache ) || ! is_array( $manifest_cache ) ) {
			return;
		}

		// The manifest cache stores the full parsed server response; file hashes are in ['files'].
		$manifest_files = isset( $manifest_cache['files'] ) && is_array( $manifest_cache['files'] )
			? $manifest_cache['files']
			: array();

		if ( empty( $manifest_files ) ) {
			return;
		}

		// Build current hashes and compare to cached manifest.
		$detector     = new MBS_Tamper_Detector();
		$current      = $detector->build_file_hashes();
		$tamper_found = false;

		foreach ( $manifest_files as $relative_path => $expected_hash ) {
			if ( ! isset( $current[ $relative_path ] ) ) {
				continue; // File absent from current build -- skip.
			}
			if ( ! hash_equals( (string) $expected_hash, (string) $current[ $relative_path ] ) ) {
				$tamper_found = true;
				break;
			}
		}

		if ( $tamper_found ) {
			// Self-heal: a mismatch is most often a STALE cached manifest (e.g. immediately
			// after a plugin update), not real tampering. Force ONE fresh manifest fetch and
			// re-compare before raising the flag.
			delete_option( 'mbs_manifest_version' );             // bypass the MBS_VERSION gate in fetch_and_verify_manifest()
			$refetched = $detector->fetch_and_verify_manifest(); // re-fetch + re-verify the signed manifest

			if ( $refetched ) {
				$fresh       = get_transient( 'mbs_manifest_cache' );
				$fresh_files = ( is_array( $fresh ) && isset( $fresh['files'] ) && is_array( $fresh['files'] ) )
					? $fresh['files'] : array();
				$still_bad = false;
				foreach ( $fresh_files as $rel => $exp ) {
					if ( isset( $current[ $rel ] ) && ! hash_equals( (string) $exp, (string) $current[ $rel ] ) ) {
						$still_bad = true;
						break;
					}
				}
				if ( $still_bad ) {
					// The fresh, server-signed manifest STILL mismatches the files on disk -> real tamper.
					$detector->apply_tamper_flag( MBS_TAMPER_WARN );
					trigger_error( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- recoverable lifecycle warning
						'MBS: Local file integrity check detected hash mismatch against a freshly-fetched manifest. Possible tampering.',
						E_USER_WARNING
					);
				} elseif ( (int) get_option( 'mbs_tamper_flag', MBS_TAMPER_CLEAN ) <= MBS_TAMPER_WARN ) {
					// Resolved by the fresh manifest -> clear a stale WARN from a prior cycle.
					// Only a WARN-level flag is downgraded here: a server-escalated DISABLE/NUCLEAR
					// is owned by the daily ping and is NEVER auto-cleared by the local self-heal,
					// so this path cannot suppress a real tamper the server has already acted on.
					$detector->apply_tamper_flag( MBS_TAMPER_CLEAN );
				}
			}
			// If the re-fetch FAILED (offline / server error): do NOT raise a false violation
			// now and do NOT clear a pre-existing flag. The next daily ping retries.
		}
	}

}
