<?php

declare(strict_types=1);

namespace SiteFort\Modules\Hardening;

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

use SiteFort\Includes\IpDetector;
use SiteFort\Includes\SettingsStore;
use SiteFort\Modules\AuditLog\AuditLog;

/**
 * Signed unlock links for login-lockout notification emails.
 *
 * The link has to work for a logged-out visitor because the recipient may be
 * the locked-out admin. GET renders a confirmation page; the unlock only
 * happens on POST so mail scanners that prefetch links cannot lift a lockout.
 */
class LockoutUnlockLink {

	private const MAC_PREFIX = 'sitefort-lockout-unlock.v1.';

	public static function build_url( string $type, string $identifier ): string {
		$token = self::mint( $type, $identifier );
		if ( '' === $token ) {
			return '';
		}

		return add_query_arg(
			[
				'action' => 'sitefort_unlock',
				't'      => $token,
			],
			admin_url( 'admin-post.php' )
		);
	}

	/**
	 * Link lifetime: at least the lockout duration so the link outlives the
	 * lockout it reports, clamped to a day.
	 */
	public static function validity_seconds(): int {
		$settings = Hardening::get_saved_settings();
		$duration = absint( $settings['limit_login_attempts_lockout'] ?? 0 );

		return (int) min( DAY_IN_SECONDS, max( HOUR_IN_SECONDS, $duration ) );
	}

	public static function handle_request(): void {
		nocache_headers();

		$request_ip = (string) IpDetector::get_ip();
		if ( self::too_many_invalid_attempts( $request_ip ) ) {
			self::render_page(
				403,
				'Unlock Unavailable',
				'HTTP 403',
				'<p class="subtitle">Unlock requests from your network are temporarily paused.</p>'
				. '<div class="reason-box"><p>Too many invalid unlock attempts. Please try again in a few minutes.</p></div>'
			);
		}

		$token  = self::read_token();
		$parsed = '' !== $token ? self::verify( $token ) : null;
		if ( null === $parsed ) {
			self::record_invalid_attempt( $request_ip );
			self::render_page(
				403,
				'Link Invalid or Expired',
				'HTTP 403',
				'<p class="subtitle">This unlock link could not be used.</p>'
				. '<div class="reason-box"><p>Links expire a while after the lockout that produced them. A fresh one arrives with the next lockout notification, or lift lockouts from the SiteFort dashboard.</p></div>'
			);
		}

		$method = isset( $_SERVER['REQUEST_METHOD'] ) && is_scalar( $_SERVER['REQUEST_METHOD'] )
			? strtoupper( sanitize_text_field( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) )
			: '';

		if ( 'POST' === $method ) {
			self::perform_unlock( $parsed['type'], $parsed['identifier'], $request_ip );
			self::render_page(
				200,
				'Lockout Removed',
				'',
				'<div class="stack-center">'
				. '<div class="subject-label">' . esc_html( self::scope_label( $parsed['type'] ) ) . '</div>'
				. '<div class="subject-chip">' . esc_html( $parsed['identifier'] ) . '</div>'
				. '<p class="note">The lockout has been removed and the failed-attempt counter reset.<br>You can try signing in again.</p>'
				. '</div>'
			);
		}

		self::render_confirm_page( $token, $parsed['type'], $parsed['identifier'] );
	}

	private static function mint( string $type, string $identifier ): string {
		$identifier = sanitize_text_field( trim( $identifier ) );
		if ( '' === $identifier || strlen( $identifier ) > 200 || ! in_array( $type, [ 'ip', 'user' ], true ) ) {
			return '';
		}

		$expires = time() + self::validity_seconds();
		$payload = self::b64url_encode( $type . '|' . $identifier );

		return 'v1.' . $expires . '.' . $payload . '.' . self::mac( $expires . '.' . $payload );
	}

	/**
	 * @return array{type: string, identifier: string}|null Null when invalid or expired.
	 */
	private static function verify( string $token ): ?array {
		if ( 3 !== substr_count( $token, '.' ) ) {
			return null;
		}

		list( $version, $expires, $payload, $mac ) = explode( '.', $token, 4 );
		if ( 'v1' !== $version || '' === $mac || '' === $payload || ! ctype_digit( $expires ) ) {
			return null;
		}

		if ( ! hash_equals( self::mac( $expires . '.' . $payload ), $mac ) ) {
			return null;
		}

		if ( (int) $expires <= time() ) {
			return null;
		}

		$decoded = self::b64url_decode( $payload );
		if ( ! is_string( $decoded ) ) {
			return null;
		}

		$parts      = explode( '|', $decoded, 2 );
		$type       = (string) ( $parts[0] ?? '' );
		$identifier = sanitize_text_field( trim( (string) ( $parts[1] ?? '' ) ) );
		if ( '' === $identifier || ! in_array( $type, [ 'ip', 'user' ], true ) ) {
			return null;
		}

		return [
			'type'       => $type,
			'identifier' => $identifier,
		];
	}

