<?php

namespace LimitLoginAttempts;

use WP_PowerSuite\Core\AbstractModule;
use WP_PowerSuite\Core\Contracts\DataLocationsInterface;
use WP_PowerSuite\Core\TransientPurger;

class Module extends AbstractModule implements DataLocationsInterface {

	private const MODULE_ID = 'limit-login-attempts';
	private const MODULE_VERSION = '1.2.0';
	private const MIN_LOCKOUT_MINUTES = 5;
	private const MAX_LOCKOUT_MINUTES = 1440;
	private const DEFAULT_LOCKOUT_MINUTES = 15;
	private const DEFAULT_ESCALATED_LOCKOUT_MINUTES = 30;
	private const DEFAULT_LOG_RETENTION_DAYS = 30;
	private const MIN_LOG_RETENTION_DAYS     = 7;
	private const DEFAULT_ATTEMPT_WINDOW_MINUTES = 60;
	private const TABLE_NAME = 'wpps_login_attempts';
	private const CRON_HOOK = 'wpps_login_attempts_prune';
	private const TRANSIENT_PREFIX = 'wpps_login_';
	private const MAX_IP_FILTER_OPTIONS = 500;

	/** @var list<string> Headers that must not be used without a trusted reverse proxy. */
	private const BLOCKED_IP_HEADERS = [
		'HTTP_X_FORWARDED_FOR',
		'HTTP_X_FORWARDED',
		'HTTP_FORWARDED_FOR',
		'HTTP_FORWARDED',
		'HTTP_CLIENT_IP',
		'HTTP_X_CLUSTER_CLIENT_IP',
	];

	private ?array $settings_cache = null;

