<?php

namespace LLAR\Core\Mfa;

use LLAR\Core\Config;
use LLAR\Core\MfaConstants;

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

/**
 * MFA rescue endpoint: decrypt, verify, disable MFA. One-time
 * transient per link plus per-code "used" in config. Prefetch/preview
 * requests are ignored so they do not consume the one-time link.
 * Uses MfaValidator for hash_id validation; MfaBackupCodes for decrypt/verify.
 */
class MfaEndpoint implements MfaEndpointInterface {

	/** Non-informative message for 403/500 (do not reveal internal details). */
	const MSG_ERROR = 'An error occurred.';

	/**
	 * Backup codes (decrypt, verify)
	 *
	 * @var MfaBackupCodesInterface
	 */
	private $backup_codes;

	/**
	 * Constructor.
	 *
	 * @param MfaBackupCodesInterface $backup_codes Backup codes service (decrypt, verify).
	 */
	public function __construct( MfaBackupCodesInterface $backup_codes ) {
		$this->backup_codes = $backup_codes;
	}

	/**
	 * Handle rescue endpoint request. Validates format, then decrypts, verifies code,
	 * temporarily disables MFA, or wp_die.
	 *
	 * @param string $hash_id Hash ID from URL (llar_rescue query var).
	 * @return void
	 */
	public function handle( $hash_id ) {
		$hash_id = MfaValidator::validate_rescue_hash_id( $hash_id );
		if ( false === $hash_id ) {
			wp_die( self::MSG_ERROR, 'LLAR MFA Rescue', array( 'response' => 403 ) );
		}

		$is_confirmed_rescue_post = (
			defined( 'LLA_MFA_RESCUE_PREFETCH_BYPASS_ARG' )
			&& isset( $_POST[ LLA_MFA_RESCUE_PREFETCH_BYPASS_ARG ] )
			&& '1' === (string) wp_unslash( $_POST[ LLA_MFA_RESCUE_PREFETCH_BYPASS_ARG ] )
		);

		if ( ! $is_confirmed_rescue_post && $this->is_rescue_request_prefetch() ) {
			$this->render_rescue_confirmation_page( $hash_id );
			exit;
		}

		// Read payload (delete only after successful verification so failed attempts are not "burned").
		$encrypted_data = $this->read_rescue_encrypted_payload( $hash_id );
		if ( false === $encrypted_data ) {
			wp_die( self::MSG_ERROR, 'LLAR MFA Rescue', array( 'response' => 403 ) );
		}

		$plain_code = $this->backup_codes->decrypt_code( $encrypted_data );
		if ( false === $plain_code ) {
			$this->remove_rescue_payload_from_options( $hash_id );
			wp_die( self::MSG_ERROR, 'LLAR MFA Rescue', array( 'response' => 403 ) );
		}

		$codes = Config::get( 'mfa_rescue_codes', array() );
		if ( ! is_array( $codes ) || empty( $codes ) ) {
			$this->remove_rescue_payload_from_options( $hash_id );
			wp_die( self::MSG_ERROR, 'LLAR MFA Rescue', array( 'response' => 403 ) );
		}

		$code_verified  = false;
		$verified_index = null;
		foreach ( $codes as $index => $code_data ) {
			$rescue_code = RescueCode::from_array( $code_data );
			if ( $rescue_code->is_used() ) {
				continue;
			}
			if ( $rescue_code->verify( $plain_code ) ) {
				$code_verified  = true;
				$verified_index = $index;
			}
		}

		if ( $code_verified && null !== $verified_index ) {
			$deleted = $this->remove_rescue_payload_from_options( $hash_id );
			if ( 1 !== (int) $deleted ) {
				wp_die( self::MSG_ERROR, 'LLAR MFA Rescue', array( 'response' => 403 ) );
			}
			$rescue_code = RescueCode::from_array( $codes[ $verified_index ] );
			$rescue_code->mark_as_used();
			$codes[ $verified_index ] = $rescue_code->to_array();
			Config::update( 'mfa_rescue_codes', $codes );
			$this->disable_mfa_temporarily();
			$login_url = add_query_arg( 'llar_mfa_disabled', '1', wp_login_url() );
			wp_safe_redirect( $login_url );
			exit;
		}

		$this->remove_rescue_payload_from_options( $hash_id );
		wp_die( self::MSG_ERROR, 'LLAR MFA Rescue', array( 'response' => 403 ) );
	}