	private static function perform_unlock( string $type, string $identifier, string $request_ip ): void {
		// Reset attempt counters for the named subject and the clicker so one
		// stray failure cannot re-lock them straight away.
		if ( 'ip' === $type ) {
			Hardening::clear_ip_login_attempts( $identifier );
		} else {
			Hardening::clear_user_login_attempts( $identifier );
		}
		if ( '' !== $request_ip && $request_ip !== $identifier ) {
			Hardening::clear_ip_login_attempts( $request_ip );
		}

		// Every unlock link comes from the same admin mailbox, so a confirmed
		// click means "let me back in"; an old-but-valid link must never
		// half-fix a newer lockout.
		$counts  = LockoutRegistry::count_active_lockouts();
		$cleared = ( $counts['ip'] ?? 0 ) + ( $counts['user'] ?? 0 );
		LockoutRegistry::clear_all_lockouts();
		do_action( 'sitefort_lockout_cleared', $identifier, $type );

		AuditLog::add_log(
			'Lockout Cleared via Email Link',
			[
				'type'           => $type,
				'identifier'     => $identifier,
				'request_ip'     => $request_ip,
				'cleared'        => $cleared,
				'attempts_reset' => true,
			],
			'warning',
			'authentication'
		);
	}

	private static function read_token(): string {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- The HMAC token is the authorization.
		if ( isset( $_POST['t'] ) && is_string( $_POST['t'] ) ) {
			$raw = sanitize_text_field( wp_unslash( $_POST['t'] ) );
		} elseif ( isset( $_GET['t'] ) && is_string( $_GET['t'] ) ) {
			$raw = sanitize_text_field( wp_unslash( $_GET['t'] ) );
		} else {
			return '';
		}
		// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing

		return (bool) preg_match( '/^[A-Za-z0-9._-]{1,400}$/', $raw ) ? $raw : '';
	}

	private static function mac( string $message ): string {
		return hash_hmac( 'sha256', self::MAC_PREFIX . $message, self::get_secret() );
	}

	private static function get_secret(): string {
		$data = SettingsStore::get_field( SettingsStore::COL_STATE, 'lockout_unlock_secret', [] );
		if ( is_array( $data ) && ! empty( $data['secret'] ) && is_string( $data['secret'] ) && (bool) preg_match( '/^[a-f0-9]{64}$/', $data['secret'] ) ) {
			return $data['secret'];
		}

		$secret = bin2hex( random_bytes( 32 ) );
		SettingsStore::set_field( SettingsStore::COL_STATE, 'lockout_unlock_secret', [
			'secret'       => $secret,
			'generated_at' => time(),
		] );

		return $secret;
	}

	private static function too_many_invalid_attempts( string $ip ): bool {
		if ( '' === $ip ) {
			return false;
		}

		return (int) get_transient( 'sitefort_unlock_fails_' . md5( $ip ) ) >= 10;
	}

	private static function record_invalid_attempt( string $ip ): void {
		if ( '' === $ip ) {
			return;
		}

		$key = 'sitefort_unlock_fails_' . md5( $ip );
		set_transient( $key, (int) get_transient( $key ) + 1, 15 * MINUTE_IN_SECONDS );
	}

	private static function render_confirm_page( string $token, string $type, string $identifier ): void {
		$body = '<div class="stack-center">'
			. '<div class="subject-label">' . esc_html( self::scope_label( $type ) ) . '</div>'
			. '<div class="subject-chip">' . esc_html( $identifier ) . '</div>'
			. '<p class="note">Unlocking allows sign-in attempts again and resets the failed-attempt counter.</p>'
			. '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">'
			. '<input type="hidden" name="action" value="sitefort_unlock">'
			. '<input type="hidden" name="t" value="' . esc_attr( $token ) . '">'
			. '<button type="submit" class="action-btn">Unlock</button>'
			. '</form>'
			. '<p class="micro">Nothing changes until you confirm.</p>'
			. '</div>';

		self::render_page( 200, 'Confirm Unlock', '', $body );
	}

	private static function scope_label( string $type ): string {
		return 'ip' === $type ? 'IP address' : 'User account';
	}

	/**
	 * Renders through the shared branded shell: works logged-out, never
	 * touches the theme, and shows no login URL so login rename stays hidden.
	 *
	 * @param string $body Pre-escaped HTML.
	 */
	private static function render_page( int $status, string $title, string $badge, string $body ): void {
		if ( ! headers_sent() ) {
			http_response_code( $status );
			header( 'Content-Type: text/html; charset=UTF-8' );
			header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );
			header( 'X-Frame-Options: DENY' );
		}

		if ( ! function_exists( 'sitefort_render_branded_page' ) ) {
			$shell = dirname( __DIR__ ) . '/Firewall/block-page.php';
			if ( is_readable( $shell ) ) {
				require_once $shell;
			}
		}

		if ( function_exists( 'sitefort_render_branded_page' ) ) {
			sitefort_render_branded_page( $title, $title, $badge, $body, (string) IpDetector::get_ip() );
			exit;
		}

		$safe_title = esc_html( $title );
		// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- Title escaped above, body escaped by callers.
		echo '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>' . $safe_title . '</title></head><body><h1>' . $safe_title . '</h1>' . $body . '</body></html>';
		// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
		exit;
	}

	private static function b64url_encode( string $value ): string {
		return rtrim( strtr( base64_encode( $value ), '+/', '-_' ), '=' );
	}

	/**
	 * @return string|false
	 */
	private static function b64url_decode( string $value ) {
		return base64_decode( strtr( $value, '-_', '+/' ), true );
	}
}