	private static bool $auth_hooks_registered = false;

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'Limit Login Attempts', 'wp-powersuite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Slow down password-guessing bots by locking out repeated failed logins for increasing cool-off periods—simple protection for your sign-in form.', 'wp-powersuite' );
	}

	public function getContext(): string|array {
		return [ 'admin', 'frontend', 'ajax', 'rest' ];
	}

	public function getDependencies(): array {
		return [];
	}

	public function getDataLocations(): array {
		return [
			'tables' => [ self::TABLE_NAME ],
		];
	}

	protected function registerHooks(): void {
		if ( ! $this->isLicenseActive() ) {
			return;
		}

		$is_admin_screen = is_admin() && ! ( defined( 'DOING_AJAX' ) && DOING_AJAX );

		if ( $is_admin_screen ) {
			add_action( 'admin_menu', [ $this, 'addAdminPage' ], 20 );
			add_action( 'admin_enqueue_scripts', [ $this, 'enqueueLogsPageAssets' ] );
			add_action( 'wp_ajax_wpps_limit_login_attempts_clear', [ $this, 'handleClearLogs' ] );
			add_action( 'admin_init', [ $this, 'maybeCreateLogTable' ], 999 );
			add_action( 'admin_init', [ $this, 'maybeSchedulePruneCron' ], 1000 );
		}

		add_action( 'rest_api_init', [ $this, 'registerRestLockoutFilter' ], 1 );
		add_action( self::CRON_HOOK, [ $this, 'maybePruneOldLogs' ] );

		if ( version_compare( (string) get_bloginfo( 'version' ), '5.6', '>=' ) ) {
			add_action( 'application_password_failed_authentication', [ $this, 'trackAppPasswordFailure' ], 10, 2 );
		}

		if ( $this->shouldRegisterAuthHooksImmediately() ) {
			$this->registerAuthHooks();
		}

		add_action( 'login_init', [ $this, 'registerAuthHooks' ], 0 );
		add_action( 'wp_loaded', [ $this, 'maybeRegisterAuthHooks' ], 0 );

		if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
			add_action( 'init', [ $this, 'registerAuthHooks' ], 0 );
			add_action( 'init', [ $this, 'abortXmlRpcIfLocked' ], 1 );
		}
	}

	/**
	 * Register login enforcement hooks once per request.
	 */
	public function registerAuthHooks(): void {
		if ( self::$auth_hooks_registered ) {
			return;
		}
		self::$auth_hooks_registered = true;

		add_action( 'wp_login_failed', [ $this, 'trackFailedLogin' ], 10, 2 );
		add_filter( 'authenticate', [ $this, 'checkLoginAttempts' ], 5, 3 );
		add_filter( 'authenticate', [ $this, 'clearTransientsOnSuccess' ], 99, 3 );
		add_filter( 'login_errors', [ $this, 'showRemainingAttempts' ], 99999 );
		add_action( 'login_head', [ $this, 'renderLockoutAssets' ] );
		add_action( 'wp_enqueue_scripts', [ $this, 'maybeEnqueueFrontendLockoutAssets' ], 20 );
		add_action( 'login_init', [ $this, 'maybeCreateLogTable' ], 1 );
		add_filter( 'wp_authenticate_user', [ $this, 'enforceLockoutAfterPasswordOk' ], 99999, 2 );
	}

	/**
	 * Whether auth hooks should register during module bootstrap (REST, AJAX login, etc.).
	 */
	private function shouldRegisterAuthHooksImmediately(): bool {
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return true;
		}

		if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
			return true;
		}

		if ( function_exists( 'is_login' ) && is_login() ) {
			return true;
		}

		if ( apply_filters( 'wp_powersuite_is_custom_login_url_request', false ) ) {
			return true;
		}

		if ( $this->isCredentialPostRequest() ) {
			return true;
		}

		return $this->isCredentialAjaxRequest();
	}

	/**
	 * WooCommerce, custom login URLs, and AJAX login forms.
	 */
	public function maybeRegisterAuthHooks(): void {
		if ( self::$auth_hooks_registered ) {
			return;
		}

		if ( $this->shouldRegisterAuthHooksImmediately() ) {
			$this->registerAuthHooks();
		}
	}

	/**
	 * Whether this POST carries login credentials (standard or WooCommerce).
	 */
	private function isCredentialPostRequest(): bool {
		if ( strtoupper( (string) ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) !== 'POST' ) {
			return false;
		}

		if ( isset( $_POST['log'], $_POST['pwd'] ) ) {
			return true;
		}

		if ( isset( $_POST['woocommerce-login-nonce'], $_POST['username'], $_POST['password'] ) ) {
			return true;
		}

		return (bool) apply_filters( 'wp_powersuite_limit_login_count_failed_login_body', false );
	}

	/**
	 * AJAX credential requests (e.g. Two-Factor Authentication wpps_2fa_login).
	 */
	private function isCredentialAjaxRequest(): bool {
		if ( ! defined( 'DOING_AJAX' ) || ! DOING_AJAX ) {
			return false;
		}

		if ( strtoupper( (string) ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) !== 'POST' ) {
			return false;
		}

		$action = isset( $_POST['action'] ) ? sanitize_key( wp_unslash( $_POST['action'] ) ) : '';
		if ( in_array( $action, [ 'wpps_2fa_login' ], true ) ) {
			return true;
		}

		if ( isset( $_POST['log'], $_POST['pwd'] ) ) {
			return true;
		}

		return (bool) apply_filters( 'wp_powersuite_limit_login_count_failed_login_body', false );
	}

	/**
	 * Whether the IP is in an active lockout (for admin logs UI).
	 */
	public function isIpCurrentlyLocked( string $ip ): bool {
		return $this->getLockoutStatus( $ip )['locked'];
	}

	public function registerRestLockoutFilter(): void {
		static $registered = false;
		if ( $registered ) {
			return;
		}
		$registered = true;
		add_filter( 'rest_authentication_errors', [ $this, 'blockRestIfLocked' ], 99 );
	}

	public function addAdminPage(): void {
		add_submenu_page(
			'powersuite',
			__( 'Login Attempts Logs', 'wp-powersuite' ),
			__( 'Login Attempts', 'wp-powersuite' ),
			$this->adminCapability(),
			'wpps-limit-login-attempts-logs',
			[ $this, 'renderLogsPage' ]
		);
	}

	public function enqueueLogsPageAssets( string $hook ): void {
		if ( $hook !== 'wp-powersuite_page_wpps-limit-login-attempts-logs' ) {
			return;
		}

		wp_enqueue_style( 'dashicons' );
		wp_enqueue_style( 'wp-powersuite-admin' );
		wp_enqueue_style( 'wp-powersuite-module-settings' );
		wp_enqueue_style( 'list-tables' );

		$base = defined( 'WP_POWERSUITE_PLUGIN_URL' ) ? WP_POWERSUITE_PLUGIN_URL : '';

		wp_enqueue_style(
			'wpps-limit-login-attempts-logs',
			$base . 'modules/limit-login-attempts/assets/css/logs.css',
			[ 'wp-powersuite-admin', 'wp-powersuite-module-settings', 'list-tables' ],
			self::MODULE_VERSION
		);

		wp_enqueue_script(
			'wpps-limit-login-attempts-logs-admin',
			$base . 'modules/limit-login-attempts/assets/js/logs-admin.js',
			[ 'jquery' ],
			self::MODULE_VERSION,
			true
		);

		wp_localize_script(
			'wpps-limit-login-attempts-logs-admin',
			'wppsLimitLoginLogs',
			[
				'moduleId'   => self::MODULE_ID,
				'moduleName' => $this->getName(),
				'clearedUrl' => admin_url( 'admin.php?page=wpps-limit-login-attempts-logs&cleared=1' ),
				'i18n'       => [
					'confirmClear'      => __( 'Are you sure you want to clear all logs? This action cannot be undone.', 'wp-powersuite' ),
					'clearing'          => __( 'Clearing...', 'wp-powersuite' ),
					'clearAll'          => __( 'Clear All Logs', 'wp-powersuite' ),
					'errorClear'        => __( 'Error clearing logs.', 'wp-powersuite' ),
					'moduleDisabled'    => __( 'Please enable the module first to access its settings.', 'wp-powersuite' ),
					'loadSettingsError' => __( 'Error loading module settings.', 'wp-powersuite' ),
					'saveSettingsError' => __( 'Error saving module settings.', 'wp-powersuite' ),
					'saving'            => __( 'Saving...', 'wp-powersuite' ),
					'saveSettings'      => __( 'Save Settings', 'wp-powersuite' ),
				],
			]
		);
	}

	public function renderLogsPage(): void {
		if ( ! $this->isLicenseActive() ) {
			wp_die(
				esc_html__( 'Limit Login Attempts requires an active license.', 'wp-powersuite' ),
				esc_html__( 'License required', 'wp-powersuite' ),
				[ 'response' => 403, 'back_link' => true ]
			);
		}

		if ( ! $this->canManagePlugin() ) {
			wp_die( esc_html__( 'Permission denied.', 'wp-powersuite' ) );
		}

		if ( isset( $_GET['action'] ) && $_GET['action'] === 'unblock' && isset( $_GET['ip'] ) && isset( $_GET['_wpnonce'] ) ) {
			$ip = sanitize_text_field( wp_unslash( $_GET['ip'] ) );
			if ( wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'unblock_ip_' . $ip ) ) {
				$this->unblockIP( $ip );
				$redirect_url = add_query_arg( 'unblocked', '1', admin_url( 'admin.php?page=wpps-limit-login-attempts-logs' ) );
				if ( isset( $_GET['paged'] ) ) {
					$redirect_url = add_query_arg( 'paged', absint( $_GET['paged'] ), $redirect_url );
				}
				wp_safe_redirect( $redirect_url );
				exit;
			}
		}

		global $wpdb;
		$table_name = self::getTableName();

		$per_page = 50;
		$current_page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1;
		$search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '';
		$filter_ip = isset( $_GET['filter_ip'] ) ? sanitize_text_field( wp_unslash( $_GET['filter_ip'] ) ) : '';
		$filter_country = isset( $_GET['filter_country'] ) ? sanitize_text_field( wp_unslash( $_GET['filter_country'] ) ) : '';

		if ( $filter_ip !== '' && ! filter_var( $filter_ip, FILTER_VALIDATE_IP ) ) {
			$filter_ip = '';
		}

		$where = [ '1=1' ];
		$where_values = [];

		if ( ! empty( $search ) ) {
			$where[] = '(ip_address LIKE %s OR username LIKE %s)';
			$search_like = '%' . $wpdb->esc_like( $search ) . '%';
			$where_values[] = $search_like;
			$where_values[] = $search_like;
		}

		if ( ! empty( $filter_ip ) ) {
			$where[] = 'ip_address = %s';
			$where_values[] = $filter_ip;
		}

		if ( ! empty( $filter_country ) ) {
			$where[] = 'country_code = %s';
			$where_values[] = strtolower( $filter_country );
		}

		$where_clause = implode( ' AND ', $where );

		$count_query = "SELECT COUNT(*) FROM {$table_name} WHERE {$where_clause}";
		if ( ! empty( $where_values ) ) {
			$total_logs = (int) $wpdb->get_var( $wpdb->prepare( $count_query, $where_values ) );
		} else {
			$total_logs = (int) $wpdb->get_var( $count_query );
		}

		$total_pages = ceil( $total_logs / $per_page );
		$offset = ( $current_page - 1 ) * $per_page;

		$query = "SELECT * FROM {$table_name} WHERE {$where_clause} ORDER BY created_at DESC LIMIT %d OFFSET %d";
		$query_values = $where_values;
		$query_values[] = $per_page;
		$query_values[] = $offset;

		if ( count( $where_values ) > 0 ) {
			$logs = $wpdb->get_results( $wpdb->prepare( $query, $query_values ) );
		} else {
			$logs = $wpdb->get_results( $wpdb->prepare( $query, $per_page, $offset ) );
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$unique_ips = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT DISTINCT ip_address FROM {$table_name} ORDER BY ip_address LIMIT %d",
				self::MAX_IP_FILTER_OPTIONS
			)
		);
		$unique_countries = $wpdb->get_col( "SELECT DISTINCT country_code FROM {$table_name} WHERE country_code IS NOT NULL AND country_code != '' ORDER BY country_code" );

		$module_instance = $this;

		include __DIR__ . '/views/logs-page.php';
	}

	public function handleClearLogs(): void {
		if ( ! $this->isLicenseActive() ) {
			wp_send_json_error(
				[
					'message'      => __( 'Limit Login Attempts requires an active license.', 'wp-powersuite' ),
					'code'         => 'license_required',
					'license_page' => admin_url( 'admin.php?page=powersuite-license' ),
				]
			);
		}

		check_ajax_referer( 'wpps_limit_login_attempts_clear', 'nonce' );

		if ( ! $this->canManagePlugin() ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}

		global $wpdb;
		$table_name = self::getTableName();
		$wpdb->query( "TRUNCATE TABLE {$table_name}" );
		self::clearAllLockoutTransients();

		wp_send_json_success( [ 'message' => __( 'All logs and active lockouts cleared successfully.', 'wp-powersuite' ) ] );
	}

	public function unblockIP( string $ip ): void {
		$this->clearLockoutTransientsForIp( $ip );

		global $wpdb;
		$table_name = self::getTableName();
		$wpdb->update(
			$table_name,
			[ 'lockout_until' => null ],
			[ 'ip_address' => $ip ],
			[ '%s' ],
			[ '%s' ]
		);
	}

	/**
	 * Clear all login lockout transients (used when clearing logs or disabling module).
	 *
	 * Also rebuilds concrete lockout/remaining/block-count keys from every
	 * IP (and username) still on record, so active lockouts are purged even
	 * under an external object cache where they never had a row in wp_options.
	 */
	public static function clearAllLockoutTransients(): void {
		TransientPurger::deleteByPrefix( self::TRANSIENT_PREFIX );
		TransientPurger::deleteByPrefix( 'wpps_ip_geo_' );
		self::deleteConcreteLockoutKeysFromLog();
	}

	/**
	 * Build and delete concrete lockout-related transient keys for every
	 * IP/username pair recorded in the attempts log.
	 */
	private static function deleteConcreteLockoutKeysFromLog(): void {
		global $wpdb;
		$table_name = self::getTableName();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->get_results( "SELECT DISTINCT ip_address, username FROM {$table_name}", ARRAY_A );
		if ( ! is_array( $rows ) ) {
			return;
		}

		$keys = [];
		foreach ( $rows as $row ) {
			$ip = isset( $row['ip_address'] ) ? (string) $row['ip_address'] : '';
			if ( $ip === '' ) {
				continue;
			}

			$ip_scope = md5( $ip );
			$keys[]   = self::TRANSIENT_PREFIX . 'lockout_' . $ip_scope;
			$keys[]   = self::TRANSIENT_PREFIX . 'remaining_' . $ip_scope;
			$keys[]   = self::TRANSIENT_PREFIX . 'block_count_' . $ip_scope;

			$username = isset( $row['username'] ) ? (string) $row['username'] : '';
			if ( $username !== '' ) {
				$user_scope = md5( $ip . '|' . strtolower( trim( $username ) ) );
				$keys[]     = self::TRANSIENT_PREFIX . 'lockout_' . $user_scope;
				$keys[]     = self::TRANSIENT_PREFIX . 'remaining_' . $user_scope;
			}
		}

		TransientPurger::deleteKeys( $keys );
	}

	/**
	 * @param string $ip Client IP address.
	 */
	private function clearLockoutTransientsForIp( string $ip ): void {
		global $wpdb;
		$table_name = self::getTableName();

		$scope_keys = [ $this->getScopeKey( $ip, '' ) ];

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$usernames = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT DISTINCT username FROM {$table_name} WHERE ip_address = %s AND username IS NOT NULL AND username != ''",
				$ip
			)
		);

		foreach ( $usernames as $username ) {
			$scope_keys[] = $this->getScopeKey( $ip, (string) $username );
		}

		$scope_keys = array_values( array_unique( $scope_keys ) );

		foreach ( $scope_keys as $scope ) {
			delete_transient( self::TRANSIENT_PREFIX . 'lockout_' . $scope );
			delete_transient( self::TRANSIENT_PREFIX . 'remaining_' . $scope );
		}

		delete_transient( self::TRANSIENT_PREFIX . 'block_count_' . md5( $ip ) );
	}

	/**
	 * @return array{ locked: bool, expires_at: ?int }
	 */
	private function getLockoutStatus( string $ip, string $username = '' ): array {
		$settings = $this->getSettings();
		$this->ensureTableExists();

		$until = $this->getLatestActiveLockoutUntil( $ip, $username, $settings );
		if ( $until ) {
			$ts = strtotime( $until );
			if ( $ts && $ts > time() ) {
				return [ 'locked' => true, 'expires_at' => $ts ];
			}
		}

		if ( $settings['lockout_mode'] === 'ip_username' && $username === '' ) {
			$until = $this->getLatestActiveLockoutUntilForIp( $ip );
			if ( $until ) {
				$ts = strtotime( $until );
				if ( $ts && $ts > time() ) {
					return [ 'locked' => true, 'expires_at' => $ts ];
				}
			}
		}

		$scope = $this->getScopeKey( $ip, $username, $settings );
		if ( get_transient( self::TRANSIENT_PREFIX . 'lockout_' . $scope ) ) {
			$stored_until = get_transient( self::TRANSIENT_PREFIX . 'lockout_until_' . $scope );
			if ( is_numeric( $stored_until ) && (int) $stored_until > time() ) {
				return [ 'locked' => true, 'expires_at' => (int) $stored_until ];
			}

			return [ 'locked' => true, 'expires_at' => null ];
		}

		return [ 'locked' => false, 'expires_at' => null ];
	}

	private function getLatestActiveLockoutUntil( string $ip, string $username, array $settings ): ?string {
		global $wpdb;
		$table_name = $wpdb->prefix . self::TABLE_NAME;

		if ( $settings['lockout_mode'] === 'ip_username' && $username !== '' ) {
			$row = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT lockout_until FROM {$table_name} WHERE ip_address = %s AND username = %s AND lockout_until IS NOT NULL AND lockout_until > %s ORDER BY created_at DESC LIMIT 1",
					$ip,
					$username,
					current_time( 'mysql' )
				)
			);
		} else {
			$row = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT lockout_until FROM {$table_name} WHERE ip_address = %s AND lockout_until IS NOT NULL AND lockout_until > %s ORDER BY created_at DESC LIMIT 1",
					$ip,
					current_time( 'mysql' )
				)
			);
		}

		if ( $row && ! empty( $row->lockout_until ) ) {
			return $row->lockout_until;
		}

		return null;
	}

	private function getLatestActiveLockoutUntilForIp( string $ip ): ?string {
		global $wpdb;
		$table_name = $wpdb->prefix . self::TABLE_NAME;

		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT lockout_until FROM {$table_name} WHERE ip_address = %s AND lockout_until IS NOT NULL AND lockout_until > %s ORDER BY created_at DESC LIMIT 1",
				$ip,
				current_time( 'mysql' )
			)
		);

		if ( $row && ! empty( $row->lockout_until ) ) {
			return $row->lockout_until;
		}

		return null;
	}

	private function buildLockoutMessageHtml( int $lockout_unix_ts ): string {
		$seconds_left = max( 0, $lockout_unix_ts - time() );

		return '<div class="wpps-lockout-message" data-lockout-until="' . esc_attr( (string) $lockout_unix_ts ) . '">' .
			'<strong>' . esc_html__( 'Access Temporarily Blocked', 'wp-powersuite' ) . '</strong><br>' .
			esc_html__( 'Your IP address has been temporarily blocked due to multiple failed login attempts. This security measure helps protect the website from unauthorized access and brute force attacks.', 'wp-powersuite' ) . '<br>' .
			esc_html__( 'Please wait for the lockout period to expire before attempting to log in again.', 'wp-powersuite' ) . '<br>' .
			'<span class="wpps-lockout-countdown">' .
			esc_html__( 'You can try logging in again in', 'wp-powersuite' ) .
			' <span class="wpps-countdown-timer" aria-live="polite" aria-atomic="true">' . esc_html( $this->formatCountdown( $seconds_left ) ) . '</span>.</span>' .
			'</div>';
	}

	/**
	 * @param \WP_User|\WP_Error|null $user     User or error from previous filter.
	 * @param mixed                  $username Login identifier.
	 * @param mixed                  $password Password.
	 * @return \WP_User|\WP_Error|null
	 */
	public function clearTransientsOnSuccess( $user, $username, $password ) {
		unset( $password );

		if ( ! $user instanceof \WP_User ) {
			return $user;
		}

		$ip = $this->getClientIP();
		$login = is_string( $username ) ? $username : $user->user_login;
		$scope = $this->getScopeKey( $ip, $login );

		delete_transient( self::TRANSIENT_PREFIX . 'lockout_' . $scope );
		delete_transient( self::TRANSIENT_PREFIX . 'lockout_until_' . $scope );
		delete_transient( self::TRANSIENT_PREFIX . 'remaining_' . $scope );
		delete_transient( self::TRANSIENT_PREFIX . 'block_count_' . md5( $ip ) );

		return $user;
	}

	private function ensureTableExists(): void {
		global $wpdb;
		$table_name = $wpdb->prefix . self::TABLE_NAME;
		$cache_key = 'wpps_login_attempts_table_exists';

		$table_exists = wp_cache_get( $cache_key );
		if ( $table_exists === false ) {
			$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) === $table_name;
			wp_cache_set( $cache_key, $table_exists, '', 3600 );
		}

		if ( ! $table_exists ) {
			$this->maybeCreateLogTable();
		}
	}

	public function maybeCreateLogTable(): void {
		global $wpdb;

		$table_name = $wpdb->prefix . self::TABLE_NAME;
		$cache_key = 'wpps_login_attempts_table_exists';
		$table_exists = wp_cache_get( $cache_key );

		if ( $table_exists === false ) {
			$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) === $table_name;
			wp_cache_set( $cache_key, $table_exists, '', 3600 );
		}

		if ( $table_exists ) {
			$columns = $wpdb->get_col( "DESCRIBE {$table_name}" );
			if ( ! in_array( 'country_code', $columns, true ) ) {
				$wpdb->query( "ALTER TABLE {$table_name} ADD COLUMN country_code varchar(2) DEFAULT NULL AFTER lockout_until, ADD KEY country_code (country_code)" );
				wp_cache_delete( $cache_key );
			}
			$indexes = $wpdb->get_results( "SHOW INDEX FROM {$table_name}", ARRAY_A );
			$index_names = array_column( $indexes ?: [], 'Key_name' );
			if ( ! in_array( 'ip_created', $index_names, true ) ) {
				$wpdb->query( "ALTER TABLE {$table_name} ADD KEY ip_created (ip_address, created_at)" );
			}
			return;
		}

		$charset_collate = $wpdb->get_charset_collate();

		$sql = "CREATE TABLE IF NOT EXISTS {$table_name} (
			id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			ip_address varchar(45) NOT NULL,
			username varchar(255) DEFAULT NULL,
			attempts int(11) NOT NULL DEFAULT 1,
			lockout_until datetime DEFAULT NULL,
			country_code varchar(2) DEFAULT NULL,
			created_at datetime NOT NULL,
			PRIMARY KEY (id),
			KEY ip_address (ip_address),
			KEY lockout_until (lockout_until),
			KEY created_at (created_at),
			KEY country_code (country_code),
			KEY ip_created (ip_address, created_at)
		) {$charset_collate};";

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

		$columns = $wpdb->get_col( "DESCRIBE {$table_name}" );
		if ( ! in_array( 'country_code', $columns, true ) ) {
			$wpdb->query( "ALTER TABLE {$table_name} ADD COLUMN country_code varchar(2) DEFAULT NULL AFTER lockout_until, ADD KEY country_code (country_code)" );
		}

		wp_cache_set( $cache_key, true, '', 3600 );
	}

	public function maybeSchedulePruneCron(): void {
		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_event( time(), 'daily', self::CRON_HOOK );
		}
		$this->maybePruneOldLogs();
	}

	private function shouldIncrementFailedAttempt(): bool {
		if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
			return true;
		}

		if ( $this->isCredentialAjaxRequest() ) {
			return true;
		}

		if ( strtoupper( (string) ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) !== 'POST' ) {
			return (bool) apply_filters( 'wp_powersuite_limit_login_count_non_post_failure', false );
		}

		if ( isset( $_POST['log'], $_POST['pwd'] ) ) {
			return true;
		}

		if ( isset( $_POST['woocommerce-login-nonce'], $_POST['username'], $_POST['password'] ) ) {
			return true;
		}

		return (bool) apply_filters( 'wp_powersuite_limit_login_count_failed_login_body', false );
	}

	public function trackFailedLogin( string $username, $error = null ): void {
		if ( $error instanceof \WP_Error && $error->get_error_code() === 'wpps_2fa_required' ) {
			return;
		}

		if ( ! $this->shouldIncrementFailedAttempt() ) {
			return;
		}

		$settings = $this->getSettings();
		$ip = $this->getClientIP();

		if ( $this->isIPExcluded( $ip, $settings ) ) {
			return;
		}

		$scope = $this->getScopeKey( $ip, $username, $settings );
		$lockout_key = self::TRANSIENT_PREFIX . 'lockout_' . $scope;
		if ( get_transient( $lockout_key ) ) {
			return;
		}

		$this->ensureTableExists();

		global $wpdb;
		$table_name = $wpdb->prefix . self::TABLE_NAME;
		$now = current_time( 'mysql' );

		$inserted = $wpdb->insert(
			$table_name,
			[
				'ip_address'    => $ip,
				'username'      => $username,
				'attempts'      => 1,
				'lockout_until' => null,
				'country_code'  => null,
				'created_at'    => $now,
			],
			[ '%s', '%s', '%d', '%s', '%s', '%s' ]
		);

		if ( ! $inserted ) {
			return;
		}

		if ( $wpdb->insert_id ) {
			$this->maybeBackfillCountryCode( (int) $wpdb->insert_id, $ip );
		}

		$attempts = $this->countRecentAttempts( $ip, $username, $settings );
		$max_attempts = absint( $settings['max_attempts'] );
		$escalation_threshold = absint( $settings['escalation_threshold'] );
		$lockout_until = null;

		$remaining_key = self::TRANSIENT_PREFIX . 'remaining_' . $scope;
		$base_lockout_minutes = (int) $settings['lockout_minutes'];

		if ( $attempts >= $max_attempts ) {
			$block_count_key = self::TRANSIENT_PREFIX . 'block_count_' . md5( $ip );
			$block_count = $this->incrementBlockCount( $block_count_key );

			$lockout_minutes = $base_lockout_minutes;
			if ( $block_count > $escalation_threshold ) {
				$lockout_minutes = (int) $settings['lockout_minutes_escalated'];
			}

			$lockout_seconds = $lockout_minutes * MINUTE_IN_SECONDS;
			$lockout_until_ts = current_time( 'timestamp' ) + $lockout_seconds;
			$lockout_until = wp_date( 'Y-m-d H:i:s', $lockout_until_ts );

			set_transient( $lockout_key, true, $lockout_seconds );
			set_transient( self::TRANSIENT_PREFIX . 'lockout_until_' . $scope, $lockout_until_ts, $lockout_seconds );
			delete_transient( $remaining_key );

			$wpdb->update(
				$table_name,
				[
					'attempts'      => $max_attempts,
					'lockout_until' => $lockout_until,
				],
				[ 'id' => (int) $wpdb->insert_id ],
				[ '%d', '%s' ],
				[ '%d' ]
			);
		} else {
			$remaining_attempts = $max_attempts - $attempts;
			$window_seconds = (int) $settings['attempt_window_minutes'] * MINUTE_IN_SECONDS;
			set_transient( $remaining_key, $remaining_attempts, $window_seconds );

			$wpdb->update(
				$table_name,
				[ 'attempts' => $attempts ],
				[ 'id' => (int) $wpdb->insert_id ],
				[ '%d' ],
				[ '%d' ]
			);
		}

		$this->maybePruneOldLogs();
	}

	/**
	 * Count failed attempts in the observation window (DB source of truth).
	 */
	private function countRecentAttempts( string $ip, string $username, array $settings ): int {
		global $wpdb;
		$table_name = $wpdb->prefix . self::TABLE_NAME;
		$window_minutes = (int) $settings['attempt_window_minutes'];
		$cutoff = wp_date( 'Y-m-d H:i:s', current_time( 'timestamp' ) - ( $window_minutes * MINUTE_IN_SECONDS ) );

		if ( $settings['lockout_mode'] === 'ip_username' && $username !== '' ) {
			return (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM {$table_name} WHERE ip_address = %s AND username = %s AND created_at >= %s",
					$ip,
					$username,
					$cutoff
				)
			);
		}

		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$table_name} WHERE ip_address = %s AND created_at >= %s",
				$ip,
				$cutoff
			)
		);
	}

	/**
	 * Block count increment via the transient API only.
	 *
	 * get_transient()/set_transient() already read/write the object cache
	 * directly when an external cache is active, so maintaining a second,
	 * separately-keyed wp_cache group here duplicated storage and could drift
	 * out of sync with the real transient value.
	 */
	private function incrementBlockCount( string $key ): int {
		$count = absint( get_transient( $key ) ) + 1;
		set_transient( $key, $count, DAY_IN_SECONDS );
		return $count;
	}

	public function maybePruneOldLogs(): void {
		$settings = $this->getSettings();
		$days = max( self::MIN_LOG_RETENTION_DAYS, (int) ( $settings['log_retention_days'] ?? self::DEFAULT_LOG_RETENTION_DAYS ) );

		$lock_key = 'wpps_login_attempts_prune_lock';
		if ( get_transient( $lock_key ) ) {
			return;
		}

		set_transient( $lock_key, 1, 12 * HOUR_IN_SECONDS );
		$this->ensureTableExists();

		global $wpdb;
		$table_name = $wpdb->prefix . self::TABLE_NAME;
		$cutoff = wp_date( 'Y-m-d H:i:s', current_time( 'timestamp' ) - ( $days * DAY_IN_SECONDS ) );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$table_name} WHERE created_at < %s", $cutoff ) );
	}

	private function maybeBackfillCountryCode( int $log_id, string $ip ): void {
		if ( $log_id <= 0 || ! $this->getSettings()['geo_lookup_enabled'] ) {
			return;
		}

		add_action(
			'shutdown',
			function () use ( $log_id, $ip ): void {
				$country_code = $this->getCountryCode( $ip );
				if ( $country_code === null ) {
					return;
				}

				global $wpdb;
				$table_name = $wpdb->prefix . self::TABLE_NAME;
				$wpdb->update(
					$table_name,
					[ 'country_code' => $country_code ],
					[ 'id' => $log_id ],
					[ '%s' ],
					[ '%d' ]
				);
			},
			20
		);
	}

	/**
	 * @param mixed $user
	 * @param mixed $username
	 * @param mixed $password
	 * @return mixed
	 */
	public function checkLoginAttempts( $user, $username, $password ) {
		$settings = $this->getSettings();
		$ip = $this->getClientIP();
		$login_id = is_string( $username ) ? $username : '';

		if ( $this->isIPExcluded( $ip, $settings ) ) {
			return $user;
		}

		$status = $this->getLockoutStatus( $ip, $login_id );
		if ( $status['locked'] ) {
			if ( $status['expires_at'] ) {
				return new \WP_Error( 'too_many_attempts', $this->buildLockoutMessageHtml( $status['expires_at'] ) );
			}

			return new \WP_Error(
				'too_many_attempts',
				'<div class="wpps-lockout-message">' . esc_html__( 'Access temporarily blocked. Please try again later.', 'wp-powersuite' ) . '</div>'
			);
		}

		if ( empty( $username ) || empty( $password ) || ! is_string( $username ) || ! is_string( $password ) ) {
			return $user;
		}

		return $user;
	}

	public function showRemainingAttempts( $error ): string {
		$error = is_string( $error ) ? $error : '';

		$action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : '';
		if ( $action === 'wpps_2fa' ) {
			return $error;
		}

		$settings = $this->getSettings();
		$ip = $this->getClientIP();
		$login_id = isset( $_POST['log'] ) ? sanitize_text_field( wp_unslash( $_POST['log'] ) ) : '';

		if ( $this->isIPExcluded( $ip, $settings ) ) {
			return $error;
		}

		$status = $this->getLockoutStatus( $ip, $login_id );
		if ( $status['locked'] ) {
			if ( ! empty( $error ) && ( strpos( $error, 'wpps-lockout-message' ) !== false || strpos( $error, 'Access Temporarily Blocked' ) !== false ) ) {
				return $error;
			}
			if ( $status['expires_at'] ) {
				return $this->buildLockoutMessageHtml( $status['expires_at'] );
			}

			return $error;
		}

		if ( ! empty( $settings['generic_error_messages'] ) ) {
			return $error;
		}

		$scope = $this->getScopeKey( $ip, $login_id, $settings );
		$remaining_attempts = get_transient( self::TRANSIENT_PREFIX . 'remaining_' . $scope );

		if ( $remaining_attempts !== false && $remaining_attempts > 0 ) {
			$lockout_minutes = (int) $settings['lockout_minutes'];
			$message = sprintf(
				/* translators: 1: remaining attempts, 2: lockout duration in minutes */
				__( 'Invalid username or password. You have %1$d attempt(s) remaining before your IP address will be temporarily blocked for %2$d minutes.', 'wp-powersuite' ),
				$remaining_attempts,
				$lockout_minutes
			);

			return '<strong>' . esc_html( $message ) . '</strong><br>' . $error;
		}

		return $error;
	}

	public function renderLockoutAssets(): void {
		$css_url = $this->getLockoutStylesheetUrl();
		if ( $css_url ) {
			wp_enqueue_style( 'wpps-limit-login-lockout', $css_url, [], self::MODULE_VERSION );
		}

		$this->outputLockoutAssets();
	}

	public function maybeEnqueueFrontendLockoutAssets(): void {
		if ( is_admin() ) {
			return;
		}

		$settings = $this->getSettings();
		$ip = $this->getClientIP();

		if ( $this->isIPExcluded( $ip, $settings ) ) {
			return;
		}

		$status = $this->getLockoutStatus( $ip );
		if ( ! $status['locked'] || ! $status['expires_at'] ) {
			return;
		}

		$css_url = $this->getLockoutStylesheetUrl();
		if ( $css_url ) {
			wp_enqueue_style( 'wpps-limit-login-lockout', $css_url, [], self::MODULE_VERSION );
		}

		$this->outputLockoutAssets();
	}

	private function getLockoutStylesheetUrl(): ?string {
		if ( defined( 'WP_POWERSUITE_PLUGIN_URL' ) ) {
			return WP_POWERSUITE_PLUGIN_URL . 'modules/limit-login-attempts/assets/css/lockout.css';
		}

		$plugin_file = dirname( dirname( __DIR__ ) ) . '/wp-powersuite.php';
		if ( file_exists( $plugin_file ) ) {
			return plugins_url( 'modules/limit-login-attempts/assets/css/lockout.css', $plugin_file );
		}

		return null;
	}

	private function outputLockoutAssets(): void {
		$settings = $this->getSettings();
		$ip = $this->getClientIP();

		if ( $this->isIPExcluded( $ip, $settings ) ) {
			return;
		}

		$status = $this->getLockoutStatus( $ip );
		if ( ! $status['locked'] || ! $status['expires_at'] ) {
			return;
		}

		$lockout_timestamp = $status['expires_at'];
		static $rendered = false;
		if ( $rendered ) {
			return;
		}
		$rendered = true;

		?>
		<script type="text/javascript">
		(function() {
			var lockoutUntil = <?php echo absint( $lockout_timestamp ); ?>;
			var countdownElement = document.querySelector('.wpps-countdown-timer');

			if (!countdownElement) {
				return;
			}

			function updateCountdown() {
				var now = Math.floor(Date.now() / 1000);
				var secondsLeft = lockoutUntil - now;

				if (secondsLeft <= 0) {
					countdownElement.textContent = '<?php echo esc_js( __( 'less than a minute', 'wp-powersuite' ) ); ?>';
					setTimeout(function() {
						window.location.reload();
					}, 2000);
					return;
				}

				var minutes = Math.floor(secondsLeft / 60);
				var seconds = secondsLeft % 60;
				var timeString = '';

				if (minutes > 0) {
					timeString = minutes + ' ' + (minutes === 1 ? '<?php echo esc_js( __( 'minute', 'wp-powersuite' ) ); ?>' : '<?php echo esc_js( __( 'minutes', 'wp-powersuite' ) ); ?>');
					if (seconds > 0) {
						timeString += ' ' + seconds + ' ' + (seconds === 1 ? '<?php echo esc_js( __( 'second', 'wp-powersuite' ) ); ?>' : '<?php echo esc_js( __( 'seconds', 'wp-powersuite' ) ); ?>');
					}
				} else {
					timeString = seconds + ' ' + (seconds === 1 ? '<?php echo esc_js( __( 'second', 'wp-powersuite' ) ); ?>' : '<?php echo esc_js( __( 'seconds', 'wp-powersuite' ) ); ?>');
				}

				countdownElement.textContent = timeString;
			}

			updateCountdown();
			setInterval(updateCountdown, 1000);

			document.addEventListener('DOMContentLoaded', function() {
				document.body.classList.add('wpps-login-locked');
				var forms = document.querySelectorAll('#loginform, form.woocommerce-form-login');
				forms.forEach(function(form) {
					form.querySelectorAll('input, button, select, textarea').forEach(function(el) {
						if (el.type === 'hidden') {
							return;
						}
						el.disabled = true;
						el.setAttribute('aria-disabled', 'true');
					});
				});
			});
		})();
		</script>
		<?php
	}

	public function abortXmlRpcIfLocked(): void {
		if ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
			return;
		}

		$settings = $this->getSettings();
		$ip = $this->getClientIP();
		$status = $this->getLockoutStatus( $ip );
		if ( $this->isIPExcluded( $ip, $settings ) || ! $status['locked'] ) {
			return;
		}

		status_header( 429 );
		header( 'Content-Type: text/xml; charset=UTF-8' );
		$msg = __( 'Too many failed login attempts.', 'wp-powersuite' );
		echo '<?xml version="1.0" encoding="UTF-8"?>';
		echo '<methodResponse><fault><value><struct>';
		echo '<member><name>faultCode</name><value><int>429</int></value></member>';
		echo '<member><name>faultString</name><value><string>' . esc_html( $msg ) . '</string></value></member>';
		echo '</struct></value></fault></methodResponse>';
		exit;
	}

	/**
	 * @param mixed $result Existing error or null.
	 * @return mixed
	 */
	public function blockRestIfLocked( $result ) {
		if ( null !== $result ) {
			return $result;
		}

		if ( empty( $_SERVER['HTTP_AUTHORIZATION'] ) && empty( $_SERVER['PHP_AUTH_USER'] ) ) {
			return $result;
		}

		$settings = $this->getSettings();
		$ip = $this->getClientIP();
		$status = $this->getLockoutStatus( $ip );
		if ( $this->isIPExcluded( $ip, $settings ) || ! $status['locked'] ) {
			return $result;
		}

		if ( $status['expires_at'] ) {
			$minutes = max( 1, (int) ceil( ( $status['expires_at'] - time() ) / MINUTE_IN_SECONDS ) );
			return new \WP_Error(
				'too_many_attempts',
				sprintf(
					/* translators: %d: minutes until retry */
					__( 'Too many failed login attempts. Try again in %d minutes.', 'wp-powersuite' ),
					$minutes
				),
				[ 'status' => 429 ]
			);
		}

		return new \WP_Error(
			'too_many_attempts',
			__( 'Too many failed login attempts. Please try again later.', 'wp-powersuite' ),
			[ 'status' => 429 ]
		);
	}

	/**
	 * @param \WP_User|\WP_Error $user     Authenticated user or error.
	 * @param string             $password Cleartext password (unused).
	 * @return \WP_User|\WP_Error
	 */
	public function enforceLockoutAfterPasswordOk( $user, $password ) {
		unset( $password );

		if ( is_wp_error( $user ) || ! ( $user instanceof \WP_User ) ) {
			return $user;
		}

		$settings = $this->getSettings();
		$ip = $this->getClientIP();
		if ( $this->isIPExcluded( $ip, $settings ) ) {
			return $user;
		}

		$status = $this->getLockoutStatus( $ip, $user->user_login );
		if ( ! $status['locked'] ) {
			return $user;
		}

		if ( $status['expires_at'] ) {
			return new \WP_Error( 'too_many_attempts', $this->buildLockoutMessageHtml( $status['expires_at'] ) );
		}

		return new \WP_Error(
			'too_many_attempts',
			'<div class="wpps-lockout-message">' . esc_html__( 'Access temporarily blocked. Please try again later.', 'wp-powersuite' ) . '</div>'
		);
	}

	/**
	 * @param mixed          $error Error detail from core.
	 * @param \WP_User|null $user  User object when known.
	 */
	public function trackAppPasswordFailure( $error, $user = null ): void {
		unset( $error );

		if ( $user instanceof \WP_User ) {
			$this->trackFailedLogin( $user->user_login );
			return;
		}

		$this->trackFailedLogin( 'unknown' );
	}

	private function formatCountdown( int $seconds ): string {
		$minutes = floor( $seconds / 60 );
		$remaining_seconds = $seconds % 60;

		if ( $minutes > 0 ) {
			if ( $remaining_seconds > 0 ) {
				return sprintf(
					/* translators: %1$d: minutes, %2$d: seconds */
					_n( '%1$d minute %2$d seconds', '%1$d minutes %2$d seconds', $minutes, 'wp-powersuite' ),
					$minutes,
					$remaining_seconds
				);
			}
			return sprintf(
				/* translators: %d: minutes */
				_n( '%d minute', '%d minutes', $minutes, 'wp-powersuite' ),
				$minutes
			);
		}

		return sprintf(
			/* translators: %d: seconds */
			_n( '%d second', '%d seconds', $remaining_seconds, 'wp-powersuite' ),
			$remaining_seconds
		);
	}

	private function getScopeKey( string $ip, string $username = '', ?array $settings = null ): string {
		$settings ??= $this->getSettings();
		if ( $settings['lockout_mode'] === 'ip_username' && $username !== '' ) {
			return md5( $ip . '|' . $this->normalizeUsername( $username ) );
		}

		return md5( $ip );
	}

	private function normalizeUsername( string $username ): string {
		return strtolower( trim( $username ) );
	}

	private function isIPExcluded( string $ip, array $settings ): bool {
		$never_block = $settings['never_block_ips'] ?? [];
		if ( empty( $never_block ) || ! is_array( $never_block ) ) {
			return false;
		}

		return in_array( $ip, $never_block, true );
	}

	private function getClientIP(): string {
		$settings = $this->getSettings();
		$custom_header = isset( $settings['ip_header'] ) && is_string( $settings['ip_header'] ) ? trim( $settings['ip_header'] ) : '';

		if ( $custom_header !== '' ) {
			$header_key = strtoupper( str_replace( '-', '_', $custom_header ) );
			if ( isset( $_SERVER[ $header_key ] ) ) {
				$ip = sanitize_text_field( wp_unslash( $_SERVER[ $header_key ] ) );
				if ( strpos( $ip, ',' ) !== false ) {
					$parts = explode( ',', $ip );
					$ip = trim( $parts[0] );
				}
				if ( $ip !== '' && filter_var( $ip, FILTER_VALIDATE_IP ) ) {
					return $ip;
				}
			}
		}

		if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
			$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
			if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
				return $ip;
			}
		}

		if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
			$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
			if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
				return $ip;
			}
		}

		return '0.0.0.0';
	}

	private function isLicenseActive(): bool {
		return function_exists( 'wp_powersuite_is_license_valid' ) && wp_powersuite_is_license_valid();
	}

	private function getSettings(): array {
		if ( $this->settings_cache !== null ) {
			return $this->settings_cache;
		}

		$settings = $this->moduleSettings()->all( [] );
		if ( ! is_array( $settings ) ) {
			$settings = [];
		}

		$lockout_minutes = $this->normalizeLockoutMinutes( $settings['lockout_minutes'] ?? self::DEFAULT_LOCKOUT_MINUTES );
		$lockout_escalated = $this->normalizeLockoutMinutes( $settings['lockout_minutes_escalated'] ?? self::DEFAULT_ESCALATED_LOCKOUT_MINUTES );
		if ( $lockout_escalated < $lockout_minutes ) {
			$lockout_escalated = $lockout_minutes;
		}

		$attempt_window = max( self::MIN_LOCKOUT_MINUTES, min( self::MAX_LOCKOUT_MINUTES, absint( $settings['attempt_window_minutes'] ?? self::DEFAULT_ATTEMPT_WINDOW_MINUTES ) ) );
		$lockout_mode = isset( $settings['lockout_mode'] ) && $settings['lockout_mode'] === 'ip_username' ? 'ip_username' : 'ip';

		$this->settings_cache = [
			'max_attempts'              => max( 1, min( 20, absint( $settings['max_attempts'] ?? 3 ) ) ),
			'escalation_threshold'      => max( 1, min( 10, absint( $settings['escalation_threshold'] ?? 2 ) ) ),
			'lockout_minutes'           => $lockout_minutes,
			'lockout_minutes_escalated' => $lockout_escalated,
			'attempt_window_minutes'    => $attempt_window,
			'lockout_mode'              => $lockout_mode,
			'log_retention_days'        => max( self::MIN_LOG_RETENTION_DAYS, min( 365, absint( $settings['log_retention_days'] ?? self::DEFAULT_LOG_RETENTION_DAYS ) ) ),
			'never_block_ips'           => self::normalizeAllowlistIps( $settings['never_block_ips'] ?? [] ),
			'ip_header'                 => self::normalizeIpHeader( $settings['ip_header'] ?? '' ),
			'geo_lookup_enabled'        => ! empty( $settings['geo_lookup_enabled'] ),
			'generic_error_messages'    => ! empty( $settings['generic_error_messages'] ),
		];

		return $this->settings_cache;
	}

	public static function getTableName(): string {
		global $wpdb;
		return $wpdb->prefix . self::TABLE_NAME;
	}

	/**
	 * @param mixed $raw Lines of IPs from settings storage.
	 * @return list<string>
	 */
	public static function normalizeAllowlistIps( $raw ): array {
		if ( is_array( $raw ) ) {
			$lines = $raw;
		} else {
			$lines = preg_split( '/\r\n|\r|\n/', (string) $raw );
			$lines = is_array( $lines ) ? $lines : [];
		}

		$valid = [];
		foreach ( $lines as $line ) {
			$line = trim( (string) $line );
			if ( $line === '' ) {
				continue;
			}
			if ( filter_var( $line, FILTER_VALIDATE_IP ) ) {
				$valid[] = $line;
			}
		}

		return array_values( array_unique( $valid ) );
	}

	/**
	 * @param mixed $raw $_SERVER-style header name.
	 */
	public static function normalizeIpHeader( $raw ): string {
		$header = strtoupper( str_replace( '-', '_', trim( (string) $raw ) ) );
		if ( $header === '' ) {
			return '';
		}
		if ( ! preg_match( '/^[A-Z][A-Z0-9_]*$/', $header ) ) {
			return '';
		}
		if ( in_array( $header, self::BLOCKED_IP_HEADERS, true ) ) {
			return '';
		}

		return $header;
	}

	private function normalizeLockoutMinutes( $minutes ): int {
		return max( self::MIN_LOCKOUT_MINUTES, min( self::MAX_LOCKOUT_MINUTES, absint( $minutes ) ) );
	}

	private function getCountryCode( string $ip_address ): ?string {
		if ( empty( $ip_address ) || $ip_address === '0.0.0.0' ) {
			return null;
		}

		if ( ! filter_var( $ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
			return null;
		}

		$cache_key = 'wpps_ip_geo_' . md5( $ip_address );
		$cached = get_transient( $cache_key );
		if ( $cached !== false && is_string( $cached ) ) {
			return $cached !== '' ? $cached : null;
		}

		if ( ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
			return null;
		}

		$api_url = 'https://ipwho.is/' . rawurlencode( $ip_address );

		$response = wp_remote_get(
			$api_url,
			[
				'timeout'     => 2,
				'httpversion' => '1.1',
				'user-agent'  => 'WP PowerSuite Limit Login Attempts',
			]
		);

		if ( is_wp_error( $response ) ) {
			return null;
		}

		$body = wp_remote_retrieve_body( $response );
		if ( empty( $body ) ) {
			return null;
		}

		$json = json_decode( $body, true );
		if ( ! is_array( $json ) || empty( $json['success'] ) ) {
			set_transient( $cache_key, '', HOUR_IN_SECONDS );
			return null;
		}

		$country_code_raw = $json['country_code'] ?? '';
		$country_code = is_string( $country_code_raw ) ? strtolower( $country_code_raw ) : '';

		set_transient( $cache_key, $country_code, WEEK_IN_SECONDS );

		return $country_code !== '' ? $country_code : null;
	}

	public function getCountryName( string $country_code ): ?string {
		$country_code = strtolower( $country_code );
		$countries = $this->getCountryList();
		return $countries[ $country_code ] ?? null;
	}

	public function getCountryFlag( string $country_code ): ?string {
		$country_code = strtoupper( $country_code );
		if ( strlen( $country_code ) !== 2 ) {
			return null;
		}

		$flag = '';
		for ( $i = 0; $i < 2; $i++ ) {
			$char = $country_code[ $i ];
			$flag .= mb_chr( ord( $char ) + 127397, 'UTF-8' );
		}

		return $flag;
	}

	private function getCountryList(): array {
		static $list = null;
		if ( null === $list ) {
			$list = require __DIR__ . '/data/country-names.php';
		}
		return $list;
	}
}