	/**
	 * Read and validate stored encrypted payload (does not remove it). Falls back to direct
	 * wp_options read when object cache misses (some hosts/stacks show the row in DB but not in cache).
	 *
	 * @param string $hash_id Validated hash_id from URL.
	 * @return string|false Encrypted payload or false.
	 */
	private function read_rescue_encrypted_payload( $hash_id ) {
		$transient_key  = MfaConstants::TRANSIENT_RESCUE_PREFIX . $hash_id;
		$encrypted_data = get_transient( $transient_key );
		if ( false === $encrypted_data ) {
			global $wpdb;
			$option_name = '_transient_' . $transient_key;
			$raw         = $wpdb->get_var(
				$wpdb->prepare(
					'SELECT option_value FROM ' . $wpdb->options . ' WHERE option_name = %s LIMIT 1',
					$option_name
				)
			);
			if ( null !== $raw && '' !== $raw ) {
				$encrypted_data = maybe_unserialize( $raw );
			}
		}
		if ( false === $encrypted_data ) {
			return false;
		}
		if ( ! is_string( $encrypted_data ) || '' === $encrypted_data ) {
			return false;
		}
		$max_len = 2048;
		if ( strlen( $encrypted_data ) > $max_len ) {
			return false;
		}
		$v2_prefix = 'v2:';
		if ( 0 === strpos( $encrypted_data, $v2_prefix ) ) {
			$payload = substr( $encrypted_data, strlen( $v2_prefix ) );
			if ( '' === $payload || ! preg_match( '/^[A-Za-z0-9+\/=]+$/', $payload ) ) {
				return false;
			}
		} elseif ( ! preg_match( '/^[A-Za-z0-9+\/=]+$/', $encrypted_data ) ) {
			return false;
		}
		return $encrypted_data;
	}

	/**
	 * Remove the one-time payload row from the database (and timeout row). Returns rows deleted (0 or 1 for value row).
	 *
	 * @param string $hash_id Validated hash_id.
	 * @return int Deleted rows for _transient_* value row.
	 */
	private function remove_rescue_payload_from_options( $hash_id ) {
		$transient_key = MfaConstants::TRANSIENT_RESCUE_PREFIX . $hash_id;
		global $wpdb;
		$option_name = '_transient_' . $transient_key;
		$deleted     = $wpdb->query(
			$wpdb->prepare(
				'DELETE FROM ' . $wpdb->options . ' WHERE option_name = %s',
				$option_name
			)
		);
		if ( 1 === (int) $deleted ) {
			$timeout_name = '_transient_timeout_' . $transient_key;
			$wpdb->query(
				$wpdb->prepare(
					'DELETE FROM ' . $wpdb->options . ' WHERE option_name = %s',
					$timeout_name
				)
			);
			delete_transient( MfaConstants::RESCUE_MAX_EXPIRY_CACHE_KEY );
		}
		return (int) $deleted;
	}

	/**
	 * Whether the request is a speculative/automated load that must not consume the one-time rescue
	 * transient. A request is treated as prefetch/preview when ANY of the following holds:
	 *   - Sec-Purpose or Purpose header contains "prefetch" or "prerender" (Chrome/Edge speculation);
	 *   - X-Moz: prefetch (Firefox);
	 *   - No Sec-Fetch-* headers at all (link-preview / bot / scanner);
	 *   - Sec-Fetch-* present but not a real user-activated top-level navigation
	 *     (Sec-Fetch-Dest: document + Sec-Fetch-Mode: navigate + Sec-Fetch-User: ?1).
	 *
	 * @return bool
	 */
	private function is_rescue_request_prefetch() {
		$sec_purpose = isset( $_SERVER['HTTP_SEC_PURPOSE'] ) && is_string( $_SERVER['HTTP_SEC_PURPOSE'] ) ? $_SERVER['HTTP_SEC_PURPOSE'] : '';
		$purpose     = isset( $_SERVER['HTTP_PURPOSE'] ) && is_string( $_SERVER['HTTP_PURPOSE'] ) ? $_SERVER['HTTP_PURPOSE'] : '';
		$x_moz       = isset( $_SERVER['HTTP_X_MOZ'] ) && is_string( $_SERVER['HTTP_X_MOZ'] ) ? $_SERVER['HTTP_X_MOZ'] : '';
		$sec_f_dest  = isset( $_SERVER['HTTP_SEC_FETCH_DEST'] ) && is_string( $_SERVER['HTTP_SEC_FETCH_DEST'] ) ? $_SERVER['HTTP_SEC_FETCH_DEST'] : '';
		$sec_f_mode  = isset( $_SERVER['HTTP_SEC_FETCH_MODE'] ) && is_string( $_SERVER['HTTP_SEC_FETCH_MODE'] ) ? $_SERVER['HTTP_SEC_FETCH_MODE'] : '';
		$sec_f_user  = isset( $_SERVER['HTTP_SEC_FETCH_USER'] ) && is_string( $_SERVER['HTTP_SEC_FETCH_USER'] ) ? $_SERVER['HTTP_SEC_FETCH_USER'] : '';
		$sec_f_site  = isset( $_SERVER['HTTP_SEC_FETCH_SITE'] ) && is_string( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ? $_SERVER['HTTP_SEC_FETCH_SITE'] : '';

		foreach ( array( $sec_purpose, $purpose ) as $p ) {
			if ( '' !== $p && ( false !== stripos( $p, 'prefetch' ) || false !== stripos( $p, 'prerender' ) ) ) {
				return true;
			}
		}
		if ( 'prefetch' === strtolower( $x_moz ) ) {
			return true;
		}
		$has_any_sec_fetch = ( '' !== $sec_f_dest || '' !== $sec_f_mode || '' !== $sec_f_user || '' !== $sec_f_site );
		if ( ! $has_any_sec_fetch ) {
			return true;
		}
		$looks_like_user_nav = ( 'document' === strtolower( $sec_f_dest ) )
			&& ( 'navigate' === strtolower( $sec_f_mode ) )
			&& ( '?1' === $sec_f_user );
		$is_prefetch = ! $looks_like_user_nav;
		return $is_prefetch;
	}

	/**
	 * Render confirmation page when a request looks like a prefetch.
	 *
	 * @param string $hash_id Validated rescue hash.
	 * @return void
	 */
	private function render_rescue_confirmation_page( $hash_id ) {
		$request_uri = isset( $_SERVER['REQUEST_URI'] ) && is_string( $_SERVER['REQUEST_URI'] )
			? $_SERVER['REQUEST_URI']
			: '/';
		$absolute_request_url = get_site_url( null, $request_uri );

		$rescue_prefetch_intro        = __( 'This will disable 2FA on your website for one hour.', 'limit-login-attempts-reloaded' );
		$rescue_prefetch_form_action  = $absolute_request_url;
		$rescue_prefetch_field_name   = LLA_MFA_RESCUE_PREFETCH_BYPASS_ARG;
		$rescue_prefetch_field_value  = '1';
		$rescue_prefetch_button_label = __( 'Click to continue', 'limit-login-attempts-reloaded' );

		ob_start();
		include LLA_PLUGIN_DIR . 'views/mfa-rescue-prefetch-confirm.php';
		$html = ob_get_clean();

		wp_die(
			$html,
			'LLAR MFA Rescue',
			array( 'response' => 200 )
		);
	}

	private function disable_mfa_temporarily() {
		// Only set transient so MFA is disabled for LLA_MFA_DISABLE_DURATION. Do not change
		// Config 'mfa_enabled' — when transient expires, MFA is effectively on again without user action.
		if ( false === get_transient( MfaConstants::TRANSIENT_MFA_DISABLED ) ) {
			set_transient( MfaConstants::TRANSIENT_MFA_DISABLED, 1, MfaConstants::MFA_DISABLE_DURATION );
		}
	}

}
