<?php

namespace LLAR\Core;

use Exception;
use IXR_Error;
use LLAR\Core\Digest\DigestDispatcher;
use LLAR\Core\Digest\DigestRetriesController;
use LLAR\Core\Digest\DigestScheduler;
use LLAR\Core\Digest\DigestStorage;
use LLAR\Core\Digest\DigestUiController;
use LLAR\Core\Http\Http;
use LLAR\Core\Integrations\BaseIntegration;
use LLAR\Core\Integrations\IntegrationManager;
use LLAR\Core\MfaFlow\MfaRestApi;
use WP_Error;
use WP_User;

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

class LimitLoginAttempts
{

	/**
	 * Admin options page slug
	 * @var string
	 */
	private $_options_page_slug = 'limit-login-attempts';

	/**
	 * Errors messages
	 *
	 * @var array
	 */
	public $_errors = array();

	public $all_errors_array = array();

	/**
	 * custom error
	 * @var string
	 */
	public $custom_error = '';

	/**
	 * User blocking
	 * @var boolean
	 */
	public $user_blocking = false;
	public $user_empty = false;

	/**
	 * Registration error messages
	 * @var string
	 */
	public $error_messages = '';

	/**
	 * Additional login errors messages that we need to show
	 *
	 * @var array
	 */
	public $other_login_errors = array();

	/**
	 * Current app object
	 *
	 * @var CloudApp
	 */
	public static $cloud_app = null;

	/**
	 * Integration manager for third-party plugins
	 *
	 * @var IntegrationManager
	 */
	private $integration_manager = null;

	private $info_data = array();

	/**
	 * MFA manager instance (MfaManager: MfaBackupCodes, MfaEndpoint, MfaSettings, MfaValidator).
	 *
	 * @var \LLAR\Core\Mfa\MfaManager
	 */
	private $mfa_controller = null;

	/**
	 * Admin notices controller (renders notice views for options page).
	 *
	 * @var \LLAR\Core\AdminNoticesController
	 */
	private $admin_notices_controller = null;
	private $auth_acl_response_cache = array();
	private $auth_acl_response_cache_max_size = 50;

	/**
	 * Request-scoped cache: hook callback -> reflection file path (avoids repeated Reflection API).
	 *
	 * @var array
	 */
	private static $hook_callback_source_file_cache = array();

	/**
	 * Request-scoped cache: normalized source file path -> plugin metadata (avoids repeated get_plugins scans).
	 *
	 * @var array
	 */
	private static $hook_source_file_plugin_cache = array();

	/**
	 * Pending flash message to display on options page (e.g. "Settings saved").
	 * Rendered via AdminNoticesController when options-page is loaded.
	 *
	 * @var array|null Keys: 'msg', 'is_error'. Null when none.
	 */
	public $pending_admin_message = null;

	/**
	 * Class instance accessible in other classes
	 *
	 * @var LimitLoginAttempts
	 */
	public static $instance;

	/**
	 * Capabilities to work with a plugin
	 *
	 * @var string
	 */
	public static $capabilities = 'llar_admin';
	public $has_capability = false;

	/**
	 * Guard: handshake already attempted this request (avoids double handshake from wp_authenticate_user + limit_login_failed).
	 *
	 * @var bool
	 */
	private static $mfa_flow_handshake_attempted = false;

	/**
	 * Guard: failed login already recorded in this request.
	 * Reset on each `init` for persistent runtimes (Swoole/FrankenPHP).
	 *
	 * @var bool
	 */
	private static $failed_login_recorded_in_request = false;

	/**
	 * Priority for the late authenticate safety net.
	 *
	 * @temporary WP 7.0 compat — remove after WP 7.1 release or when auth flow is stable.
	 */
	const LATE_AUTH_PRIORITY = 99990;

	/**
	 * @temporary WP 7.0 compat — single source of truth for auth failure WP_Error codes.
	 * TODO: Remove after WP 7.1 release or when auth flow is stable.
	 *
	 * @var array
	 */
	private static $auth_failure_codes = array( 'invalid_username', 'invalid_email', 'incorrect_password', 'authentication_failed' );

	/**
	 * Cached results of WP version checks.
	 *
	 * @var array
	 */
	private static $wp_version_cache = array();

	/**
	 * Check whether the current WordPress version is at least $version. Result is cached.
	 *
	 * @param string $version Minimum version to compare against (e.g. '6.9', '7.0').
	 * @return bool
	 */
	private static function is_wp_at_least( $version ) {
		if ( ! isset( self::$wp_version_cache[ $version ] ) ) {
			$current = preg_replace( '/[^0-9.].*/', '', Helpers::get_wordpress_version() );
			self::$wp_version_cache[ $version ] = version_compare( $current, $version, '>=' );
		}
		return self::$wp_version_cache[ $version ];
	}

	/**
	 * Reset per-request static guards for persistent PHP runtimes (Swoole, FrankenPHP).
	 * $wp_version_cache is intentionally NOT reset — WP version does not change between requests.
	 */
	public static function reset_request_guards() {
		self::$failed_login_recorded_in_request = false;
	}

	/**
	 * Allowed tabs for options page
	 */
	public static $allowed_tabs = array( 'logs-local', 'logs-custom', 'settings', 'mfa', 'debug', 'premium', 'help' );

	/**
	 * Check if a role is an admin role
	 *
	 * @param string $role_key Role key (e.g., 'administrator')
	 * @param string $role_name Role display name (e.g., 'Administrator') - optional, deprecated, not used
	 * @return bool True if role is admin-related
	 */
	public static function is_admin_role( $role_key, $role_name = '' ) {
		// Validate input
		if ( ! is_string( $role_key ) || empty( $role_key ) ) {
			return false;
		}

		// Primary check: exact match for administrator role
		if ( 'administrator' === $role_key ) {
			return true;
		}

		// Secondary check: verify role has admin capabilities (most reliable method)
		$role = get_role( $role_key );
		if ( $role && $role->has_cap( 'manage_options' ) ) {
			return true;
		}

		// Fallback: check if role key is exactly 'admin' (common custom admin role name)
		// Note: We don't check $role_name to avoid false positives (e.g., 'admin_peter' user name)
		if ( 'admin' === strtolower( $role_key ) ) {
			return true;
		}

		return false;
	}

	private $plans = array(
		'default'       => array(
			'name'          => 'Free',
			'rate'          => 10,
		),
		'free'          => array(
			'name'          => 'Micro Cloud',
			'rate'          => 20,
		),
		'premium'       => array(
			'name'          => 'Premium',
			'rate'          => 30,
		),
		'plus'          => array(
			'name'          => 'Premium +',
			'rate'          => 40,
		),
		'pro'           => array(
			'name'          => 'Professional',
			'rate'          => 50,
		),
		'agency_pro'    => array(
			'name'          => 'Agency',
			'rate'          => 60,
		),
	);

	public function __construct()
	{
		self::$instance = $this;

		Config::init();
		Http::init();

		// Initialize integrations manager
		$this->integration_manager = new IntegrationManager( $this );

		$this->hooks_init();
		$this->setup();
		$this->cloud_app_init();

		// Initialize MFA (dependency injection: MfaBackupCodes, MfaEndpoint, MfaSettings)
		$payload_storage = \LLAR\Core\Mfa\RescuePayloadStorage\RescuePayloadStorageSelector::get_storage();
		$mfa_backup_codes = new \LLAR\Core\Mfa\MfaBackupCodes( $payload_storage );
		$mfa_endpoint     = new \LLAR\Core\Mfa\MfaEndpoint( $mfa_backup_codes, $payload_storage );
		$mfa_settings    = new \LLAR\Core\Mfa\MfaSettings();
		$this->mfa_controller = new \LLAR\Core\Mfa\MfaManager( $mfa_backup_codes, $mfa_endpoint, $mfa_settings, $payload_storage );
		$this->mfa_controller->register();

		( new Shortcodes() )->register();
		( new Ajax() )->register();
	}

	/**
	 * Register wp hooks and filters
	 */
	public function hooks_init()
	{
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ,999);
		add_action( 'login_enqueue_scripts', array( $this, 'login_page_enqueue' ) );
		add_filter( 'limit_login_whitelist_ip', array( $this, 'check_whitelist_ips' ), 10, 2 );
		add_filter( 'limit_login_whitelist_usernames', array( $this, 'check_whitelist_usernames' ), 10, 2 );
		add_filter( 'limit_login_blacklist_ip', array( $this, 'check_blacklist_ips' ), 10, 2 );
		add_filter( 'limit_login_blacklist_usernames', array( $this, 'check_blacklist_usernames' ), 10, 2 );

		add_filter( 'illegal_user_logins', array( $this, 'register_user_blacklist' ), 999 );
		add_filter( 'um_custom_authenticate_error_codes', array( $this, 'ultimate_member_register_error_codes' ) );

		// TODO: Temporary turn off the holiday warning.
		//add_action( 'admin_notices', array( $this, 'show_enable_notify_notice' ) );

		add_action( 'admin_notices', array( $this, 'show_leave_review_notice' ) );

		add_action( 'admin_print_scripts-toplevel_page_limit-login-attempts', array( $this, 'load_admin_scripts' ) );
		add_action( 'admin_print_scripts-settings_page_limit-login-attempts', array( $this, 'load_admin_scripts' ) );
		add_action( 'admin_print_scripts-index.php', array( $this, 'load_admin_scripts' ) );

		add_action( 'admin_init', array( $this, 'dashboard_page_redirect' ), 9999 );
		add_action( 'admin_init', array( $this, 'onboarding_redirect_to_dashboard' ), 5 );
		add_action( 'admin_init', array( $this, 'setup_cookie' ), 10 );

		add_action( 'login_footer', array( $this, 'login_page_gdpr_message' ) );

		add_action( 'login_footer', array( $this, 'login_page_render_js' ), 9999 );
		add_action( 'wp_footer', array( $this, 'login_page_render_js' ), 9999 );

		if( !Config::get( 'hide_dashboard_widget' ) )
			add_action( 'wp_dashboard_setup', array( $this, 'register_dashboard_widgets' ) );

		add_action( 'login_form_register', array( $this, 'llar_submit_login_form_register' ), 10 );
		add_filter( 'registration_errors', array( $this, 'llar_submit_registration_errors' ), 10, 3 );

		register_activation_hook( LLA_PLUGIN_FILE, array( $this, 'activation' ) );

		add_action( 'upgrader_process_complete', array( $this, 'after_plugin_update' ), 10, 2 );
	}

	/**
	 * Runs when the plugin is activated
	 */
	public function activation()
	{
		Helpers::persist_stored_plugin_version();

		if ( ! Config::exists( 'activation_timestamp' ) ) {
			set_transient( 'llar_dashboard_redirect', true, 30 );
		}

		Config::apply_digest_defaults_on_fresh_activation();
	}

	/**
	 * After this plugin is updated from wp-admin, persist the new file version.
	 *
	 * @param \WP_Upgrader $upgrader Upgrader instance (unused).
	 * @param array        $options  Context: action, type, plugins, etc.
	 * @return void
	 */
	public function after_plugin_update( $upgrader, $options ) {
		if ( ! isset( $options['type'], $options['action'] ) || 'update' !== $options['action'] || 'plugin' !== $options['type'] ) {
			return;
		}
		if ( empty( $options['plugins'] ) || ! is_array( $options['plugins'] ) ) {
			return;
		}
		if ( ! in_array( LLA_PLUGIN_BASENAME, $options['plugins'], true ) ) {
			return;
		}

		$old_version = (string) Config::get( 'plugin_version' );
		Helpers::persist_stored_plugin_version();
		$new_version = (string) Config::get( 'plugin_version' );

		if ( $old_version !== $new_version ) {
			if ( '' !== $old_version ) {
				Config::ensure_digest_defaults_for_existing_site();
			}

			/**
			 * Fires after LLAR plugin version is persisted post-update.
			 *
			 * @param string $old_version Previously stored version (may be empty).
			 * @param string $new_version Newly stored version.
			 */
			do_action( 'llar_plugin_version_updated', $old_version, $new_version );
		}
	}


	public function setup_cookie()
	{
		if ( empty( $_GET['page'] ) || $_GET['page'] !== $this->_options_page_slug ) {

			return;
		}

		$cookie_name = 'llar_menu_alert_icon_shown';

		if ( empty( $_COOKIE[$cookie_name] ) ) {
			setcookie( $cookie_name, '1', strtotime( 'tomorrow' ) );
		}
	}

	public function register_dashboard_widgets() {

		if ( ! $this->has_capability ) return;

		wp_add_dashboard_widget(
			'llar_stats_widget',
			__( 'Limit Login Attempts Reloaded', 'limit-login-attempts-reloaded' ),
			array( $this, 'dashboard_widgets_content' ),
			null,
			null,
			'normal',
			'high'
		);
	}

	public function dashboard_widgets_content()
	{
		include_once( LLA_PLUGIN_DIR . 'views/admin-dashboard-widgets.php' );
	}

	/**
	 * Whether a retries_stats bucket key is older than cutoff (safe strtotime for string keys).
	 *
	 * @param mixed $key    Bucket key (unix ts or date string).
	 * @param int   $cutoff Cutoff unix timestamp.
	 *
	 * @return bool
	 */
	private function is_retries_stats_bucket_expired( $key, $cutoff ) {
		if ( is_numeric( $key ) ) {
			return (int) $key < $cutoff;
		}
		$ts = strtotime( (string) $key );
		if ( false === $ts ) {
			return false;
		}
		return $ts < $cutoff;
	}

	/**
	 * Plain-text strings for failed-attempts circle (no HTML; whitelist keys only).
	 *
	 * @param string $key Level text key, e.g. zero_title, desc_low.
	 *
	 * @return string
	 */
	private function get_risk_circle_string( $key ) {
		switch ( $key ) {
			case 'zero_title':
				return __( 'Hooray! Zero failed login attempts (past 24 hrs)', 'limit-login-attempts-reloaded' );
			case 'desc_low':
				return __( 'Your site is currently at a low risk for brute force activity.', 'limit-login-attempts-reloaded' );
			case 'desc_medium':
				return __( 'Your site is currently at a medium risk for brute force activity.', 'limit-login-attempts-reloaded' );
			case 'failed_today_title':
				return __( 'Failed Login Attempts Today', 'limit-login-attempts-reloaded' );
			default:
				return '';
		}
	}

	/**
	 * Recommendation HTML: Micro Cloud path (link text translated; markup built in code).
	 *
	 * @return string
	 */
	private function get_micro_cloud_recommendation_html() {
		return sprintf(
			__(
				'Based on your level of brute force activity, we recommend <a class="llar_orange %s">free Micro Cloud upgrade</a> to access features to reduce failed logins and improve site performance.',
				'limit-login-attempts-reloaded'
			),
			'button_micro_cloud'
		);
	}

	/**
	 * Recommendation HTML: premium upgrade URL (href escaped; rel on external target).
	 *
	 * @param string $upgrade_premium_url Premium URL.
	 * @param bool   $open_new_window     Open link in a new window.
	 *
	 * @return string
	 */
	private function get_premium_recommendation_desc( $upgrade_premium_url, $open_new_window = true ) {
		$url       = esc_url( $upgrade_premium_url );
		if ( $open_new_window ) {
			return sprintf(
				__(
					'Based on your level of brute force activity, we recommend <a href="%s" class="llar_orange" target="_blank" rel="noopener noreferrer">upgrading to premium</a> to access features to reduce failed logins and improve site performance.',
					'limit-login-attempts-reloaded'
				),
				$url
			);
		}

		return sprintf(
			__(
				'Based on your level of brute force activity, we recommend <a href="%s" class="llar_orange">upgrading to premium</a> to access features to reduce failed logins and improve site performance.',
				'limit-login-attempts-reloaded'
			),
			$url
		);
	}

	/**
	 * Get failed login attempts count for the last 24 hours in local mode.
	 *
	 * @return int
	 */
	public function get_local_retries_count_for_last_day() {
		$retries_count = 0;
		$retries_stats = Config::get( 'retries_stats' );

		if ( $retries_stats ) {
			$cutoff_ts = time() - DAY_IN_SECONDS;
			foreach ( $retries_stats as $key => $count ) {
				if ( is_numeric( $key ) && (int) $key > $cutoff_ts ) {
					$retries_count += $count;
				} elseif ( ! is_numeric( $key ) && date_i18n( 'Y-m-d' ) === $key ) {
					$retries_count += $count;
				}
			}
		}

		return (int) $retries_count;
	}

	/**
	 * Remove retries_stats buckets older than 8 days (keeps autoload option bounded).
	 *
	 * @param array $retries_stats Stats keyed by time bucket.
	 *
	 * @return array
	 */
	private function prune_retries_stats_old_buckets( $retries_stats ) {
		if ( ! is_array( $retries_stats ) || empty( $retries_stats ) ) {
			return $retries_stats;
		}

		$cutoff = strtotime( '-8 day' );
		foreach ( $retries_stats as $key => $count ) {
			if ( $this->is_retries_stats_bucket_expired( $key, $cutoff ) ) {
				unset( $retries_stats[ $key ] );
			}
		}

		return $retries_stats;
	}

	/**
	 * Build localized retries chart title with attempts count.
	 *
	 * @param int $retries_count Number of retries.
	 *
	 * @return string
	 */
	private function get_retries_chart_title_with_count( $retries_count ) {
		return sprintf(
			_n(
				'%d failed login attempt ',
				'%d failed login attempts ',
				$retries_count,
				'limit-login-attempts-reloaded'
			),
			$retries_count
		) . __( '(past 24 hrs)', 'limit-login-attempts-reloaded' );
	}

	/**
	 * Build recommendation description for elevated brute force activity.
	 *
	 * @param string $setup_code App setup code.
	 *
	 * @return string
	 */
	private function get_recommendation_desc( $setup_code ) {
		if ( ! empty( $setup_code ) ) {
			$premium_tab_url = $this->get_options_page_uri( 'premium' );
			return $this->get_premium_recommendation_desc( $premium_tab_url, false );
		}

		return $this->get_micro_cloud_recommendation_html();
	}

	/**
	 * Resolve risk level by retries count using configured ranges.
	 *
	 * @param int   $retries_count Retries count.
	 * @param array $levels        Risk levels config.
	 *
	 * @return array
	 */
	private function resolve_risk_level( $retries_count, $levels ) {
		$default_level = null;

		foreach ( $levels as $level ) {
			if ( isset( $level['exact'] ) && (int) $level['exact'] === $retries_count ) {
				return $level;
			}

			if ( isset( $level['max_exclusive'] ) && (int) $level['max_exclusive'] > $retries_count ) {
				return $level;
			}

			if ( ! empty( $level['default'] ) ) {
				$default_level = $level;
			}
		}

		return null !== $default_level ? $default_level : array();
	}

	/**
	 * Build chart title/description/color from matched risk level.
	 *
	 * @param array  $matched_level        Matched level config.
	 * @param int    $retries_count        Retries count.
	 * @param array  $risk_config          Risk config.
	 * @param string $setup_code           App setup code.
	 * @param string $upgrade_premium_url  Premium upgrade URL.
	 *
	 * @return array
	 */
	private function build_chart_display_data( $matched_level, $retries_count, $risk_config, $setup_code, $upgrade_premium_url ) {
		$risk_colors = ( isset( $risk_config['colors'] ) && is_array( $risk_config['colors'] ) ) ? $risk_config['colors'] : array();
		$default_color = isset( $risk_colors['green'] ) ? $risk_colors['green'] : '#97F6C8';

		$retries_chart_title = '';
		$retries_chart_desc = '';
		$retries_chart_color = $default_color;

		$rule_flag_keys = array( 'count_title', 'warning_title', 'recommendation', 'premium_recommendation' );
		foreach ( array( 'title', 'count_title', 'warning_title', 'desc', 'recommendation', 'premium_recommendation', 'color' ) as $rule_key ) {
			if ( ! isset( $matched_level[ $rule_key ] ) ) {
				continue;
			}
			if ( in_array( $rule_key, $rule_flag_keys, true ) ) {
				if ( true !== $matched_level[ $rule_key ] && ! $matched_level[ $rule_key ] ) {
					continue;
				}
			} elseif ( empty( $matched_level[ $rule_key ] ) ) {
				continue;
			}

			switch ( $rule_key ) {
				case 'title':
					if ( ! empty( $matched_level['title'] ) ) {
						$retries_chart_title = $this->get_risk_circle_string( $matched_level['title'] );
					}
					break;
				case 'count_title':
					$retries_chart_title = $this->get_retries_chart_title_with_count( $retries_count );
					break;
				case 'warning_title':
					$medium_upper = isset( $risk_config['bounds']['medium_upper'] ) ? (int) $risk_config['bounds']['medium_upper'] : 0;
					if ( $medium_upper <= 0 && isset( $matched_level['min_inclusive'] ) ) {
						$medium_upper = (int) $matched_level['min_inclusive'];
					}
					if ( $medium_upper <= 0 ) {
						$medium_upper = 300;
					}
					/* translators: %d: threshold count (e.g. 300) for "N+ failed login attempts" (high risk, local mode). */
					$retries_chart_title = sprintf(
						__( 'Your site has experienced %d+ failed login attempts in the past 24 hours.', 'limit-login-attempts-reloaded' ),
						$medium_upper
					);
					break;
				case 'desc':
					if ( ! empty( $matched_level['desc'] ) ) {
						$retries_chart_desc = $this->get_risk_circle_string( $matched_level['desc'] );
					}
					break;
				case 'recommendation':
					$recommendation_html = $this->get_recommendation_desc( $setup_code );
					if ( ! empty( $retries_chart_desc ) ) {
						$retries_chart_desc .= '<br><br>' . $recommendation_html;
					} else {
						$retries_chart_desc = $recommendation_html;
					}
					break;
				case 'premium_recommendation':
					$retries_chart_desc = $this->get_premium_recommendation_desc( $upgrade_premium_url );
					break;
				case 'color':
					if ( isset( $risk_colors[ $matched_level['color'] ] ) ) {
						$retries_chart_color = $risk_colors[ $matched_level['color'] ];
					}
					break;
			}
		}

		return array(
			'retries_chart_title' => $retries_chart_title,
			'retries_chart_desc'  => $retries_chart_desc,
			'retries_chart_color' => $retries_chart_color,
		);
	}

	/**
	 * Build data for failed attempts circle widget.
	 *
	 * Local mode: risk color bands by retries (0 / 1–99 / 100–299 / 300+). Custom Cloud: always green
	 * indicator; retries count only (no risk band styling).
	 *
	 * @param bool        $is_active_app_custom Cloud mode flag.
	 * @param bool|string $is_exhausted         Cloud exhausted flag (unused for donut styling; kept for callers).
	 * @param string      $block_sub_group      Cloud plan name (unused for donut styling; kept for callers).
	 * @param string      $setup_code           App setup code.
	 * @param string      $upgrade_premium_url  Premium upgrade URL.
	 * @param bool|array  $api_stats            Cloud API stats.
	 *
	 * @return array
	 */
	public function get_failed_attempts_circle_data( $is_active_app_custom, $is_exhausted, $block_sub_group, $setup_code, $upgrade_premium_url, $api_stats ) {
		$risk_config         = llar_get_risk_config();
		$risk_levels         = ( isset( $risk_config['levels'] ) && is_array( $risk_config['levels'] ) ) ? $risk_config['levels'] : array();
		$risk_colors         = ( isset( $risk_config['colors'] ) && is_array( $risk_config['colors'] ) ) ? $risk_config['colors'] : array();
		$retries_chart_title = '';
		$retries_chart_desc  = '';
		$retries_chart_color = '';
		$retries_count       = 0;

		if ( ! $is_active_app_custom ) {
			$retries_count = $this->get_local_retries_count_for_last_day();

			$local_levels  = isset( $risk_levels['local'] ) && is_array( $risk_levels['local'] ) ? $risk_levels['local'] : array();
			$matched_level = $this->resolve_risk_level( $retries_count, $local_levels );
			$display_data = $this->build_chart_display_data( $matched_level, $retries_count, $risk_config, $setup_code, $upgrade_premium_url );
			$retries_chart_title = $display_data['retries_chart_title'];
			$retries_chart_desc = $display_data['retries_chart_desc'];
			$retries_chart_color = $display_data['retries_chart_color'];
		} else {
			// Custom Cloud: always green "no risk" indicator; show retries count only (product spec).
			if ( $api_stats && ! empty( $api_stats['attempts']['count'] ) && is_array( $api_stats['attempts']['count'] ) ) {
				$attempt_counts = array();
				foreach ( $api_stats['attempts']['count'] as $v ) {
					if ( is_numeric( $v ) ) {
						$attempt_counts[] = (int) $v;
					}
				}
				if ( ! empty( $attempt_counts ) ) {
					$retries_count = (int) end( $attempt_counts );
				}
			}

			$retries_chart_title = $this->get_risk_circle_string( 'failed_today_title' );
			$retries_chart_desc  = '';
			$retries_chart_color = isset( $risk_colors['green'] ) ? $risk_colors['green'] : '#97F6C8';
		}

		return array(
			'retries_chart_title' => $retries_chart_title,
			'retries_chart_desc'  => $retries_chart_desc,
			'retries_chart_color' => $retries_chart_color,
			'retries_count'       => (int) $retries_count,
		);
	}

	/**
	 * Redirect to dashboard page after installed
	 */
	public function dashboard_page_redirect()
	{
		if (
			! get_transient( 'llar_dashboard_redirect' )
			|| isset( $_GET['activate-multi'] ) || is_network_admin()
		) {
			return;
		}

		delete_transient( 'llar_dashboard_redirect' );

		wp_redirect( admin_url( 'index.php?page=' . $this->_options_page_slug ) );
		exit();
	}

	/**
	 * Redirect to dashboard when onboarding is not completed yet (so onboarding can start on any plugin page).
	 * Runs on admin_init before any output to avoid "headers already sent" when using wp_safe_redirect().
	 */
	public function onboarding_redirect_to_dashboard()
	{
		if ( empty( $_GET['page'] ) || $this->_options_page_slug !== $_GET['page'] ) {
			return;
		}
		$tab = isset( $_GET['tab'] ) ? sanitize_text_field( $_GET['tab'] ) : 'dashboard';
		if ( 'dashboard' === $tab ) {
			return;
		}
		if ( Config::get( 'onboarding_popup_shown' ) ) {
			return;
		}
		if ( 'custom' === Config::get( Config::OPTION_ACTIVE_APP ) && self::$cloud_app ) {
			return;
		}
		if ( ! empty( Config::get( 'app_setup_code' ) ) ) {
			return;
		}
		wp_safe_redirect( $this->get_options_page_uri( 'dashboard' ) );
		exit;
	}

	/**
	 * Hook 'plugins_loaded'
	 */
	public function setup()
	{
		if ( ! ( $activation_timestamp = Config::get( 'activation_timestamp' ) ) ) {

			// Write time when the plugin is activated
			Config::update( 'activation_timestamp', time() );
		}

		if ( ! ( $activation_timestamp = Config::get( 'notice_enable_notify_timestamp' ) ) ) {

			// Write time when the plugin is activated
			Config::update( 'notice_enable_notify_timestamp', strtotime( '-32 day' ) );
		}

		if ( ! self::is_wp_at_least( '5.5' ) ) {
			Config::update( 'auto_update_choice', 0 );
		}

		// Load translations and defaults in a WP-version-safe way.
		add_action( 'init', array( $this, 'load_plugin_textdomain_in_time' ) );

		// Reset per-request static guards for persistent runtimes (Swoole/FrankenPHP).
		add_action( 'init', array( __CLASS__, 'reset_request_guards' ), 0 );

		$this->register_mfa_providers();
		DigestScheduler::bootstrap();
		DigestDispatcher::bootstrap();

		// Check if installed old plugin
		$this->check_original_installed();

		// Setup default plugin options
		//$this->sanitize_options();

		add_action( 'wp_login_failed', array( $this, 'limit_login_failed' ) );
		add_filter( 'wp_authenticate_user', array( $this, 'wp_authenticate_user' ), 99999, 2 );
		add_action( 'wp_login', array( $this, 'limit_login_success' ), 10, 2 );

		add_filter( 'shake_error_codes', array( $this, 'failure_shake' ) );
		add_filter( 'wp_login_errors', array( $this, 'inject_mfa_return_login_error' ), 10, 2 );
		add_action( 'login_errors', array( $this, 'fixup_error_messages' ) );
		// hook for the plugin UM
		add_action( 'um_submit_form_errors_hook_login', array( $this, 'um_limit_login_failed' ) );

		if ( Helpers::is_network_mode() ) {
			add_action( 'network_admin_menu', array( $this, 'network_admin_menu' ) );

			if ( Config::get( 'show_warning_badge' ) )
				add_action( 'network_admin_menu', array( $this, 'network_setting_menu_alert_icon' ) );
		}

		if ( Helpers::allow_local_options() ) {
			add_action( 'admin_menu', array( $this, 'admin_menu' ) );

			if ( Config::get( 'show_top_bar_menu_item' ) )
				add_action( 'admin_bar_menu', array( $this, 'admin_bar_menu' ), 999 );

			if ( Config::get( 'show_warning_badge' ) )
				add_action( 'admin_menu', array( $this, 'setting_menu_alert_icon' ) );
		}

		// Add notices for XMLRPC request
		add_filter( 'xmlrpc_login_error', array( $this, 'xmlrpc_error_messages' ) );

		/*
		 * Primary auth chain: guard at lowest priority, then early ACL/blacklist,
		 * credentials tracking, late error fallback, and final lockout safety net.
		 */
		add_filter( 'authenticate', array( $this, 'authenticate_guard_filter' ), -9999, 3 );
		add_action( 'authenticate', array( $this, 'track_credentials' ), 1, 3 ); // to replace the deprecated wp_authenticate hook
		add_action( 'authenticate', array( $this, 'authenticate_filter' ), 0, 3 );

		/**
		 * BuddyPress unactivated user account message fix
		 * Wordfence error message fix
		 */
		add_action( 'authenticate', array( $this, 'authenticate_filter_errors_fix' ), 35, 3 );

		// @temporary WP 7.0 compat — late safety net.
		// TODO: Remove after WP 7.1 release or when auth flow is stable.
		if ( self::is_wp_at_least( '7.0' ) ) {
			add_filter( 'authenticate', array( $this, 'authenticate_late_lockout_check' ), self::LATE_AUTH_PRIORITY, 3 );
		}

		add_filter( 'plugin_action_links_' . LLA_PLUGIN_BASENAME, array( $this, 'add_action_links' ) );

		// MFA flow callback: llar_mfa=1&token=...&code=...
		add_action( 'init', array( $this, 'mfa_flow_callback' ), 1 );
		add_action( 'init', array( DigestStorage::class, 'register_post_type' ) );
		add_filter( 'query_vars', array( $this, 'add_mfa_flow_query_var' ) );
		MfaRestApi::register();

		$role = get_role( 'administrator' );

		if ( $role && ! $role->has_cap( self::$capabilities ) ) {

			$role->add_cap( self::$capabilities );
		}

		$this->has_capability = ( current_user_can('manage_options' ) || current_user_can( self::$capabilities ) );

	}


	/**
	 * Initialize i18n and plugin defaults.
	 *
	 * WordPress 6.9+ (including 7.x) uses JIT translation loading and no longer needs
	 * explicit `load_plugin_textdomain()` calls. Older WordPress versions still rely on it.
	 *
	 * @return void
	 */
	public function load_plugin_textdomain_in_time()
	{
		if ( ! self::is_wp_at_least( '6.9' ) ) {
			load_plugin_textdomain( 'limit-login-attempts-reloaded', false, basename( LLA_PLUGIN_DIR ) . '/languages' );
		}

		Config::init_defaults();
	}

	public function login_page_gdpr_message()
	{

		if ( ! Config::get( 'gdpr' ) || isset( $_REQUEST['interim-login'] ) ) return;

		?>
        <div id="llar-login-page-gdpr">
            <div class="llar-login-page-gdpr__message"><?php echo do_shortcode( stripslashes( Config::get( 'gdpr_message' ) ) ); ?></div>
            <div class="llar-login-page-gdpr__close" onclick="document.getElementById('llar-login-page-gdpr').style.display = 'none';">
                &times;
            </div>
        </div>
		<?php
	}

	public function login_page_render_js()
	{
		if ( true === LoginFlowTransientStore::get( 'llar_user_is_whitelisted', false ) ) {
			LoginFlowTransientStore::merge( array( 'llar_user_is_whitelisted' => null ) );
			return;
		}
		global $limit_login_just_lockedout, $limit_login_nonempty_credentials, $um_limit_login_failed;

		$llar_mfa_error = isset( $_GET['llar_mfa_error'] ) ? sanitize_text_field( wp_unslash( $_GET['llar_mfa_error'] ) ) : '';
		// Same error output as failed login for any MFA redirect (session_expired, code_invalid, etc.).
		$show_mfa_return_error = ( $llar_mfa_error !== '' );

		if ( Config::get( Config::OPTION_ACTIVE_APP ) === 'local' && ! $limit_login_nonempty_credentials && ! $show_mfa_return_error ) {
			return;
		}

		$custom_error = Config::get( 'custom_error_message' );
		$late_hook_errors = ! empty( $this->all_errors_array['late_hook_errors'] ) ? $this->all_errors_array['late_hook_errors'] : false;
		$is_wp_login_page = isset( $_POST['log'] );
		$is_custom_login_page = $this->integration_manager->is_custom_login_page();

		$mfa_return_message = __( '<strong>ERROR</strong>: Incorrect username or password.', 'limit-login-attempts-reloaded' );
		if ( ( $limit_login_nonempty_credentials && ( $is_wp_login_page || $is_custom_login_page || $um_limit_login_failed ) ) || $show_mfa_return_error ) :
            ?>

            <script>
                ;( function( $ ) {
                    let ajaxUrlObj = new URL( `<?php echo admin_url( 'admin-ajax.php' ); ?>` );
                    let um_limit_login_failed = `<?php echo esc_js( isset( $um_limit_login_failed ) ? $um_limit_login_failed : '' ); ?>`;
                    let late_hook_errors = <?php echo wp_json_encode( wp_kses_post( ( $late_hook_errors ) ) ) ?>;
                    let custom_error = <?php echo wp_json_encode( nl2br( esc_html( $custom_error ) ) ) ?>;
                    let llar_mfa_return_error = <?php echo $show_mfa_return_error ? 'true' : 'false'; ?>;
                    let llar_mfa_return_message = <?php echo wp_json_encode( wp_kses_post( $mfa_return_message ) ); ?>;

                    ajaxUrlObj.protocol = location.protocol;

                    $.post( ajaxUrlObj.toString(), {
                        action: 'get_remaining_attempts_message',
                        sec: '<?php echo wp_create_nonce( "llar-get-remaining-attempts-message" ); ?>'
                    }, function( response ) {
                        if ( llar_mfa_return_error ) {
                            if ( response.success && response.data ) {
                                notification_login_page( response.data + ( custom_error.length ? '<br /><br />' + custom_error : '' ) );
                            } else {
                                notification_login_page( llar_mfa_return_message + ( custom_error.length ? '<br /><br />' + custom_error : '' ) );
                            }
                            return;
                        }
                        if ( response.success && response.data ) {

                            if ( custom_error.length ) {

                                custom_error = '<br /><br />' + custom_error;
                            }
                             notification_login_page( response.data + custom_error );

                        } else if ( um_limit_login_failed ) {

                            if ( late_hook_errors === false || late_hook_errors === '' ) {

                                notification_login_page( custom_error );
                            } else {

                                if ( custom_error.length ) {
                                    custom_error = '<br /><br />' + custom_error;
                                }

                                notification_login_page( late_hook_errors + custom_error );
                            }

                        } else {

                            if ( custom_error.length ) {
                                notification_login_page(custom_error);
                            }
                        }
                    } ).fail( function() {
                        if ( llar_mfa_return_error ) {
                            notification_login_page( llar_mfa_return_message + ( custom_error.length ? '<br /><br />' + custom_error : '' ) );
                        }
                    } );

                    function notification_login_page( message ) {

                        if ( ! message.length ) {
                            return false;
                        }
                        let css = '.llar_notification_login_page { position: fixed; top: 50%; left: 50%; font-size: 120%; line-height: 1.5; width: 365px; z-index: 999999; background: #fffbe0; padding: 20px; color: rgb(121, 121, 121); text-align: center; border-radius: 10px; transform: translate(-50%, -50%); box-shadow: 10px 10px 14px 0 #72757B99;} .llar_notification_login_page h4 { color: rgb(255, 255, 255); margin-bottom: 1.5rem; } .llar_notification_login_page .close-button {position: absolute; top: 0; right: 5px; cursor: pointer; line-height: 1;}';
                        let style = document.createElement('style');
                        style.appendChild(document.createTextNode(css));
                        document.head.appendChild(style);

                        $( 'body' ).prepend( '<div class="llar_notification_login_page"><div class="close-button">&times;</div>' + message + '</div>' );

                        setTimeout(function () {
                            $('.llar_notification_login_page').hide();
                        }, 10000);

                        $('.llar_notification_login_page').on( 'click', '.close-button', function () {
                            $('.llar_notification_login_page').hide();
                        });

                        $( 'body' ).on('click', function(event) {
                            if (!$(event.target).closest('.llar_notification_login_page').length) {
                                $('.llar_notification_login_page').hide();
                            }
                        });
                    }

                } )(jQuery)
            </script>
		<?php endif;
	}

	public function add_action_links( $actions )
	{
		$actions = array_merge( array(
			'<a href="' . $this->get_options_page_uri() . '">' . __( 'Dashboard', 'limit-login-attempts-reloaded' ) . '</a>',
			'<a href="' . $this->get_options_page_uri( 'settings' ) . '">' . __( 'Settings', 'limit-login-attempts-reloaded' ) . '</a>',
		), $actions );

		if ( Config::get( Config::OPTION_ACTIVE_APP ) === 'local' ) {

			if ( empty( Config::get( 'app_setup_code' ) ) ) {

				$slug = $this->get_options_page_uri('dashboard#modal_micro_cloud');

				$actions = array_merge( array(
					'<a href="' . esc_html( $slug ) . '" style="font-weight: bold;">' . __( 'Free Upgrade', 'limit-login-attempts-reloaded' ) . '</a>',
				), $actions );
			} else {

				$url_site = 'https://www.limitloginattempts.com/info.php?from=plugin-plugins';

				$actions = array_merge( array(
					'<a href="' . esc_html( $url_site ) . '" target="_blank" style="font-weight: bold;">' . __( 'Upgrade to Premium', 'limit-login-attempts-reloaded' ) . '</a>',
				), $actions );
			}
		}

		return $actions;
	}

	/**
	 * Add llar_mfa to public query vars for MFA flow callback.
	 *
	 * @param array $vars Existing query vars.
	 * @return array
	 */
	public function add_mfa_flow_query_var( $vars ) {
		$vars[] = 'llar_mfa';
		return $vars;
	}

	/**
	 * MFA flow callback: handle llar_mfa=1&token=...&code=... and exit if handled.
	 */
	public function mfa_flow_callback() {
		\LLAR\Core\MfaFlow\CallbackHandler::maybe_handle();
	}

	public function cloud_app_init()
	{
		if ( Config::get( Config::OPTION_ACTIVE_APP ) === 'custom' && $config = Config::get( 'app_config' ) ) {

			self::$cloud_app = new CloudApp( $config );
		}
	}

	public function load_admin_scripts()
	{
		if ( ! empty( $_REQUEST['page'] ) && $_REQUEST['page'] !== $this->_options_page_slug ) {
			return;
		}

		wp_enqueue_script('jquery-ui-accordion');
		wp_enqueue_style('llar-jquery-ui', LLA_PLUGIN_URL.'assets/css/jquery-ui.css');

		wp_enqueue_script( 'llar-charts', LLA_PLUGIN_URL . 'assets/js/chart.umd.js' );
	}

	public function check_whitelist_ips( $allow, $ip )
	{
		return Helpers::ip_in_range( $ip, ( array ) Config::get( 'whitelist' ) );
	}

	public function check_whitelist_usernames( $allow, $username )
	{
		$username = trim( (string) $username );
		if ( '' === $username ) {
			return false;
		}

		$whitelist_usernames = (array) Config::get( 'whitelist_usernames' );
		foreach ( $whitelist_usernames as $whitelist_username ) {
			if ( 0 === strcasecmp( $username, trim( (string) $whitelist_username ) ) ) {
				return true;
			}
		}

		return false;
	}

	public function check_blacklist_ips( $allow, $ip )
	{
		return Helpers::ip_in_range( $ip, ( array ) Config::get( 'blacklist' ) );
	}

	public function check_blacklist_usernames( $allow, $username )
	{
		return in_array( $username, ( array ) Config::get( 'blacklist_usernames' ) );
	}

	/**
	 * @param $blacklist
	 * @return array|null
	 */
	public function register_user_blacklist($blacklist)
	{

		$black_list_usernames = Config::get( 'blacklist_usernames' );

		if ( ! empty( $black_list_usernames ) && is_array( $black_list_usernames ) ) {
			$blacklist += $black_list_usernames;
		}

		return $blacklist;
	}

	/**
	 * @param $error IXR_Error
	 *
	 * @return IXR_Error
	 */
	public function xmlrpc_error_messages( $error )
	{
		if ( ! class_exists( 'IXR_Error' ) ) {
			return $error;
		}

		if ( $login_error = $this->get_message() ) {

			return new IXR_Error( 403, strip_tags( $login_error ) );
		}

		return $error;
	}


	/**
	 * @param $user
	 * @param $username
	 * @param $password
	 *
	 * @return WP_Error | WP_User
	 * @throws Exception
	 */
	public function authenticate_filter( $user, $username, $password )
	{
		LoginFlowTransientStore::ensure_token();
		if ( ! is_wp_error( $user ) ) {
			LoginFlowTransientStore::merge( array( 'errors_in_early_hook' => false ) );
		}

		$error_message = '';
		if ( $this->check_login_blocked( $username, $password, $error_message ) ) {
			return $this->create_username_blacklisted_error( $error_message );
		}

		if ( ! empty( $username ) && ! empty( $password ) ) {
			$ip = $this->get_address();

			if ( self::$cloud_app && $response = $this->get_auth_acl_response( $username ) ) {
				if ( 'pass' === $response['result'] ) {
					remove_filter( 'login_errors', array( $this, 'fixup_error_messages' ) );

					// @temporary WP 7.0 compat — on WP 7.0+ keep all hooks active; late safety net handles recording and lockout.
					// TODO: Remove after WP 7.1 release or when auth flow is stable.
					// On older WP, preserve original hook removal logic.
					if ( ! self::is_wp_at_least( '7.0' ) ) {
						$mfa_effectively_enabled = Config::get( 'mfa_enabled' ) && ( false === get_transient( MfaConstants::TRANSIENT_MFA_DISABLED ) );
						if ( ! $mfa_effectively_enabled ) {
							remove_filter( 'wp_login_failed', array( $this, 'limit_login_failed' ) );
						}
						remove_filter( 'wp_authenticate_user', array( $this, 'wp_authenticate_user' ), 99999 );
					}
				}
			} else {

				$ip = $this->get_address();

				// Check if username is blacklisted
				if (
					( ! $this->is_username_whitelisted( $username ) && ! $this->is_ip_whitelisted( $ip ) )
					&& ( $this->is_username_blacklisted( $username ) || $this->is_ip_blacklisted( $ip ) )
				) {

					LoginFlowTransientStore::merge( array( 'login_attempts_left' => null ) );

					remove_filter( 'login_errors', array( $this, 'fixup_error_messages' ) );
					remove_filter( 'wp_login_failed', array( $this, 'limit_login_failed' ) );
					remove_filter( 'wp_authenticate_user', array( $this, 'wp_authenticate_user' ), 99999 );

					// Remove default WP authentication filters
					remove_filter( 'authenticate', 'wp_authenticate_username_password', 20 );
					remove_filter( 'authenticate', 'wp_authenticate_email_password', 20 );

					$user = new WP_Error();
					$err = __( '<strong>ERROR</strong>: Too many failed login attempts.', 'limit-login-attempts-reloaded' );

					$err = ! empty( $err ) ? '<span>' . $err . '</span>' : '';

					$user->add( 'username_blacklisted', $err );

					LoginFlowTransientStore::merge( array( 'errors_in_early_hook' => true ) );
					$this->all_errors_array['early_hook_errors'] = $err;

					if ( defined('XMLRPC_REQUEST') && XMLRPC_REQUEST ) {

						header('HTTP/1.0 403 Forbidden');
						exit;
					}

				} elseif ( $this->is_username_whitelisted( $username ) || $this->is_ip_whitelisted( $ip ) ) {
					LoginFlowTransientStore::merge( array( 'llar_user_is_whitelisted' => true ) );
					// Do not run limit_login_failed for whitelist: no lockout, but lockout_check / retries would still run and hit the API.
					remove_filter( 'wp_login_failed', array( $this, 'limit_login_failed' ) );
					$mfa_effectively_enabled = Config::get( 'mfa_enabled' ) && ( false === get_transient( MfaConstants::TRANSIENT_MFA_DISABLED ) );
					if ( ! $mfa_effectively_enabled ) {
						remove_filter( 'wp_authenticate_user', array( $this, 'wp_authenticate_user' ), 99999 );
					}
					remove_filter( 'login_errors', array( $this, 'fixup_error_messages' ) );

				} elseif ( self::$cloud_app && self::$cloud_app->last_response_code === 403 ) {
					add_action('wp_login', array( $this, 'cloud_app_null' ), 999);
				}
			}
		}

		return $user;
	}

	/**
	 * Run ACL / blacklist checks before third-party late authenticate hooks.
	 *
	 * @param mixed  $user
	 * @param string $username
	 * @param string $password
	 * @return mixed
	 */
	public function authenticate_guard_filter( $user, $username, $password ) {

		$error_message = '';
		if ( $this->check_login_blocked( $username, $password, $error_message ) ) {
			remove_filter( 'authenticate', 'wp_authenticate_username_password', 20 );
			remove_filter( 'authenticate', 'wp_authenticate_email_password', 20 );
			return $this->create_username_blacklisted_error( $error_message );
		}

		return $user;
	}

	/**
	 * Unified blocked-login check for cloud ACL and local blacklist.
	 *
	 * @param string $username
	 * @param string $password
	 * @param string $error_message
	 * @return bool
	 * @throws Exception
	 */
	private function check_login_blocked( $username, $password, &$error_message ) {
		if ( empty( $username ) || empty( $password ) ) {
			return false;
		}

		if ( self::$cloud_app && $response = $this->get_auth_acl_response( $username ) ) {
			if ( 'deny' === $response['result'] ) {
				$time_left = ! empty( $response['time_left'] ) ? (int) $response['time_left'] : 0;
				$error_message = $this->build_lockout_error_message( $time_left );

				self::$cloud_app->add_error( $error_message );
				$this->log_security_event( 'cloud_acl_deny', $username, $this->get_address(), array( 'time_left' => $time_left ) );
				LoginFlowTransientStore::ensure_token();
				LoginFlowTransientStore::merge(
					array(
						'errors_in_early_hook'           => true,
						'llar_early_hook_error_message' => $error_message,
						'login_attempts_left'            => null,
					)
				);
				$this->all_errors_array['early_hook_errors'] = $error_message;

				if ( defined('XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
					header('HTTP/1.0 403 Forbidden' );
					exit;
				}

				remove_filter( 'login_errors', array( $this, 'fixup_error_messages' ) );
				remove_filter( 'wp_login_failed', array( $this, 'limit_login_failed' ) );
				remove_filter( 'wp_authenticate_user', array( $this, 'wp_authenticate_user' ), 99999 );

				return true;
			}
		}

		$ip = $this->get_address();
		if ( $this->is_ip_whitelisted( $ip ) || $this->is_local_allowlisted_username( $username ) ) {
			return false;
		}

		if (
			( ! $this->is_username_whitelisted( $username ) && ! $this->is_ip_whitelisted( $ip ) )
			&& ( $this->is_username_blacklisted( $username ) || $this->is_ip_blacklisted( $ip ) )
		) {
			$error_message = $this->build_lockout_error_message();
			$this->log_security_event( 'local_blacklist_block', $username, $ip );
			LoginFlowTransientStore::ensure_token();
			LoginFlowTransientStore::merge(
				array(
					'errors_in_early_hook' => true,
					'login_attempts_left'  => null,
				)
			);
			$this->all_errors_array['early_hook_errors'] = $error_message;

			remove_filter( 'login_errors', array( $this, 'fixup_error_messages' ) );
			remove_filter( 'wp_login_failed', array( $this, 'limit_login_failed' ) );
			remove_filter( 'wp_authenticate_user', array( $this, 'wp_authenticate_user' ), 99999 );

			if ( defined('XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
				header('HTTP/1.0 403 Forbidden' );
				exit;
			}

			return true;
		}

		return false;
	}

	/**
	 * Build lockout error message with optional time left.
	 *
	 * @param int $time_left
	 * @return string
	 */
	private function build_lockout_error_message( $time_left = 0 ) {
		$err = __( '<strong>ERROR</strong>: Too many failed login attempts.', 'limit-login-attempts-reloaded' );

		if ( 0 < $time_left ) {
			if ( 60 < $time_left ) {
				$time_left = ceil( $time_left / 60 );
				$err .= ' ' . sprintf( _n( 'Please try again in %d hour.', 'Please try again in %d hours.', $time_left, 'limit-login-attempts-reloaded' ), $time_left );
			} else {
				$err .= ' ' . sprintf( _n( 'Please try again in %d minute.', 'Please try again in %d minutes.', $time_left, 'limit-login-attempts-reloaded' ), $time_left );
			}
		}

		return '<span>' . wp_kses_post( $err ) . '</span>';
	}

	/**
	 * Create standardized lockout WP_Error.
	 *
	 * @param string $error_message
	 * @return WP_Error
	 */
	private function create_username_blacklisted_error( $error_message ) {
		return new WP_Error( 'username_blacklisted', $error_message );
	}

	/**
	 * Lightweight security event logging (enabled when WP debug log is active).
	 *
	 * @param string $event_type
	 * @param string $username
	 * @param string $ip
	 * @param array  $details
	 * @return void
	 */
	private function log_security_event( $event_type, $username, $ip, $details = array() ) {
		if ( ! defined( 'WP_DEBUG_LOG' ) || ! WP_DEBUG_LOG ) {
			return;
		}

		error_log(
			'[LLAR Security] ' . wp_json_encode(
				array(
					'event'    => $event_type,
					'username' => $this->mask_username_for_log( $username ),
					'ip'       => $this->mask_ip_for_log( $ip ),
					'gateway'  => Helpers::detect_gateway(),
					'details'  => $this->sanitize_security_log_details( $details ),
				)
			)
		);
	}

	/**
	 * Allow only non-sensitive detail keys in security logs.
	 *
	 * @param array $details Raw details.
	 * @return array
	 */
	private function sanitize_security_log_details( $details ) {
		if ( empty( $details ) || ! is_array( $details ) ) {
			return array();
		}

		$allowed = apply_filters(
			'llar_security_log_detail_keys',
			array( 'time_left', 'attempts', 'reason', 'window' )
		);
		if ( ! is_array( $allowed ) ) {
			$allowed = array( 'time_left', 'attempts', 'reason', 'window' );
		}

		$safe = array();
		foreach ( $details as $key => $value ) {
			if ( in_array( (string) $key, $allowed, true ) ) {
				$safe[ $key ] = $value;
			}
		}

		return $safe;
	}

	/**
	 * Mask username in logs to reduce sensitive data exposure.
	 *
	 * @param string $username
	 * @return string
	 */
	private function mask_username_for_log( $username ) {
		$username = (string) $username;
		$length = strlen( $username );
		if ( $length <= 0 ) {
			return '';
		}
		if ( $length <= 2 ) {
			return str_repeat( '*', $length );
		}

		return substr( $username, 0, 2 ) . str_repeat( '*', $length - 2 );
	}

	/**
	 * Reduce IP precision in debug logs (privacy).
	 *
	 * @param string $ip
	 * @return string
	 */
	private function mask_ip_for_log( $ip ) {
		$ip = (string) $ip;
		if ( '' === $ip ) {
			return '';
		}

		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
			return preg_replace( '/\.\d+$/', '.0', $ip );
		}

		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
			if ( function_exists( 'inet_pton' ) && function_exists( 'inet_ntop' ) ) {
				$binary = inet_pton( $ip );
				if ( false !== $binary && 16 === strlen( $binary ) ) {
					$masked = substr( $binary, 0, 8 ) . str_repeat( "\0", 8 );
					$masked_ip = inet_ntop( $masked );
					if ( false !== $masked_ip ) {
						return $masked_ip . '/64';
					}
				}
			}
			if ( function_exists( 'wp_hash' ) ) {
				return wp_hash( $ip );
			}
		}

		return '***';
	}

	/**
	 * Cached Cloud ACL response for current authenticate request.
	 *
	 * @param string $username
	 * @return array|false
	 * @throws Exception
	 */
	private function get_auth_acl_response( $username ) {
		$payload = array(
			'ip'      => Helpers::get_all_ips(),
			'login'   => $username,
			'gateway' => Helpers::detect_gateway(),
		);
		$cache_key = md5( wp_json_encode( $payload ) );

		if ( isset( $this->auth_acl_response_cache[ $cache_key ] ) ) {
			return $this->auth_acl_response_cache[ $cache_key ];
		}

		$response = self::$cloud_app->acl_check( $payload );
		if ( $this->auth_acl_response_cache_max_size <= count( $this->auth_acl_response_cache ) ) {
			array_shift( $this->auth_acl_response_cache );
		}
		$this->auth_acl_response_cache[ $cache_key ] = $response;

		return $response;
	}


	/**
	 * Delete the CloudApp object
	 */
	public function cloud_app_null()
	{
		self::$cloud_app = null;
	}

	/**
	 * Fix displaying the errors of other plugins
	 *
	 * @param $user
	 * @param $username
	 * @param $password
	 * @return mixed
	 */
	public function authenticate_filter_errors_fix( $user, $username, $password )
	{
		if ( ! empty( $username ) && ! empty( $password ) ) {

			if ( is_wp_error( $user ) ) {

				// @temporary WP 7.0 compat — fallback recording for auth flows where wp_login_failed is unreliable.
				// TODO: Remove after WP 7.1 release or when auth flow is stable.
				if ( self::is_wp_at_least( '7.0' ) ) {
					$error_codes = $user->get_error_codes();
					if (
						! self::$failed_login_recorded_in_request
						&& array_intersect( self::$auth_failure_codes, $error_codes )
					) {
						$this->record_failed_login_attempt( $username );
					}
				}

				// BuddyPress errors
				if ( in_array('bp_account_not_activated', $user->get_error_codes() ) ) {

					$this->other_login_errors[] = $user->get_error_message('bp_account_not_activated');
				} elseif ( in_array('wfls_captcha_verify', $user->get_error_codes() ) ) { // Wordfence errors

					$this->other_login_errors[] = $user->get_error_message( 'wfls_captcha_verify' );
				}
			}

		}
		return $user;
	}

	/**
	 * Late authenticate safety net for WP 7.0+ compatibility.
	 *
	 * @temporary WP 7.0 compat — remove after WP 7.1 release or when auth flow is stable.
	 *
	 * Runs at a very high priority on the authenticate filter to catch
	 * failed logins that were not recorded by earlier hooks (e.g. when
	 * wp_login_failed does not fire or core auth runs at changed priorities)
	 * and to enforce lockout even when the wp_authenticate_user filter
	 * inside wp_authenticate_username_password is not reached.
	 *
	 * @param mixed  $user
	 * @param string $username
	 * @param string $password
	 * @return mixed
	 */
	public function authenticate_late_lockout_check( $user, $username, $password ) {
		if ( empty( $username ) || empty( $password ) ) {
			return $user;
		}

		// Successful auth already validated by earlier guards — do not override.
		if ( $user instanceof \WP_User ) {
			return $user;
		}

		if ( is_wp_error( $user ) ) {
			$error_codes = $user->get_error_codes();

			if (
				in_array( 'too_many_retries', $error_codes, true )
				|| in_array( 'username_blacklisted', $error_codes, true )
			) {
				return $user;
			}

			if ( ! self::$failed_login_recorded_in_request ) {
				if ( array_intersect( self::$auth_failure_codes, $error_codes ) ) {
					$this->record_failed_login_attempt( $username );
				}
			}
		}

		$ip = $this->get_address();
		if (
			! $this->is_ip_whitelisted( $ip )
			&& ! $this->is_username_whitelisted( $username )
			&& ! $this->is_limit_login_ok( $username )
		) {
			global $limit_login_my_error_shown;
			$limit_login_my_error_shown = true;

			$error = new WP_Error();
			$error->add( 'too_many_retries', $this->error_msg( $username ) );
			LoginFlowTransientStore::merge( array( 'errors_in_early_hook' => false ) );

			return $error;
		}

		return $user;
	}

	public function ultimate_member_register_error_codes( $codes )
	{
		if ( ! is_array( $codes ) ) {
			return $codes;
		}

		$codes[] = 'too_many_retries';
		$codes[] = 'username_blacklisted';

		return $codes;
	}

	/**
	 * Register MFA flow providers (e.g. LlarMfaProvider).
	 */
	private function register_mfa_providers() {
		\LLAR\Core\MfaFlow\MfaProviderRegistry::register( new \LLAR\Core\MfaFlow\Providers\Email\LlarMfaProvider() );
	}

	/**
	 * Check if the original plugin is installed
	 */
	private function check_original_installed()
	{
		require_once( ABSPATH . '/wp-admin/includes/plugin.php' );
		if ( is_plugin_active('limit-login-attempts/limit-login-attempts.php') ) {

			deactivate_plugins( 'limit-login-attempts/limit-login-attempts.php', true );
			remove_action( 'plugins_loaded', 'limit_login_setup', 99999 );
		}
	}

	/**
	 * Enqueue js and css
	 */
	public function enqueue()
	{
		$plugin_data = get_plugin_data( LLA_PLUGIN_DIR . 'limit-login-attempts-reloaded.php' );

		wp_enqueue_style( 'lla-main', LLA_PLUGIN_URL . 'assets/css/limit-login-attempts.css', array(), $plugin_data['Version'] );

		if ( ! empty( $_REQUEST['page'] ) && $_REQUEST['page'] === $this->_options_page_slug ) {

			$auto_update                = wp_create_nonce( 'llar-toggle-auto-update' );
			$app_setup                  = wp_create_nonce( 'llar-app-setup' );
			$account_policies           = wp_create_nonce( 'llar-strong-account-policies' );
			$block_country              = wp_create_nonce( 'llar-block_by_country' );
			$onboarding_reset           = wp_create_nonce( 'llar-action-onboarding-reset' );
			$dismiss_onboarding_popup   = wp_create_nonce( 'llar-dismiss-onboarding-popup' );
			$activate_micro_cloud       = wp_create_nonce( 'llar-activate-micro-cloud' );
			$subscribe_email            = wp_create_nonce( 'llar-subscribe-email' );
			$close_premium_message      = wp_create_nonce( 'llar-close-premium-message' );
			wp_enqueue_script( 'lla-main', LLA_PLUGIN_URL . 'assets/js/limit-login-attempts.js', array('jquery'), $plugin_data['Version'], false );
			wp_localize_script('lla-main', 'llar_vars', array(
				'nonce_auto_update'               => $auto_update,
				'nonce_app_setup'                 => $app_setup,
				'nonce_account_policies'          => $account_policies,
				'nonce_block_by_country'          => $block_country,
				'nonce_onboarding_reset'          => $onboarding_reset,
				'nonce_dismiss_onboarding_popup'  => $dismiss_onboarding_popup,
				'nonce_activate_micro_cloud'      => $activate_micro_cloud,
				'nonce_subscribe_email'           => $subscribe_email,
				'nonce_close_premium_message'     => $close_premium_message,
			));

			global $wp_scripts, $wp_styles;
				
			if($wp_scripts && $wp_scripts->registered) {
				foreach($wp_scripts->registered as $handle => $script) {
					if(strpos($handle, 'jquery-confirm') !== false) {
						wp_dequeue_script($handle);
					}
				}
			}
				
			if($wp_styles && $wp_styles->registered) {
				foreach($wp_styles->registered as $handle => $style) {
					if(strpos($handle, 'jquery-confirm') !== false) {
						wp_dequeue_style($handle);
					}
				}
			}

			wp_enqueue_style( 'lla-jquery-confirm', LLA_PLUGIN_URL . 'assets/css/jquery-confirm.min.css' );
			wp_enqueue_script( 'lla-jquery-confirm', LLA_PLUGIN_URL . 'assets/js/jquery-confirm.min.js' );
		}

	}

	public function login_page_enqueue()
	{
		if ( ! Config::get( 'gdpr' ) || isset( $_REQUEST['interim-login'] ) ) return;

		$plugin_data = get_plugin_data( LLA_PLUGIN_DIR . 'limit-login-attempts-reloaded.php' );

		wp_enqueue_style( 'llar-login-page-styles', LLA_PLUGIN_URL . 'assets/css/login-page-styles.css', array(), $plugin_data['Version'] );
		wp_enqueue_script( 'jquery' );
	}

	/**
	 * Add admin options page
	 */
	public function network_admin_menu()
	{
		if ( ! $this->has_capability ) return;

		add_submenu_page(
			'settings.php',
			'Limit Login Attempts',
			'Limit Login Attempts' . $this->menu_alert_icon(),
			self::$capabilities,
			$this->_options_page_slug,
			array( $this, 'options_page' ) );
	}

	private function get_submenu_items()
	{
		$active_app        = Config::get( Config::OPTION_ACTIVE_APP );
		$app_setup_code    = Config::get( 'app_setup_code' );
		$is_cloud_app_enabled = $active_app === 'custom';
		$is_local_empty_setup_code = ( $active_app === 'local' && empty( $app_setup_code ) );

		$submenu_items = array(
			array(
				'id'    => 'dashboard',
				'name'  => __( 'Dashboard', 'limit-login-attempts-reloaded' ),
				'url'   => '&tab=dashboard'
			),
			array(
				'id'    => 'settings',
				'name'  => __( 'Settings', 'limit-login-attempts-reloaded' ),
				'url'   => '&tab=settings'
			),
			array(
				'id'    => 'mfa',
				'name'  => __( '2FA', 'limit-login-attempts-reloaded' ),
				'url'   => '&tab=mfa'
			),
			$is_cloud_app_enabled
				? array(
				'id'    => 'logs-custom',
				'name'  => __( 'Login Firewall', 'limit-login-attempts-reloaded' ),
				'url'   => '&tab=logs-custom'
			)
				: array(
				'id'    => 'logs-local',
				'name'  => __( 'Logs', 'limit-login-attempts-reloaded' ),
				'url'   => '&tab=logs-local'
			),
			array(
				'id'    => 'debug',
				'name'  => __( 'Debug', 'limit-login-attempts-reloaded' ),
				'url'   => '&tab=debug'
			),
			array(
				'id'    => 'help',
				'name'  => __( 'Help', 'limit-login-attempts-reloaded' ),
				'url'   => '&tab=help'
			)
		);

		if ( ! $is_cloud_app_enabled ) {

			$slug       = '&tab=dashboard#modal_micro_cloud';
			$name_item  = $is_local_empty_setup_code ? __( 'Free Upgrade', 'limit-login-attempts-reloaded' ) : __( 'Premium', 'limit-login-attempts-reloaded' );
			$url_item   = $is_local_empty_setup_code ? $slug : '&tab=premium';

			$submenu_items[] = array(
				'id'    => 'premium',
				'name'  => __( $name_item, 'limit-login-attempts-reloaded' ),
				'url'   => $url_item,
			);
		}

		return $submenu_items;
	}

	public function admin_menu()
	{
		if ( ! $this->has_capability ) return;

		global $submenu;

		if ( Config::get( 'show_top_level_menu_item' ) ) {

			add_menu_page(
				'Limit Login Attempts',
				'Limit Login Attempts' . $this->menu_alert_icon(),
				self::$capabilities,
				$this->_options_page_slug,
				array( $this, 'options_page' ),
				'data:image/svg+xml;base64,' . base64_encode( $this->get_svg_logo_content() ),
				74
			);

			$is_cloud_app_enabled = Config::get( Config::OPTION_ACTIVE_APP ) === 'custom';
			$submenu_items = $this->get_submenu_items();

			$index = 1;
			foreach ( $submenu_items as $item ) {
				add_submenu_page(
					$this->_options_page_slug,
					$item['name'],
					$item['name'],
					self::$capabilities,
					$this->_options_page_slug . $item['url'],
					array( $this, 'options_page' )
				);

				if ( ! empty ( $_GET['page'] ) && $_GET['page'] === $this->_options_page_slug && ! empty( $_GET['tab'] ) && $_GET['tab'] === $item['id'] ) {
					$submenu[$this->_options_page_slug][$index][4] = 'current';
				}
				$index++;
			}

			remove_submenu_page( $this->_options_page_slug, $this->_options_page_slug );

			if ( ! $is_cloud_app_enabled && isset( $submenu[$this->_options_page_slug] ) ) {
				// Premium is the last submenu item (Dashboard, Settings, 2FA, Logs, Debug, Help, Premium).
				$submenu_keys = array_keys( $submenu[$this->_options_page_slug] );
				$premium_key  = end( $submenu_keys );
				$submenu[$this->_options_page_slug][$premium_key][4] =
					! empty( $submenu[$this->_options_page_slug][$premium_key][4] )
						? $submenu[$this->_options_page_slug][$premium_key][4] . ' llar-submenu-premium-item'
						: 'llar-submenu-premium-item';
			}

		} else {

			add_options_page(
				'Limit Login Attempts',
				'Limit Login Attempts' . $this->menu_alert_icon(),
				self::$capabilities,
				$this->_options_page_slug,
				array( $this, 'options_page' )
			);
		}
	}

	public function admin_bar_menu( $admin_bar )
	{

		if ( ! $this->has_capability ) return;

		$root_item_id = 'llar-root';
		$href = $this->get_options_page_uri();

		$admin_bar->add_node( array(
			'id'    => $root_item_id,
			'title' => __( 'LLAR', 'limit-login-attempts-reloaded' ) . $this->menu_alert_icon(),
			'href'  => $href,
		) );

		$submenu_items = $this->get_submenu_items();

		foreach ( $submenu_items as $item ) {

			$admin_bar->add_node( array(
				'parent'    => $root_item_id,
				'id'        => $root_item_id . '-' . $item['id'],
				'title'     => $item['name'],
				'href'      => $href . $item['url'],
			) );
		}

	}

	public function get_svg_logo_content()
	{
		return file_get_contents( LLA_PLUGIN_DIR . 'assets/img/logo.svg' );
	}

	private function menu_alert_icon()
	{

		if (
			! empty( $_COOKIE['llar_menu_alert_icon_shown'] )
			|| Config::get( Config::OPTION_ACTIVE_APP ) !== 'local'
			|| ! Config::get( 'show_warning_badge' )
		) {
			return '';
		}

		$retries_count = 0;
		$retries_stats = Config::get( 'retries_stats' );

		if ( $retries_stats ) {

			foreach ( $retries_stats as $key => $count ) {

				if ( is_numeric( $key ) && $key > strtotime( '-24 hours' ) ) {
					$retries_count += $count;
				} elseif ( ! is_numeric( $key ) && date_i18n( 'Y-m-d' ) === $key ) {
					$retries_count += $count;
				}
			}
		}

		if ( $retries_count < 100 ) {
			return '';
		}

		return ' <span class="update-plugins count-1 llar-alert-icon"><span class="plugin-count">1</span></span>';
	}

	public function setting_menu_alert_icon()
	{
		global $menu;

		if ( ! Config::get( 'show_top_level_menu_item' ) && ! empty( $menu[80][0] ) ) {

			$menu[80][0] .= $this->menu_alert_icon();
		}
	}

	public function network_setting_menu_alert_icon()
	{
		global $menu;

		if ( ! empty( $menu[25][0] ) ) {

			$menu[25][0] .= $this->menu_alert_icon();
		}
	}

	/**
	 * Get the correct options page URI
	 *
	 * @param bool $tab
	 * @return mixed
	 */
	public function get_options_page_uri( $tab = false )
	{
		if ( is_network_admin() ) {
			$uri = network_admin_url( 'settings.php?page=' . $this->_options_page_slug );
		} else {
			$uri = admin_url( 'admin.php?page=' . $this->_options_page_slug );
		}

		if ( ! empty( $tab ) ) {
			$uri = add_query_arg( 'tab', $tab, $uri );
		}

		return $uri;
	}


	/**
	 * Fires after successful login
	 *
	 * @param $username
	 * @param $user
	 *
	 */
	public function limit_login_success( $username, $user ) {

		if ( ! self::$cloud_app ) {
			return;
		}

		if ( ! empty( $username ) ) {

			$clean_url = '';
			if ( isset( $_SERVER['HTTP_REFERER'] ) ) {

				$referer_url = $_SERVER['HTTP_REFERER'];
				$referer_parsed = parse_url( $referer_url );

				$clean_url = isset( $referer_parsed['path']) ? $referer_parsed['path'] : '';
				$clean_url = trim( $clean_url, '/' );
			}

			$user = get_user_by('login', $username);

			$data = array(
				'ip'        => Helpers::get_all_ips(),
				'login'     => $username,
				'user_id'   => $user->ID,
				'gateway'   => Helpers::detect_gateway(),
				'roles'     => $user->roles,
				'agent'     => $_SERVER['HTTP_USER_AGENT'],
				'url'       => $clean_url,
			);

			self::$cloud_app->request( 'login', 'post', $data );
		}
	}


	/**
	 * Resolve login identifier for cloud ACL checks.
	 *
	 * @param string $username Optional username from the auth hook.
	 * @return string
	 */
	private function resolve_login_username( $username = '' ) {
		if ( '' !== $username ) {
			return $username;
		}

		if ( isset( $_REQUEST['log'] ) ) {
			return sanitize_text_field( wp_unslash( $_REQUEST['log'] ) );
		}

		if ( $this->integration_manager ) {
			return $this->integration_manager->get_login_identifier();
		}

		return '';
	}

	/**
	 * Cloud ACL lockout state for the current request.
	 *
	 * Returns null when cloud mode is off, the username cannot be resolved, or
	 * the Cloud API is unreachable — in those cases the caller must fall back
	 * to the local lockouts check so failover keeps blocking attackers.
	 *
	 * @param string $username Optional username from the auth hook.
	 * @return bool|null True when login is allowed, false when denied, null when local check applies.
	 * @throws Exception
	 */
	private function is_cloud_login_allowed( $username = '' ) {
		if ( ! self::$cloud_app ) {
			return null;
		}

		$username = $this->resolve_login_username( $username );
		if ( '' === $username ) {
			return null;
		}

		$response = $this->get_auth_acl_response( $username );
		if ( ! $response ) {
			return null;
		}

		return ( 'deny' !== $response['result'] );
	}

	/**
	 * Check if it is ok to login
	 *
	 * @param string $username Optional username from the auth hook.
	 * @return bool
	 * @throws Exception
	 */
	public function is_limit_login_ok( $username = '' )
	{
		$ip = $this->get_address();

		/* Check external whitelist filter */
		if ( $this->is_ip_whitelisted( $ip ) ) {
			return true;
		}

		$cloud_allowed = $this->is_cloud_login_allowed( $username );
		if ( null !== $cloud_allowed ) {
			return $cloud_allowed;
		}

		/* lockout active? (local mode only) */
		$lockouts = Config::get( Config::OPTION_LOCKOUTS );

		return ( ! is_array( $lockouts ) || ! isset( $lockouts[ $ip ] ) || time() >= $lockouts[ $ip ] );
	}


	/**
	 * Redirect browser to MFA app URL. Clears output buffers, then sends Location header or HTML fallback.
	 *
	 * @param string $url Redirect URL (already escaped).
	 */
	public static function mfa_redirect_to_url( $url ) {
		while ( ob_get_level() ) {
			ob_end_clean();
		}
		if ( ! headers_sent() ) {
			header( 'Location: ' . $url, true, 302 );
			exit;
		}
		$url_attr = esc_attr( $url );
		$url_js   = esc_js( $url );
		echo '<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=' . $url_attr . '"><title>Redirect</title></head><body><p><a href="' . $url_attr . '">Continue to verification</a></p><script>window.location.replace("' . $url_js . '");</script></body></html>';
		exit;
	}

	/**
	 * For plugin UM
	 */
	public function um_limit_login_failed ()
	{
		global $um_limit_login_failed;

		do_action( 'login_errors', '' );
		$um_limit_login_failed = true;
	}

	/**
	 * For plugin MemberPress
	 * Triggers authenticate filter to allow Limit Login Attempts Reloaded
	 * to track credentials and check lockouts before MemberPress validates the password
	 * This enables the plugin to display remaining attempts messages
	 *
	 * @param array $errors Array of existing errors (MemberPress passes validate_login output first).
	 * @param array $params Login parameters (log, pwd)
	 * @return array Errors for MemberPress; when LLAR blocks login, returns that message as first error.
	 */
	public function mepr_validate_login_handler( $errors, $params = array() )
	{
		if ( ! isset( $_POST['log'] ) || ! isset( $_POST['pwd'] ) ) {
			return $errors;
		}

		$log = sanitize_text_field( wp_unslash( $_POST['log'] ) );
		$pwd = isset( $_POST['pwd'] ) ? $_POST['pwd'] : ''; // Password should not be sanitized

		// Trigger authenticate filter to track credentials and check lockouts.
		$auth_result = apply_filters( 'authenticate', null, $log, $pwd );

		if ( is_wp_error( $auth_result ) ) {
			$codes = $auth_result->get_error_codes();
			if ( in_array( 'too_many_retries', $codes, true ) ) {
				return array( $auth_result->get_error_message( 'too_many_retries' ) );
			}
			if ( in_array( 'username_blacklisted', $codes, true ) ) {
				return array( $auth_result->get_error_message( 'username_blacklisted' ) );
			}
		}

		if ( ! $this->is_limit_login_ok( $log ) ) {
			return array( $this->error_msg( $log ) );
		}

		return $errors;
	}

	/**
	 * Run MFA flow on login: handshake, save session, redirect to MFA app.
	 * Exits on successful redirect. Call only after password verification.
	 *
	 * @param string  $username             Value from login form (user_login or email).
	 * @param bool    $is_pre_authenticated True if password was already validated (successful login).
	 * @param WP_User $authenticated_user  Optional. User after password check (use when log field is email).
	 * @return void Exits on redirect; otherwise returns.
	 */
	private function try_mfa_flow_redirect( $username, $is_pre_authenticated = false, $authenticated_user = null ) {
		// CRITICAL: never fetch or disclose any user info unless password was verified.
		if ( ! $is_pre_authenticated ) {
			return;
		}
		$ip = $this->get_address();
		if ( $this->is_ip_whitelisted( $ip ) || $this->is_local_allowlisted_username( $username, $authenticated_user ) ) {
			return;
		}

		if ( self::$cloud_app ) {
			$acl_response = $this->get_auth_acl_response( $username );
			if ( is_array( $acl_response ) && ! empty( $acl_response['result'] ) && 'pass' === $acl_response['result'] ) {
				return;
			}
		}

		$mfa_temporarily_disabled = false !== get_transient( MfaConstants::TRANSIENT_MFA_DISABLED );
		$mfa_enabled              = (bool) Config::get( 'mfa_enabled' ) && ! $mfa_temporarily_disabled;
		$user = null;
		if ( is_a( $authenticated_user, 'WP_User' ) ) {
			$user = $authenticated_user;
		} elseif ( is_string( $username ) && '' !== $username ) {
			$user = get_user_by( 'login', $username );
			if ( ! $user && function_exists( 'is_email' ) && is_email( $username ) ) {
				$user = get_user_by( 'email', $username );
			}
		}
		$mfa_roles         = Config::get( 'mfa_roles', array() );
		$mfa_roles         = is_array( $mfa_roles ) ? $mfa_roles : array();
		$has_mfa_groups    = ! empty( $mfa_roles );
		$user_excluded     = $user && $has_mfa_groups && ! array_intersect( (array) $user->roles, $mfa_roles );
		$should_trigger_mfa = $mfa_enabled && $has_mfa_groups && ! $user_excluded;

		if ( ! $should_trigger_mfa ) {
			return;
		}

		if ( self::$mfa_flow_handshake_attempted ) {
			return;
		}

		$provider_id = defined( 'LLA_MFA_PROVIDER' ) ? LLA_MFA_PROVIDER : 'llar';
		$provider     = \LLAR\Core\MfaFlow\MfaProviderRegistry::get( $provider_id );
		if ( ! $provider ) {
			return;
		}

		$rate_key = 'llar_mfa_flow_handshake_' . md5( $ip . ( defined( 'AUTH_SALT' ) ? AUTH_SALT : 'llar' ) );
		$rate     = get_transient( $rate_key );
		$period   = defined( 'LLA_MFA_FLOW_HANDSHAKE_RATE_LIMIT_PERIOD' ) ? (int) LLA_MFA_FLOW_HANDSHAKE_RATE_LIMIT_PERIOD : 60;
		$max      = defined( 'LLA_MFA_FLOW_HANDSHAKE_RATE_LIMIT_MAX' ) ? (int) LLA_MFA_FLOW_HANDSHAKE_RATE_LIMIT_MAX : 5;

		if ( is_array( $rate ) && isset( $rate['t'], $rate['c'] ) ) {
			if ( time() - (int) $rate['t'] >= $period ) {
				$rate = array( 'c' => 0, 't' => time() );
			}
		} else {
			$rate = array( 'c' => 0, 't' => time() );
		}

		$rate_ok = ( (int) $rate['c'] < $max );
		if ( ! $rate_ok ) {
			$rate['c'] = (int) $rate['c'] + 1;
			set_transient( $rate_key, $rate, $period );
			return;
		}

		self::$mfa_flow_handshake_attempted = true;
		$user_group = '';
		if ( $user && ! empty( $user->roles ) && is_array( $user->roles ) ) {
			$user_group = reset( $user->roles );
		}
		$redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : '';
		$cancel_url  = add_query_arg( 'llar_mfa_cancelled', '1', wp_login_url() );
		$current_request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
		$current_login_url   = '';
		if ( is_string( $current_request_uri ) && '' !== $current_request_uri ) {
			$current_login_url = home_url( $current_request_uri );
		}
		$login_url = ( '' !== $current_login_url ) ? $current_login_url : wp_login_url();
		$login_url   = add_query_arg( 'llar_mfa', '1', $login_url );
		if ( '' !== $redirect_to ) {
			$login_url = add_query_arg( 'redirect_to', $redirect_to, $login_url );
		}
		$payload     = array(
			'user_ip'              => Helpers::get_all_ips(),
			'login_url'            => $login_url,
			'user_group'           => $user_group,
			'is_pre_authenticated' => (bool) $is_pre_authenticated,
		);
		if ( $user ) {
			$payload['user_id'] = (int) $user->ID;
			if ( ! empty( $user->user_email ) && is_string( $user->user_email ) ) {
				$payload['user_email'] = Helpers::obfuscate_email( $user->user_email );
			}
		}

		$result = $provider->handshake( $payload );

		$has_token         = ! empty( $result['data']['token'] );
		$has_secret        = ! empty( $result['data']['secret'] );
		$redirect_url_value = isset( $result['data']['redirect_url'] ) ? $result['data']['redirect_url'] : ( isset( $result['data']['redirectUrl'] ) ? $result['data']['redirectUrl'] : '' );
		$has_redirect      = ! empty( $redirect_url_value );

		if ( $result['success'] && $has_token && $has_secret && $has_redirect ) {
			// Save session locally so callback can enforce is_pre_authenticated.
			$store = new \LLAR\Core\MfaFlow\SessionStore();
			// Single secret from MFA app (handshake response): used for verify and for send_code endpoint authorization.
			$store->save_send_email_secret( $result['data']['token'], $result['data']['secret'] );
			$state = wp_generate_password( 32, false, false );
			$remember_me = ! empty( $_REQUEST['rememberme'] );
			$session_username = ( $user && ! empty( $user->user_login ) ) ? $user->user_login : $username;
			$store->save_session(
				$result['data']['token'],
				$result['data']['secret'],
				$session_username,
				$user ? (int) $user->ID : 0,
				$redirect_to,
				$cancel_url,
				$provider_id,
				$is_pre_authenticated,
				$remember_me
			);
			$store->save_callback_state( $state, $result['data']['token'] );
			\LLAR\Core\MfaFlow\SessionStore::set_state_cookie( $state );
			$mfa_redirect_url = esc_url_raw( $redirect_url_value );
			if ( $mfa_redirect_url ) {
				self::mfa_redirect_to_url( $mfa_redirect_url );
				exit;
			}
		}

		// When external MFA API does not respond, behave as if MFA is absent (same mechanism as rescue; 1 min).
		if ( ! $result['success'] && ! empty( $result['server_unreachable'] ) ) {
			set_transient( MfaConstants::TRANSIENT_MFA_DISABLED, 'api_unreachable', 60 );
			return;
		}

		$rate['c'] = (int) $rate['c'] + 1;
		set_transient( $rate_key, $rate, $period );
	}

	/**
	 * Record one failed login attempt: Cloud lockout_check and/or local retries, lockout, notify.
	 * Used by limit_login_failed (wp_login_failed hook).
	 *
	 * @param string $username Login username.
	 */
	private function record_failed_login_attempt( $username ) {
		self::$failed_login_recorded_in_request = true;

		LoginFlowTransientStore::ensure_token();
		LoginFlowTransientStore::merge( array( 'login_attempts_left' => 0 ) );

		if ( self::$cloud_app && $response = self::$cloud_app->lockout_check( array(
				'ip'        => Helpers::get_all_ips(),
				'login'     => $username,
				'gateway'   => Helpers::detect_gateway()
			) ) ) {

			if ( $response['result'] === 'allow' ) {

				LoginFlowTransientStore::merge( array( 'login_attempts_left' => (int) $response['attempts_left'] ) );

			} elseif ( $response['result'] === 'deny' ) {

				global $limit_login_just_lockedout;
				$limit_login_just_lockedout = true;

				$err = __( '<strong>ERROR</strong>: Too many failed login attempts.', 'limit-login-attempts-reloaded' );

				$time_left = ( ! empty( $response['time_left'] ) ) ? $response['time_left'] : 0;

				if ( $time_left > 60 ) {

					$time_left = ceil( $time_left / 60 );
					$err .= ' ' . sprintf( _n( 'Please try again in %d hour.', 'Please try again in %d hours.', $time_left, 'limit-login-attempts-reloaded' ), $time_left );
				} else {
					$err .= ' ' . sprintf( _n( 'Please try again in %d minute.', 'Please try again in %d minutes.', $time_left, 'limit-login-attempts-reloaded' ), $time_left );
				}

				self::$cloud_app->add_error( $err );
				LoginFlowTransientStore::merge( array( 'errors_in_early_hook' => false ) );
			}

		} else {

			$ip = $this->get_address();

			/* if currently locked-out, do not add to retries */
			$lockouts = Config::get( Config::OPTION_LOCKOUTS );

			if ( ! is_array( $lockouts ) ) {
				$lockouts = array();
			}

			if ( isset( $lockouts[ $ip ] ) && time() < $lockouts[ $ip ] ) {
				return;
			}

			/* Get the arrays with retries and retries-valid information */
			$retries = Config::get( 'retries' );
			$valid   = Config::get( 'retries_valid' );
			$retries_stats = Config::get( 'retries_stats' );

			if ( ! is_array( $retries ) ) {

				$retries = array();
				Config::add( 'retries', $retries );
			}

			if ( ! is_array( $valid ) ) {

				$valid = array();
				Config::add( 'retries_valid', $valid );
			}

			if ( ! is_array( $retries_stats ) ) {

				$retries_stats = array();
				Config::add( 'retries_stats', $retries_stats );
			}

			$date_key = strtotime( date( 'Y-m-d H:00:00' ) );
			if ( ! empty( $retries_stats[ $date_key ] ) ) {

				$retries_stats[ $date_key ]++;
			} else {

				$retries_stats[ $date_key ] = 1;
			}
			$retries_stats = $this->prune_retries_stats_old_buckets( $retries_stats );
			Config::update( 'retries_stats', $retries_stats );
			DigestRetriesController::save_failed_attempt( $ip, $username );

			/* Check validity and add one to retries */
			if ( isset( $retries[ $ip ] ) && isset( $valid[ $ip ] ) && time() < $valid[ $ip ] ) {

				$retries[ $ip ] ++;
			} else {

				$retries[ $ip ] = 1;
			}
			$valid[ $ip ] = time() + Config::get( 'valid_duration' );

			/* lockout? */
			if ( $retries[ $ip ] % Config::get( 'allowed_retries' ) != 0 ) {
				/*
				* Not lockout (yet!)
				* Do housecleaning (which also saves retry/valid values).
				*/
				$this->cleanup( $retries, null, $valid );

				LoginFlowTransientStore::merge( array( 'login_attempts_left' => $this->calculate_retries_remaining() ) );

				return;
			}

			/* lockout! */
			$whitelisted = $this->is_ip_whitelisted( $ip );
			$retries_long = Config::get( 'allowed_retries' ) * Config::get( 'allowed_lockouts' );

			/*
			* Note that retries and statistics are still counted and notifications
			* done as usual for whitelisted ips , but no lockout is done.
			*/
			if ( $whitelisted ) {

				if ( $retries[ $ip ] >= $retries_long ) {

					unset( $retries[ $ip ] );
					unset( $valid[ $ip ] );
				}
			} else {

				global $limit_login_just_lockedout;
				$limit_login_just_lockedout = true;

				/* setup lockout, reset retries as needed */
				if ( ( isset($retries[ $ip ]) ? $retries[ $ip ] : 0 ) >= $retries_long ) {

					/* long lockout */
					$lockouts[ $ip ] = time() + Config::get( 'long_duration' );
					unset( $retries[ $ip ] );
					unset( $valid[ $ip ] );
				} else {

					/* normal lockout */
					$lockouts[ $ip ] = time() + Config::get( 'lockout_duration' );
				}

				DigestRetriesController::save_lockout( $ip );
			}

			/* do housecleaning and save values */
			$this->cleanup( $retries, $lockouts, $valid );

			/* do any notification */
			$this->notify( $username );

			/* increase statistics */
			$total = Config::get( 'lockouts_total' );
			if ( $total === false || ! is_numeric( $total ) ) {

				Config::add( 'lockouts_total', 1 );
			} else {

				Config::update( 'lockouts_total', $total + 1 );
			}
		}
	}

	/**
	 * Action when login attempt failed
	 *
	 * @param string $username Login username.
	 */
	public function limit_login_failed( $username ) {
		// @temporary WP 7.0 compat — prevent double-recording when late authenticate fallback already fired.
		// TODO: Remove after WP 7.1 release or when auth flow is stable.
		if ( self::$failed_login_recorded_in_request ) {
			return;
		}

		$this->record_failed_login_attempt( $username );
	}

	/**
	 * Handle notification in event of lockout
	 *
	 * @param $user
	 * @return bool|void
	 */
	public function notify( $user ) {

		if ( is_object( $user ) ) {
			return false;
		}

		$this->notify_log( $user );

		$args = explode( ',', Config::get( 'lockout_notify' ) );

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

		if ( in_array( 'email', $args ) ) {
			$this->notify_email( $user );
		}
	}

	/**
	 * Email notification of lockout to admin (if configured)
	 *
	 * @param $user
	 */
	public function notify_email( $user )
	{
		$ip = $this->get_address();
		$retries = Config::get( 'retries' );
		$notify_email_after = (int) Config::get( 'notify_email_after' );
		$notify_email_after = max( 1, $notify_email_after );

		if ( ! is_array( $retries ) ) {
			$retries = array();
		}

		/* check if we are at the right nr to do notification */
		if (
			isset( $retries[ $ip ] )
			&& 0 != ( (int) floor( (int) $retries[ $ip ] / Config::get( 'allowed_retries' ) ) % $notify_email_after )
		) {
			return;
		}

		/* Format message. First current lockout duration */
		if ( ! isset( $retries[ $ip ] ) ) {

			/* longer lockout */
			$count    = Config::get( 'allowed_retries' )
			            * Config::get( 'allowed_lockouts' );
			$lockouts = Config::get( 'allowed_lockouts' );
			$time     = round( Config::get( 'long_duration' ) / 3600 );
			$when     = sprintf( _n( '%d hour', '%d hours', $time, 'limit-login-attempts-reloaded' ), $time );
		} else {

			/* normal lockout */
			$count    = $retries[ $ip ];
			$lockouts = floor( ( $count ) / Config::get( 'allowed_retries' ) );
			$time     = round( Config::get( 'lockout_duration' ) / 60 );
			$when     = sprintf( _n( '%d minute', '%d minutes', $time, 'limit-login-attempts-reloaded' ), $time );
		}

		if ( $custom_admin_email = Config::get( 'admin_notify_email' ) ) {

			$admin_email = $custom_admin_email;
		} else {

			$admin_email = get_site_option( 'admin_email' );
		}

		$admin_name = '';

		global $wpdb;

		$res = $wpdb->get_col( $wpdb->prepare( "
                SELECT u.display_name
                FROM $wpdb->users AS u
                LEFT JOIN $wpdb->usermeta AS m ON u.ID = m.user_id
                WHERE u.user_email = %s
                AND m.meta_key LIKE 'wp_capabilities'
                AND m.meta_value LIKE '%administrator%'",
			$admin_email
		)
		);

		if ( $res ) {
			$admin_name = $res[0];
		}

		$site_domain = str_replace( array( 'http://', 'https://' ), '', home_url() );
		$blogname = Helpers::use_local_options() ? get_option( 'blogname' ) : get_site_option( 'site_name' );
		$blogname = htmlspecialchars_decode( $blogname, ENT_QUOTES );

		$plugin_data = get_plugin_data( LLA_PLUGIN_DIR . 'limit-login-attempts-reloaded.php' );

		$subject = sprintf(
			__( 'Failed login by IP %1$s %2$s', 'limit-login-attempts-reloaded' ),
			esc_html( $ip ),
			esc_html( $site_domain )
		);

		$unsubscribe_url = admin_url( 'options-general.php?page=' . $this->_options_page_slug . '&tab=settings' );
		$unsubscribe_footer_text = DigestDispatcher::build_unsubscribe_footer_text(
			array( 'unsubscribe_text' => LLA_DIGEST_DEFINITIONS['daily']['unsubscribe_text'] ),
			$unsubscribe_url
		);

		ob_start();
		include LLA_PLUGIN_DIR . 'views/emails/failed-login-content.php';
		$email_body = ob_get_clean();

		// get current url with the current page and the current query string
		$current_url_label = Helpers::get_current_url_label();
		$current_url = Helpers::get_current_url();

		$placeholders = array(
			'{name}'                => esc_html( (string) $admin_name ),
			'{domain}'              => esc_html( (string) $site_domain ),
			'{attempts_count}'      => (int) $count,
			'{lockouts_count}'      => (int) $lockouts,
			'{ip_address}'          => esc_html( $ip ),
			'{ip_address_link}'     => esc_url( 'https://www.limitloginattempts.com/location/?ip=' . $ip ),
			'{username}'            => esc_html( (string) $user ),
			'{blocked_duration}'    => esc_html( (string) $when ),
			'{dashboard_url}'       => admin_url( 'options-general.php?page=' . $this->_options_page_slug ),
			'{premium_url}'         => 'https://www.limitloginattempts.com/info.php?from=plugin-lockout-email&v=' . $plugin_data['Version'],
			'{llar_url}'            => 'https://www.limitloginattempts.com/?from=plugin-lockout-email&v=' . $plugin_data['Version'],
			'{current_url}'         => esc_url( $current_url ),
			'{current_url_label}'   => esc_html( (string) $current_url_label ),
		);

		$email_body = str_replace(
			array_keys( $placeholders ),
			array_values( $placeholders ),
			$email_body
		);

		Helpers::send_mail_with_logo( $admin_email, $subject, $email_body );
	}

	/**
	 * Logging of lockout (if configured)
	 *
	 * @param $user_login
	 *
	 * @internal param $user
	 */
	public function notify_log( $user_login )
	{

		if ( ! $user_login ) {
			return;
		}

		$log = $option = Config::get( Config::OPTION_LOGGED );

		if ( ! is_array( $log ) ) {
			$log = array();
		}
		$ip = $this->get_address();

		/* can be written much simpler, if you do not mind php warnings */
		if ( ! isset( $log[ $ip ] ) ) {
			$log[ $ip ] = array();
		}

		if ( ! isset( $log[ $ip ][ $user_login ] ) ) {

			$log[ $ip ][ $user_login ] = array( 'counter' => 0 );
		} elseif ( ! is_array( $log[ $ip ][ $user_login ] ) ) {

			$log[ $ip ][ $user_login ] = array( 'counter' => $log[ $ip ][ $user_login ] );
		}

		$log[ $ip ][ $user_login ]['counter']++;
		$log[ $ip ][ $user_login ]['date'] = time();

		$log[ $ip ][ $user_login ]['gateway'] = Helpers::detect_gateway();

		if ( $option === false ) {

			Config::add( 'logged', $log );
		} else {

			Config::update( Config::OPTION_LOGGED, $log );
		}
	}

	/**
	 * Check if IP is whitelisted.
	 *
	 * This function allow external ip whitelisting using a filter. Note that it can
	 * be called multiple times during the login process.
	 *
	 * Note that retries and statistics are still counted and notifications
	 * done as usual for whitelisted ips , but no lockout is done.
	 *
	 * Example:
	 * function my_ip_whitelist($allow, $ip) {
	 *    return ($ip == 'my-ip') ? true : $allow;
	 * }
	 * add_filter('limit_login_whitelist_ip', 'my_ip_whitelist', 10, 2);
	 *
	 * @param null $ip
	 *
	 * @return bool
	 */
	public function is_ip_whitelisted( $ip = null )
	{
		if ( is_null( $ip ) ) {
			$ip = $this->get_address();
		}

		$whitelisted = apply_filters( 'limit_login_whitelist_ip', false, $ip );

		return ( $whitelisted === true );
	}

	public function is_username_whitelisted( $username )
	{
		if ( empty( $username ) ) {
			return false;
		}

		$whitelisted = apply_filters( 'limit_login_whitelist_usernames', false, $username );

		return ( $whitelisted === true );
	}

	public function is_ip_blacklisted( $ip = null )
	{
		if ( is_null( $ip ) ) {
			$ip = $this->get_address();
		}

		$blacklisted = apply_filters( 'limit_login_blacklist_ip', false, $ip );

		return ( $blacklisted === true );
	}

	public function is_username_blacklisted( $username )
	{
		if ( empty( $username ) ) {
			return false;
		}

		$whitelisted = apply_filters( 'limit_login_blacklist_usernames', false, $username );

		return ( $whitelisted === true );
	}

	/**
	 * Filter: allow login attempt? (called from wp_authenticate())
	 *
	 * @param $user WP_User
	 * @param $password
	 *
	 * @return WP_Error|WP_User
	 */
	public function wp_authenticate_user( $user, $password )
	{
		$username = isset( $_REQUEST['log'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['log'] ) ) : '';
		if ( '' === $username && $this->integration_manager ) {
			$username = $this->integration_manager->get_login_identifier();
		}
		if ( empty( $password ) && $this->integration_manager ) {
			$integration_credentials = $this->integration_manager->get_login_credentials();
			if ( is_array( $integration_credentials ) && ! empty( $integration_credentials['password'] ) ) {
				$password = $integration_credentials['password'];
			}
		}
		$ip       = $this->get_address();
		$user_login = is_a( $user, 'WP_User' ) ? $user->user_login : ( ( ! empty( $user ) && ! is_wp_error( $user ) ) ? $user : '' );
		$not_locked_out = $this->check_whitelist_ips( false, $ip ) || $this->is_local_allowlisted_username( $username, $user ) || $this->check_whitelist_usernames( false, $user_login ) || $this->is_limit_login_ok( $username );

		if ( is_wp_error( $user ) ) {
			return $user;
		}

		// is_pre_authenticated must reflect actual password check: WP may pass valid $user by username before password is verified.
		$password_ok = false;
		if ( is_a( $user, 'WP_User' ) && ! empty( $password ) ) {
			$password_ok = wp_check_password( $password, $user->user_pass, $user->ID );
		}

		// If locked out, do not run MFA flow — return lockout error so blocked user cannot bypass via correct password + MFA.
		if ( ! $not_locked_out ) {
			$error = new WP_Error();
			global $limit_login_my_error_shown;
			$limit_login_my_error_shown = true;
			$error->add( 'too_many_retries', $this->error_msg( $username ) );
			LoginFlowTransientStore::merge( array( 'errors_in_early_hook' => false ) );
			return $error;
		}

		// Trigger MFA flow (for selected roles). Only treat as pre-authenticated if password was verified.
		if ( $username !== '' ) {
			$auth_user_for_mfa = ( $password_ok && is_a( $user, 'WP_User' ) ) ? $user : null;
			$this->try_mfa_flow_redirect( $username, $password_ok, $auth_user_for_mfa );
		}

		$user_login = '';

		if ( is_a( $user, 'WP_User' ) ) {

			$user_login = $user->user_login;
		} elseif( ! empty( $user ) && !is_wp_error( $user ) ) {

			$user_login = $user;
		}

		if (
			$this->check_whitelist_ips( false, $ip )
			|| $this->is_local_allowlisted_username( $username, $user )
			|| $this->check_whitelist_usernames( false, $user_login )
			|| $this->is_limit_login_ok( $username )
		) {
			return $user;
		}

		$error = new WP_Error();

		global $limit_login_my_error_shown;
		$limit_login_my_error_shown = true;

		if ( $this->is_username_blacklisted( $user_login ) || $this->is_ip_blacklisted( $this->get_address() ) ) {

			$err = __( '<strong>ERROR</strong>: Too many failed login attempts.', 'limit-login-attempts-reloaded' );
			$err = ! empty( $err ) ? '<span>' . $err . '</span>' : '';

			$error->add( 'username_blacklisted', $err );
			$this->all_errors_array['late_hook_errors'] = $err;
		} else {

			// This error should be the same as in "shake it" filter below
			$error->add( 'too_many_retries', $this->error_msg( $username ) );
		}

		LoginFlowTransientStore::merge( array( 'errors_in_early_hook' => false ) );

		return $error;
	}

	/**
	 * Determine if submitted login identifier maps to local allowed usernames.
	 *
	 * Supports direct username, case-insensitive username match and email-based login.
	 *
	 * @param string  $username Submitted login value (username or email).
	 * @param WP_User $user     Optional authenticated user object.
	 * @return bool
	 */
	private function is_local_allowlisted_username( $username, $user = null ) {
		$username = trim( (string) $username );
		if ( '' !== $username && $this->is_username_whitelisted( $username ) ) {
			return true;
		}

		if ( is_a( $user, 'WP_User' ) && ! empty( $user->user_login ) && $this->is_username_whitelisted( $user->user_login ) ) {
			return true;
		}

		if ( '' === $username || ! function_exists( 'is_email' ) || ! is_email( $username ) ) {
			return false;
		}

		$user_by_email = get_user_by( 'email', $username );
		if ( ! $user_by_email || ! is_a( $user_by_email, 'WP_User' ) ) {
			return false;
		}

		return $this->is_username_whitelisted( $user_by_email->user_login );
	}

	/**
	 * Filter: add this failure to login page "Shake it!"
	 *
	 * @param $error_codes
	 *
	 * @return array
	 */
	public function failure_shake( $error_codes )
	{
		$error_codes[] = 'too_many_retries';
		$error_codes[] = 'username_blacklisted';

		return $error_codes;
	}

	/**
	 * Keep track of if user or password are empty, to filter errors correctly
	 *
	 * @param $user
	 * @param $username
	 * @param $password
	 */
	public function track_credentials( $user, $username, $password )
	{
		global $limit_login_nonempty_credentials;

		$limit_login_nonempty_credentials = ( ! empty( $username ) && ! empty( $password ) );

		return $user;
	}

	/**
	 * Construct informative error message
	 *
	 * @param string $username Optional username from the auth hook.
	 * @return string
	 * @throws Exception
	 */
	public function error_msg( $username = '' )
	{
		if ( self::$cloud_app ) {
			$app_errors = self::$cloud_app->get_errors();
			if ( ! empty( $app_errors ) ) {
				$msg = is_array( $app_errors ) ? implode( ' ', $app_errors ) : (string) $app_errors;
				$this->all_errors_array['late_hook_errors'] = $msg;
				LoginFlowTransientStore::merge( array( 'errors_in_early_hook' => false ) );

				return $msg;
			}

			$resolved_username = $this->resolve_login_username( $username );
			if ( '' !== $resolved_username ) {
				$response = $this->get_auth_acl_response( $resolved_username );
				if ( $response && 'deny' === $response['result'] ) {
					$time_left = ! empty( $response['time_left'] ) ? (int) $response['time_left'] : 0;
					$msg       = wp_strip_all_tags( $this->build_lockout_error_message( $time_left ) );
					$this->all_errors_array['late_hook_errors'] = $msg;
					LoginFlowTransientStore::merge( array( 'errors_in_early_hook' => false ) );

					return $msg;
				}
			}
		}

		// Cloud is off or unreachable — fall back to the local lockouts timer so failover messages match the lockout state.

		$ip       = $this->get_address();
		$lockouts = Config::get( Config::OPTION_LOCKOUTS );
		$a        = $this->checkKey($lockouts, $ip);
		$b        = $this->checkKey($lockouts, $this->getHash($ip));

		$msg = __( '<strong>ERROR</strong>: Too many failed login attempts.', 'limit-login-attempts-reloaded' ) . ' ';

		if (
			! is_array( $lockouts )
			|| ( ! isset( $lockouts[ $ip ] ) && ! isset( $lockouts[ $this->getHash( $ip ) ] ) )
			|| ( time() >= $a && time() >= $b )
		){
			/* Huh? No timeout active? */
			$msg .= __( 'Please try again later.', 'limit-login-attempts-reloaded' );

			$this->all_errors_array['late_hook_errors'] = $msg;
			LoginFlowTransientStore::merge( array( 'errors_in_early_hook' => false ) );

			return $msg;
		}

		$when = ceil( ( ($a > $b ? $a : $b) - time() ) / 60 );
		if ( $when > 60 ) {

			$when = ceil( $when / 60 );
			$msg .= sprintf( _n( 'Please try again in %d hour.', 'Please try again in %d hours.', $when, 'limit-login-attempts-reloaded' ), $when );
		} else {

			$msg .= sprintf( _n( 'Please try again in %d minute.', 'Please try again in %d minutes.', $when, 'limit-login-attempts-reloaded' ), $when );
		}

		$this->all_errors_array['late_hook_errors'] = $msg;
		LoginFlowTransientStore::merge( array( 'errors_in_early_hook' => false ) );

		return $msg;
	}

	/**
	 * When returning from MFA with llar_mfa_error, inject an error so WordPress outputs the red #login_error block.
	 *
	 * @param \WP_Error $errors      WP_Error object passed to login_header().
	 * @param string   $redirect_to  Redirect URL.
	 * @return \WP_Error
	 */
	public function inject_mfa_return_login_error( $errors, $redirect_to ) {
		$llar_mfa_error = isset( $_GET['llar_mfa_error'] ) ? sanitize_text_field( wp_unslash( $_GET['llar_mfa_error'] ) ) : '';
		if ( $llar_mfa_error !== '' ) {
			if ( ! is_wp_error( $errors ) ) {
				$errors = new \WP_Error();
			}
			$errors->add( 'llar_mfa_return', __( '<strong>ERROR</strong>: Incorrect username or password.', 'limit-login-attempts-reloaded' ) );
		}
		return $errors;
	}

	/**
	 * Fix up the error message before showing it
	 *
	 * @param $content
	 *
	 * @return string
	 */
	public function fixup_error_messages( $content )
	{
		global $limit_login_just_lockedout, $limit_login_nonempty_credentials, $limit_login_my_error_shown;

		$error_msg = $this->get_message();

		$early_hook_msg = LoginFlowTransientStore::get( 'llar_early_hook_error_message', '' );
		if ( $early_hook_msg !== '' && is_string( $early_hook_msg ) ) {
			$content = $early_hook_msg;
			LoginFlowTransientStore::merge(
				array(
					'llar_early_hook_error_message' => null,
					'errors_in_early_hook'           => false,
				)
			);
		} else {
		$llar_mfa_error = isset( $_GET['llar_mfa_error'] ) ? sanitize_text_field( wp_unslash( $_GET['llar_mfa_error'] ) ) : '';
		$show_mfa_return_error = ( $llar_mfa_error !== '' );

		if ( $limit_login_nonempty_credentials ) {

			$content = '';

			if ( $this->other_login_errors ) {

				foreach ( $this->other_login_errors as $msg ) {
					$content .= ! empty( $msg ) ? $msg . '<br />' : '';
				}

			} else {

				/* Replace error message, including ours if necessary */
				if ( ! empty( $_REQUEST['log'] ) && is_email( $_REQUEST['log'] ) ) {

					$content = __( '<strong>ERROR</strong>: Incorrect email address or password.', 'limit-login-attempts-reloaded' );
				} else {

					$content = __( '<strong>ERROR</strong>: Incorrect username or password.', 'limit-login-attempts-reloaded' );
				}
			}
		} elseif ( $show_mfa_return_error ) {
			/* Same red error as failed login when returning from MFA (e.g. pre_auth_required). */
			$content = __( '<strong>ERROR</strong>: Incorrect username or password.', 'limit-login-attempts-reloaded' );
		}

		if ( ! empty( $error_msg ) ) {

			$content = $error_msg;
		}
		}

		$content = ! empty( $content ) ? '<span>' . $content . '</span>' : '';

		$this->all_errors_array['late_hook_errors'] = $content;
		LoginFlowTransientStore::merge( array( 'errors_in_early_hook' => false ) );

		return $content;
	}

	public function fixup_error_messages_wc( \WP_Error $error )
	{
		$error->add( 1, __( 'WC Error', 'limit-login-attempts-reloaded' ) );
	}

	/**
	 * Return current (error) message to show, if any
	 *
	 * @return string
	 */
	public function get_message()
	{

		if ( self::$cloud_app ) {

			$app_errors = self::$cloud_app->get_errors();
			return ! empty( $app_errors ) ? implode( '<br>', $app_errors ) : '';
		}

		/* Check external whitelist */
		if ( $this->is_ip_whitelisted() ) {
			return '';
		}

		/* Is lockout in effect? */
		if ( ! $this->is_limit_login_ok() ) {
			return $this->error_msg();
		}

		return '';
	}

	private function calculate_retries_remaining()
	{
		$remaining = 0;

		$ip      = $this->get_address();
		$retries = Config::get( 'retries' );
		$valid   = Config::get( 'retries_valid' );
		$a = $this->checkKey($retries, $ip);
		$b = $this->checkKey($retries, $this->getHash($ip));
		$c = $this->checkKey($valid, $ip);
		$d = $this->checkKey($valid, $this->getHash($ip));

		/* Should we show retries remaining? */
		if ( ! is_array( $retries ) || ! is_array( $valid ) ) {
			/* no retries at all */
			return $remaining;
		}
		if (
			( ! isset( $retries[ $ip ] ) && ! isset( $retries[ $this->getHash($ip) ] ))
			|| ( ! isset( $valid[ $ip ] ) && ! isset( $valid[ $this->getHash($ip) ] ))
			|| ( time() > $c && time() > $d )
		) {
			/* no: no valid retries */
			return $remaining;
		}
		if (
			( $a % Config::get( 'allowed_retries' ) ) == 0
			&& ( $b % Config::get( 'allowed_retries' ) ) == 0
		) {
			/* no: already been locked out for these retries */
			return $remaining;
		}

		$remaining = max( ( Config::get( 'allowed_retries' ) - ( ($a + $b) % Config::get( 'allowed_retries' ) ) ), 0 );
		return (int) $remaining;
	}

	/**
	 * Get correct remote address
	 *
	 * @return string
	 *
	 */
	public function get_address()
	{
		return Helpers::detect_ip_address( Config::get( 'trusted_ip_origins' ) );
	}


	/**
	 * Clean up old lockouts and retries, and save supplied arrays
	 *
	 * @param null $retries
	 * @param null $lockouts
	 * @param null $valid
	 */
	public function cleanup( $retries = null, $lockouts = null, $valid = null )
	{
		$now      = time();
		$lockouts = ! is_null( $lockouts ) ? $lockouts : Config::get( Config::OPTION_LOCKOUTS );

		$log = Config::get( Config::OPTION_LOGGED );

		/* remove old lockouts */
		if ( is_array( $lockouts ) ) {
			foreach ( $lockouts as $ip => $lockout ) {
				if ( $lockout < $now ) {
					unset( $lockouts[ $ip ] );

					if( is_array( $log ) && isset( $log[ $ip ] ) ) {
						foreach ( $log[ $ip ] as $user_login => &$data ) {

							if ( !is_array( $data ) ) {
								$data = array();
							}
							$data['unlocked'] = true;
						}
					}
				}
			}
			Config::update( Config::OPTION_LOCKOUTS, $lockouts );
		}

		Config::update( Config::OPTION_LOGGED, $log );

		/* remove retries that are no longer valid */
		$valid   = ! is_null( $valid ) ? $valid : Config::get( 'retries_valid' );
		$retries = ! is_null( $retries ) ? $retries : Config::get( 'retries' );

		if ( ! is_array( $valid ) || ! is_array( $retries ) ) {
			return;
		}

		foreach ( $valid as $ip => $lockout ) {

			if ( $lockout < $now ) {

				unset( $valid[ $ip ] );
				unset( $retries[ $ip ] );
			}
		}

		/* go through retries directly, if for some reason they've gone out of sync */
		foreach ( $retries as $ip => $retry ) {

			if ( ! isset( $valid[ $ip ] ) ) {
				unset( $retries[ $ip ] );
			}
		}

		$retries_stats = Config::get( 'retries_stats' );

		if ( $retries_stats ) {

			$stats_cutoff = strtotime( '-8 day' );
			foreach ( $retries_stats as $key => $count ) {

				if ( $this->is_retries_stats_bucket_expired( $key, $stats_cutoff ) ) {
					unset( $retries_stats[ $key ] );
				}
			}

			Config::update( 'retries_stats', $retries_stats );
		}

		Config::update( 'retries', $retries );
		Config::update( 'retries_valid', $valid );
	}

	/**
	 * Render admin options page
	 */
	public function options_page()
	{
		if ( ! empty( $_GET['tab'] ) && $_GET['tab'] === 'settings' ) {
			Config::use_local_options( ! is_network_admin() );
		}

		$this->cleanup();

		if ( ! empty( $_POST ) ) {

			check_admin_referer( 'limit-login-attempts-options' );

			if ( is_network_admin() ) {

				Config::update( 'allow_local_options', ! empty( $_POST['allow_local_options'] ) );
			} elseif ( Helpers::is_network_mode() ) {

				Config::update( 'use_local_options', empty( $_POST['use_global_options'] ) );
			}

			/* Should we clear log? */
			if ( isset( $_POST[ 'clear_log' ] ) ) {

				Config::update( Config::OPTION_LOGGED, array() );
				$this->show_message( __( 'Cleared IP log', 'limit-login-attempts-reloaded' ) );
			}

			/* Should we reset counter? */
			if ( isset( $_POST[ 'reset_total' ] ) ) {

				Config::update( 'lockouts_total', 0 );
				$this->show_message( __( 'Reset lockout count', 'limit-login-attempts-reloaded' ) );
			}

			/* Should we restore current lockouts? */
			if ( isset( $_POST[ 'reset_current' ] ) ) {

				Config::update( Config::OPTION_LOCKOUTS, array() );
				$this->show_message( __( 'Cleared current lockouts', 'limit-login-attempts-reloaded' ) );
			}

			/* Should we update options? */
			if ( isset( $_POST[ 'llar_update_dashboard' ] ) ) {

				$white_list_ips = ( ! empty( $_POST['lla_whitelist_ips'] ) ) ? explode("\n", str_replace("\r", "", stripslashes( $_POST['lla_whitelist_ips'] ) ) ) : array();

				if ( ! empty( $white_list_ips ) ) {

					foreach( $white_list_ips as $key => $ip ) {

						if( '' == $ip ) {
							unset( $white_list_ips[ $key ] );
						}
					}
				}

				Config::update('whitelist', $white_list_ips );

				$white_list_usernames = ( ! empty( $_POST['lla_whitelist_usernames'] ) ) ? explode("\n", str_replace("\r", "", stripslashes( $_POST['lla_whitelist_usernames'] ) ) ) : array();

				if ( ! empty( $white_list_usernames ) ) {

					foreach( $white_list_usernames as $key => $ip ) {

						if ( '' == $ip ) {

							unset( $white_list_usernames[ $key ] );
						}
					}
				}

				Config::update('whitelist_usernames', $white_list_usernames );

				$black_list_ips = ( ! empty( $_POST['lla_blacklist_ips'] ) ) ? explode("\n", str_replace("\r", "", stripslashes( $_POST['lla_blacklist_ips'] ) ) ) : array();

				if ( ! empty( $black_list_ips ) ) {

					foreach( $black_list_ips as $key => $ip ) {

						$range = array_map('trim', explode( '-', $ip ) );

						if ( count( $range ) > 1 && ( float )sprintf( "%u", ip2long( $range[0] ) ) > ( float )sprintf( "%u",ip2long( $range[1] ) ) ) {

							$this->show_message( sprintf ( __( 'The %s IP range is invalid', 'limit-login-attempts-reloaded' ), $ip ) );
						}

						if ( '' == $ip ) {

							unset( $black_list_ips[ $key ] );
						}
					}
				}

				Config::update('blacklist', $black_list_ips );

				$black_list_usernames = ( ! empty( $_POST['lla_blacklist_usernames'] ) ) ? explode("\n", str_replace("\r", "", stripslashes( $_POST['lla_blacklist_usernames'] ) ) ) : array();

				if ( ! empty( $black_list_usernames ) ) {

					foreach( $black_list_usernames as $key => $ip ) {

						if ( '' == $ip ) {
							unset( $black_list_usernames[ $key ] );
						}
					}
				}
				Config::update('blacklist_usernames', $black_list_usernames );

				Config::sanitize_options();

				$this->show_message( __( 'Settings saved.', 'limit-login-attempts-reloaded' ) );

			} elseif ( isset( $_POST[ 'llar_update_settings' ] ) ) {

				/* Should we support GDPR */
				if ( isset( $_POST[ 'gdpr' ] ) ) {

					Config::update( 'gdpr', 1 );
				} else {

					Config::update( 'gdpr', 0 );
				}

				Config::update('show_top_level_menu_item', ( isset( $_POST['show_top_level_menu_item'] ) ? 1 : 0 ) );
				Config::update('show_top_bar_menu_item', ( isset( $_POST['show_top_bar_menu_item'] ) ? 1 : 0 ) );
				Config::update('hide_dashboard_widget', ( isset( $_POST['hide_dashboard_widget'] ) ? 1 : 0 ) );
				Config::update('show_warning_badge', ( isset( $_POST['show_warning_badge'] ) ? 1 : 0 ) );

				Config::update('allowed_retries',           (int)$_POST['allowed_retries'] );
				Config::update('lockout_duration',    (int)$_POST['lockout_duration'] * 60 );
				Config::update('valid_duration',      (int)$_POST['valid_duration'] * 3600 );
				Config::update('allowed_lockouts',          (int)$_POST['allowed_lockouts'] );
				Config::update('long_duration',       (int)$_POST['long_duration'] * 3600 );
				Config::update('notify_email_after',        (int)$_POST['email_after'] );
				Config::update('gdpr_message',              sanitize_textarea_field( Helpers::deslash( $_POST['gdpr_message'] ) ) );
				Config::update('custom_error_message',      sanitize_textarea_field( Helpers::deslash( $_POST['custom_error_message'] ) ) );
				$admin_notify_email = isset( $_POST['admin_notify_email'] ) ? sanitize_email( wp_unslash( $_POST['admin_notify_email'] ) ) : '';
				if ( empty( $admin_notify_email ) ) {
					$this->show_message( __( 'Please enter a valid admin notification email.', 'limit-login-attempts-reloaded' ), true );
					$admin_notify_email = Config::get( 'admin_notify_email' );
				}
				Config::update('admin_notify_email',        $admin_notify_email );
				DigestUiController::save_settings_from_request();

				Config::update( Config::OPTION_ACTIVE_APP, sanitize_text_field( $_POST['active_app'] ) );

				$trusted_ip_origins = ( ! empty( $_POST['lla_trusted_ip_origins'] ) )
					? array_map( 'trim', explode( ',', sanitize_text_field( $_POST['lla_trusted_ip_origins'] ) ) )
					: array();

				if ( ! in_array( 'REMOTE_ADDR', $trusted_ip_origins ) ) {

					$trusted_ip_origins[] = 'REMOTE_ADDR';
				}

				Config::update('trusted_ip_origins', $trusted_ip_origins );

				$notify_methods = array();

				if ( isset( $_POST[ 'lockout_notify_email' ] ) ) {
					$notify_methods[] = 'email';
				}
				Config::update('lockout_notify', implode( ',', $notify_methods ) );

				Config::sanitize_options();

				if ( ! empty( $_POST['llar_app_settings'] ) && self::$cloud_app ) {

					if ( ( $app_setup_code = Config::get( 'app_setup_code' ) ) && $setup_result = CloudApp::setup( strrev( $app_setup_code ) ) ) {

						if ( $setup_result['success'] && $active_app_config = $setup_result['app_config'] ) {

							foreach ( $_POST['llar_app_settings'] as $key => $value ) {

								if ( array_key_exists( $key, $active_app_config['settings'] ) ) {

									if ( ! empty( $active_app_config['settings'][$key]['options'] ) &&
									     ! in_array( $value, $active_app_config['settings'][$key]['options'] ) ) {

										continue;
									}

									$active_app_config['settings'][$key]['value'] = $value;
								}
							}

							Config::update( 'app_config', $active_app_config );
						}
					}
				}
				$this->show_message( __( 'Settings saved.', 'limit-login-attempts-reloaded' ) );
				$this->cloud_app_init();
			} elseif ( isset( $_POST['llar_update_mfa_settings'] ) ) {
				// Handle MFA settings submission via controller (capability checked inside)
				if ( $this->mfa_controller ) {
					$show_popup = $this->mfa_controller->handle_settings_submission();
					if ( ! $show_popup ) {
						$this->show_message( __( 'Settings saved.', 'limit-login-attempts-reloaded' ) );
					}
				}
			}
		}

		// Prepare roles data for MFA tab (before including view to ensure data is ready)
		// Check if we're on MFA tab (GET or POST with tab parameter, or default after form submit)
		$current_tab = 'settings';
		if ( isset( $_GET['tab'] ) && in_array( $_GET['tab'], self::$allowed_tabs ) ) {
			$current_tab = sanitize_text_field( $_GET['tab'] );
		} elseif ( isset( $_POST['llar_update_mfa_settings'] ) ) {
			// After MFA form submit, we're still on MFA tab
			$current_tab = 'mfa';
		}

		$lockout_notify_items = explode( ',', (string) Config::get( 'lockout_notify' ) );
		$email_checked = in_array( 'email', $lockout_notify_items, true );
		$digest_notification_checkboxes = DigestUiController::get_notification_checkboxes();

		// MFA tab data comes from get_settings_for_view() (single source in MfaSettingsManager)
		include_once LLA_PLUGIN_DIR . 'views/options-page.php';
	}

	/**
	 * Render an admin notice view by key (e.g. 'auto-update', 'mfa-no-ssl').
	 *
	 * @param string $notice_key Notice identifier.
	 * @param array  $args       Variables to pass to the notice view.
	 * @return void
	 */
	public function render_admin_notice( $notice_key, array $args = array() ) {
		if ( null === $this->admin_notices_controller ) {
			$this->admin_notices_controller = new AdminNoticesController();
		}
		$this->admin_notices_controller->render( $notice_key, $args );
	}

	/**
	 * Show warning when MFA is enabled and rescue links need attention: no rescue payload transients,
	 * or latest payload expiry is within RESCUE_NOTICE_THRESHOLD. Uses a short-lived cache for the
	 * max-expiry query to avoid scanning wp_options on every admin page load.
	 *
	 * @return bool
	 */
	public function should_show_mfa_recovery_links_expired_notice() {
		if ( ! (bool) Config::get( 'mfa_enabled' ) ) {
			return false;
		}

		$seconds_left = $this->mfa_controller->get_rescue_links_seconds_left();
		if ( null === $seconds_left ) {
			return true;
		}

		return $seconds_left <= MfaConstants::RESCUE_NOTICE_THRESHOLD;
	}

	/**
	 * Return non-LLAR callbacks attached to authenticate filter.
	 *
	 * When the owning plugin cannot be resolved (empty plugin metadata), we still want to warn
	 * if the hook runs at an unusual priority (often security plugins use very early/late priorities).
	 * Callbacks with unknown origin but a typical priority are skipped to avoid noisy Debug notices:
	 * many benign cases (themes, mu-plugins, shared vendor paths) sit in the same numeric range as
	 * core and common plugins. Filter hooks below can widen the "normal" window if needed.
	 *
	 * @return array
	 */
	public static function get_foreign_authenticate_hooks() {
		global $wp_filter;

		if ( empty( $wp_filter['authenticate'] ) || ! is_object( $wp_filter['authenticate'] ) || ! isset( $wp_filter['authenticate']->callbacks ) ) {
			return array();
		}

		$allowed_callbacks = array(
			'wp_authenticate_username_password',
			'wp_authenticate_email_password',
			'wp_authenticate_spam_check',
		);

		$foreign = array();
		foreach ( $wp_filter['authenticate']->callbacks as $priority => $callbacks ) {
			if ( ! is_array( $callbacks ) ) {
				continue;
			}

			foreach ( $callbacks as $callback_data ) {
				if ( empty( $callback_data['function'] ) ) {
					continue;
				}

				$callback_name = self::normalize_hook_callback_name( $callback_data['function'] );
				if ( '' === $callback_name ) {
					continue;
				}

				$is_llar = ( 0 === strpos( $callback_name, __CLASS__ . '::' ) );
				if ( $is_llar || in_array( $callback_name, $allowed_callbacks, true ) ) {
					continue;
				}

				$plugin_meta = self::detect_plugin_for_hook_callback( $callback_data['function'] );
				$hook_priority = (int) $priority;
				// Unknown source + normal-looking priority: omit from the list (see docblock on this method).
				if ( empty( $plugin_meta ) && ! self::is_anomalous_authenticate_priority( $hook_priority ) ) {
					continue;
				}

				$foreign[] = array(
					'priority'      => $hook_priority,
					'callback'      => $callback_name,
					'accepted_args' => isset( $callback_data['accepted_args'] ) ? (int) $callback_data['accepted_args'] : 0,
					'plugin'        => $plugin_meta,
				);
			}
		}

		return $foreign;
	}

	/**
	 * Whether authenticate hook priority is unusual enough to surface unknown callbacks.
	 *
	 * Default "normal" band is [-10000, 999]: covers LLAR early hooks, core (e.g. 20), and typical
	 * third-party priorities. Outside that band we treat priority as anomalous and list the callback
	 * even when plugin metadata is missing. The optional filter can force anomalous for edge cases
	 * inside the band.
	 *
	 * @param int $priority Hook priority.
	 * @return bool
	 */
	private static function is_anomalous_authenticate_priority( $priority ) {
		$priority = (int) $priority;
		$min = (int) apply_filters( 'llar_foreign_auth_hook_normal_priority_min', -10000 );
		$max = (int) apply_filters( 'llar_foreign_auth_hook_normal_priority_max', 999 );
		if ( $priority < $min || $priority > $max ) {
			return true;
		}

		// Allow hosts to flag specific in-band priorities as worth showing without plugin resolution.
		return (bool) apply_filters( 'llar_foreign_auth_hook_force_anomalous_priority', false, $priority );
	}

	/**
	 * Convert callback to stable printable format.
	 *
	 * @param mixed $callback
	 * @return string
	 */
	private static function normalize_hook_callback_name( $callback ) {
		if ( is_string( $callback ) ) {
			return $callback;
		}

		if ( is_array( $callback ) && 2 === count( $callback ) ) {
			if ( is_object( $callback[0] ) ) {
				return get_class( $callback[0] ) . '::' . $callback[1];
			}
			if ( is_string( $callback[0] ) ) {
				return $callback[0] . '::' . $callback[1];
			}
		}

		if ( $callback instanceof \Closure ) {
			return 'Closure';
		}

		return '';
	}

	/**
	 * Resolve plugin metadata for callback, if callback comes from plugin file.
	 *
	 * @param mixed $callback
	 * @return array
	 */
	private static function detect_plugin_for_hook_callback( $callback ) {
		$source_file = self::get_hook_callback_source_file_cached( $callback );
		if ( '' === $source_file ) {
			return array();
		}

		$source_file = wp_normalize_path( $source_file );
		if ( isset( self::$hook_source_file_plugin_cache[ $source_file ] ) ) {
			return self::$hook_source_file_plugin_cache[ $source_file ];
		}

		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$plugins = get_plugins();
		$plugins_dir = trailingslashit( wp_normalize_path( WP_PLUGIN_DIR ) );

		if ( 0 !== strpos( $source_file, $plugins_dir ) ) {
			self::$hook_source_file_plugin_cache[ $source_file ] = array();

			return array();
		}

		$relative_file = ltrim( substr( $source_file, strlen( $plugins_dir ) ), '/' );
		$result = array();

		foreach ( $plugins as $plugin_file => $plugin_data ) {
			$plugin_file = wp_normalize_path( $plugin_file );
			$plugin_dir = dirname( $plugin_file );
			$is_main_file = ( $relative_file === $plugin_file );
			$is_inside_plugin_dir = ( '.' !== $plugin_dir && 0 === strpos( $relative_file, trailingslashit( $plugin_dir ) ) );

			if ( ! $is_main_file && ! $is_inside_plugin_dir ) {
				continue;
			}

			$slug = explode( '/', $plugin_file );
			$slug = sanitize_key( $slug[0] );

			$result = array(
				'slug'    => $slug,
				'name'    => isset( $plugin_data['Name'] ) ? $plugin_data['Name'] : '',
				'version' => isset( $plugin_data['Version'] ) ? $plugin_data['Version'] : '',
				'file'    => $plugin_file,
			);
			break;
		}

		self::$hook_source_file_plugin_cache[ $source_file ] = $result;

		return $result;
	}

	/**
	 * Stable cache key for a hook callback (reflection is expensive per callback).
	 *
	 * @param mixed $callback Callback from WP_Hook.
	 * @return string
	 */
	private static function get_hook_callback_cache_key( $callback ) {
		if ( is_string( $callback ) ) {
			return 's:' . $callback;
		}

		if ( is_array( $callback ) && 2 === count( $callback ) ) {
			if ( is_object( $callback[0] ) ) {
				return 'o:' . spl_object_hash( $callback[0] ) . ':' . (string) $callback[1];
			}
			if ( is_string( $callback[0] ) ) {
				return 'c:' . $callback[0] . '::' . (string) $callback[1];
			}
		}

		if ( $callback instanceof \Closure ) {
			return 'f:' . spl_object_hash( $callback );
		}

		return 'u:' . md5( serialize( $callback ) );
	}

	/**
	 * Return source file for callback, with per-request cache.
	 *
	 * @param mixed $callback
	 * @return string
	 */
	private static function get_hook_callback_source_file_cached( $callback ) {
		$key = self::get_hook_callback_cache_key( $callback );
		if ( isset( self::$hook_callback_source_file_cache[ $key ] ) ) {
			return self::$hook_callback_source_file_cache[ $key ];
		}

		$file = self::get_hook_callback_source_file( $callback );
		self::$hook_callback_source_file_cache[ $key ] = $file;

		return $file;
	}

	/**
	 * Get callback source file path using reflection.
	 *
	 * @param mixed $callback
	 * @return string
	 */
	private static function get_hook_callback_source_file( $callback ) {
		try {
			if ( is_string( $callback ) && function_exists( $callback ) ) {
				$reflection = new \ReflectionFunction( $callback );
				return (string) $reflection->getFileName();
			}

			if ( is_array( $callback ) && 2 === count( $callback ) ) {
				$object_or_class = $callback[0];
				$method = $callback[1];

				if ( is_object( $object_or_class ) && method_exists( $object_or_class, $method ) ) {
					$reflection = new \ReflectionMethod( $object_or_class, $method );
					return (string) $reflection->getFileName();
				}

				if ( is_string( $object_or_class ) && method_exists( $object_or_class, $method ) ) {
					$reflection = new \ReflectionMethod( $object_or_class, $method );
					return (string) $reflection->getFileName();
				}
			}

			if ( $callback instanceof \Closure ) {
				$reflection = new \ReflectionFunction( $callback );
				return (string) $reflection->getFileName();
			}
		} catch ( \Exception $e ) {
			return '';
		}

		return '';
	}

	/**
	 * Show error message
	 *
	 * @param $msg
	 * @param bool $is_error
	 */
	public function show_message( $msg, $is_error = false ) {
		$this->pending_admin_message = array(
			'msg'      => $msg,
			'is_error' => $is_error,
		);
	}

	/**
	 * returns IP with its md5 value
	 */
	private function getHash( $str )
	{
		return md5( $str );
	}

	/**
	 * @param $arr - array
	 * @param $k - key
	 * @return int array value at given index or zero
	 */
	private function checkKey( $arr, $k )
	{
		return isset( $arr[ $k ] ) ? $arr[ $k ] : 0;
	}


	private function plan_name_match( $plan = 'default' )
	{
		if ( ! array_key_exists( $plan, $this->plans ) ) {
			$plan = 'default';
		}

		return $this->plans[ $plan ]['name'];
	}


	public function array_name_plans()
	{
		$plans = [];

		foreach ( $this->plans as $plan ) {

			$plans[ $plan['name'] ] = $plan['rate'];
		}

		return $plans;
	}

	private function info()
	{
		if ( self::$cloud_app ) {
			$this->info_data = self::$cloud_app->info();
		}

		return $this->info_data;
	}

	public function info_is_exhausted()
	{
		if ( empty( $this->info_data ) ) {

			$this->info_data = $this->info();
		}

		return isset( $this->info_data['requests']['exhausted'] ) ? filter_var( $this->info_data['requests']['exhausted'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ) : false;
	}

	/**
	 * Whether /info returned usable quota and plan data for the dashboard UI.
	 *
	 * @return bool
	 */
	public function info_has_valid_data()
	{
		if ( empty( $this->info_data ) ) {
			$this->info_data = $this->info();
		}

		if ( empty( $this->info_data ) || ! is_array( $this->info_data ) ) {
			return false;
		}

		if ( empty( $this->info_data['requests'] ) || ! is_array( $this->info_data['requests'] ) ) {
			return false;
		}

		return array_key_exists( 'quota', $this->info_data['requests'] )
			&& '' !== (string) $this->info_data['requests']['quota'];
	}

	/**
	 * Cloud API responded to /info but access is denied (e.g. quota exhausted or unpaid domain).
	 *
	 * @return bool
	 */
	public function info_is_cloud_unavailable()
	{
		if ( ! self::$cloud_app ) {
			return false;
		}

		if ( $this->info_has_valid_data() ) {
			return false;
		}

		return ! self::$cloud_app->is_info_network_failure();
	}


	public function info_requests()
	{
		if ( empty( $this->info_data ) ) {

			$this->info_data = $this->info();
		}

		return ( ! empty( $this->info_data ) && ! empty( $this->info_data['requests'] ) ) ? $this->info_data['requests'] : '';
	}


	public function info_sub_group()
	{
		if ( empty( $this->info_data ) ) {

			$this->info_data = $this->info();
		}

		$data = ( ! empty( $this->info_data ) && ! empty( $this->info_data['sub_group'] ) ) ? $this->info_data['sub_group'] : '';

		return $this->plan_name_match( $data );
	}


	public function info_upgrade_url()
	{
		if ( empty( $this->info_data ) ) {

			$this->info_data = $this->info();
		}

		return ( ! empty( $this->info_data ) && ! empty( $this->info_data['upgrade_url'] ) ) ? $this->info_data['upgrade_url'] : '';
	}


	public function info_block_by_country()
	{
		if ( empty( $this->info_data ) ) {

			$this->info_data = $this->info();
		}

		return ( ! empty( $this->info_data ) && ! empty( $this->info_data['block_by_country'] ) ) ? $this->info_data['block_by_country'] : '';
	}


	public function show_leave_review_notice()
	{
		$screen = get_current_screen();

		if ( isset( $_COOKIE['llar_review_notice_shown'] ) ) {

			Config::update( 'review_notice_shown', true );
			@setcookie( 'llar_review_notice_shown', '', time() - 3600, '/' );
		}

		if (
			! $this->has_capability
			|| Config::get( 'review_notice_shown' )
			|| ! in_array( $screen->base, array( 'dashboard', 'plugins', 'toplevel_page_limit-login-attempts' ) )
		) {
			return;
		}

		$activation_timestamp = Config::get( 'activation_timestamp' );

		if ( $activation_timestamp && $activation_timestamp < strtotime("-1 month") ) : ?>

            <div id="message" class="updated fade notice is-dismissible llar-notice-review">
                <div class="llar-review-image">
                    <img width="80px" src="<?php echo LLA_PLUGIN_URL?>assets/img/icon-256x256.png" alt="review-logo">
                </div>
                <div class="llar-review-info">
                    <p><?php _e('Hey <strong>Limit Login Attempts Reloaded</strong> user!', 'limit-login-attempts-reloaded'); ?></p>
                    <!--<p><?php _e('A <strong>crazy idea</strong> we wanted to share! What if we put an image from YOU on the <a href="https://wordpress.org/plugins/limit-login-attempts-reloaded/" target="_blank">LLAR page</a>?! (<a href="https://wordpress.org/plugins/hello-dolly/" target="_blank">example</a>) A drawing made by you or your child would cheer people up! Send us your drawing by <a href="mailto:wpchef.me@gmail.com" target="_blank">email</a> and we like it, we\'ll add it in the next release. Let\'s have some fun!', 'limit-login-attempts-reloaded'); ?></p> Also, -->
                    <p><?php _e('We would really like to hear your feedback about the plugin! Please take a couple minutes to write a few words <a href="https://wordpress.org/support/plugin/limit-login-attempts-reloaded/reviews/#new-post" target="_blank">here</a>. Thank you!', 'limit-login-attempts-reloaded'); ?></p>

                    <ul class="llar-buttons">
                        <li><a href="#" class="llar-review-dismiss" data-type="dismiss"><?php _e('Don\'t show again', 'limit-login-attempts-reloaded'); ?></a></li>
                        <li><i class=""></i><a href="#" class="llar-review-dismiss llar_button menu__item button__transparent_orange" data-type="later"><?php _e('Maybe later', 'limit-login-attempts-reloaded'); ?></a></li>
                        <li><a class="llar_button menu__item button__transparent_orange" target="_blank" href="https://wordpress.org/support/plugin/limit-login-attempts-reloaded/reviews/#new-post"><?php _e('Leave a review', 'limit-login-attempts-reloaded'); ?></a></li>
                    </ul>
                </div>
            </div>
            <script type="text/javascript">
                ( function( $ ){

                    $( document ).ready( function() {
                        $( '.llar-review-dismiss' ).on( 'click', function( e ) {
                            e.preventDefault();

                            const type = $( this ).data( 'type' );

                            $.post( ajaxurl, {
                                action: 'dismiss_review_notice',
                                type: type,
                                sec: '<?php echo wp_create_nonce( "llar-dismiss-review" ); ?>'
                            } );

                            $( this ).closest( '.llar-notice-review' ).remove();
                        } );

                        $( ".llar-notice-review" ).on( "click", ".notice-dismiss", function (event) {
                            createCookie( 'llar_review_notice_shown', '1', 30 );
                        } );

                        function createCookie( name, value, days ) {
                            let expires;

                            if ( days ) {
                                const date = new Date();
                                date.setTime( date.getTime() + (days * 24 * 60 * 60 * 1000 ) );
                                expires = "; expires=" + date.toGMTString();
                            } else {
                                expires = "";
                            }
                            document.cookie = encodeURIComponent( name ) + "=" + encodeURIComponent( value ) + expires + "; path=/";
                        }
                    } );

                } )(jQuery);
            </script>
		<?php endif;
	}

	public function show_enable_notify_notice()
	{
		$screen = get_current_screen();

		if ( isset( $_COOKIE['llar_enable_notify_notice_shown'] ) ) {

			Config::update( 'enable_notify_notice_shown', true );
			@setcookie( 'llar_enable_notify_notice_shown', '', time() - 3600, '/' );
		}

		$active_app = Config::get( Config::OPTION_ACTIVE_APP );
		$notify_methods = explode( ',', Config::get( 'lockout_notify' ) );

		if (
			$active_app !== 'local'
			|| in_array( 'email', $notify_methods )
			|| ! $this->has_capability
			|| Config::get('enable_notify_notice_shown')
			|| $screen->parent_base === 'edit'
		) {

			return;
		}

		$activation_timestamp = Config::get('notice_enable_notify_timestamp');

		if ( $activation_timestamp && $activation_timestamp < strtotime("-1 month") ) {

			$review_activation_timestamp = Config::get('activation_timestamp');

			if ( $review_activation_timestamp && $review_activation_timestamp < strtotime("-1 month") ) {
				Config::update( 'activation_timestamp', time() );
			}

			?>

            <div id="message" class="updated fade notice is-dismissible llar-notice-notify">
                <div class="llar-review-image">
                    <span class="dashicons dashicons-warning"></span>
                </div>
                <div class="llar-review-info">
                    <p><?php _e('You have been upgraded to the latest version of <strong>Limit Login Attempts Reloaded</strong>.<br> ' .
					            'Due to increased security threats around the holidays, we recommend turning on email ' .
					            'notifications when you receive a failed login attempt.', 'limit-login-attempts-reloaded'); ?></p>

                    <ul class="llar-buttons">
                        <li><a class="button button-primary llar-ajax-enable-notify" target="_blank" href="#"><?php _e('Yes, turn on email notifications', 'limit-login-attempts-reloaded'); ?></a></li>
                        <li><a href="#" class="llar-notify-notice-dismiss button" data-type="later"><?php _e('Remind me a month from now', 'limit-login-attempts-reloaded'); ?></a></li>
                        <li><a href="#" class="llar-notify-notice-dismiss" data-type="dismiss"><?php _e('Don\'t show this message again', 'limit-login-attempts-reloaded'); ?></a></li>
                    </ul>
                </div>
            </div>
            <script type="text/javascript">
                ( function( $ ) {

                    $( document ).ready( function() {
                        $( '.llar-notify-notice-dismiss' ).on( 'click', function( e ) {
                            e.preventDefault();

                            const type = $( this ).data( 'type' );

                            $.post( ajaxurl, {
                                action: 'dismiss_notify_notice',
                                type: type,
                                sec: '<?php echo wp_create_nonce( "llar-dismiss-notify-notice" ); ?>'
                            } );

                            $( this ).closest( '.llar-notice-notify' ).remove();
                        } );

                        $( ".llar-notice-notify" ).on( "click", ".notice-dismiss", function ( e ) {
                            createCookie( 'llar_enable_notify_notice_shown', '1', 30 );
                        } );

                        $( ".llar-ajax-enable-notify" ).on( "click", function ( e ) {
                            e.preventDefault();

                            $.post( ajaxurl, {
                                action: 'enable_notify',
                                sec: '<?php echo wp_create_nonce( "llar-enable-notify" ); ?>'
                            }, function( response ){

                                if ( response.success ) {
                                    $( ".llar-notice-notify .llar-review-info p" ).text( 'You are all set!' );
                                    $( ".llar-notice-notify .llar-buttons" ).remove();
                                }

                            } );
                        } );

                        function createCookie( name, value, days ) {
                            let expires;

                            if ( days ) {
                                const date = new Date();
                                date.setTime( date.getTime() + ( days * 24 * 60 * 60 * 1000 ) );
                                expires = "; expires=" + date.toGMTString();
                            } else {
                                expires = "";
                            }
                            document.cookie = encodeURIComponent( name ) + "=" + encodeURIComponent( value ) + expires + "; path=/";
                        }
                    } );

                } )(jQuery);
            </script>
			<?php
		}
	}


	/**
	 * Check if the user is a cloud user and if limit_registration is enabled
	 * @return bool
	 */
	private function is_limit_registration()
	{
		if ( ! self::$cloud_app ) {
			return false;
		}

		$app_config = Config::get( 'app_config' );
		$limit_registration = isset( $app_config['settings']['limit_registration']['value'] ) ? $app_config['settings']['limit_registration']['value'] : '';

		return $limit_registration === 'on';
	}


	/**
	 * API response
	 * @param $user_data
	 *
	 * @return bool|mixed
	 * @throws Exception
	 */
	private function llar_api_response( $user_data )
	{
		return self::$cloud_app->acl_check( array(
			'ip'        => Helpers::get_all_ips(),
			'login'     => $user_data,
			'gateway'   => Helpers::detect_gateway(),
		) );
	}

	/**
	 * Public wrapper for llar_api_response to allow integrations to use it
	 * Only allows calls from integration classes within this plugin
	 *
	 * @param string $user_data User data to check
	 * @param BaseIntegration|null $integration Integration instance (optional, for security validation)
	 * @return array API response
	 */
	public function check_registration_api( $user_data, $integration = null ) {
		// This method allows integrations to check registration via API. 
		// Only trusted integration classes may call it.
		if ( null !== $integration && $integration instanceof BaseIntegration ) {
			// Additional security check: verify the class is in the correct namespace
			// This prevents external code from extending BaseIntegration and calling this method
			$integration_class = get_class( $integration );
			$expected_namespace = 'LLAR\Core\Integrations\\';

			// Check if the class is in the expected namespace
			if ( strpos( $integration_class, $expected_namespace ) !== 0 ) {
				// Class is not in the trusted namespace, deny the request
				if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
					error_log( sprintf( 'LLAR: Security check failed - integration class %s is not in trusted namespace', $integration_class ) );
				}
				return array( 'result' => 'deny' );
			}

			$response = $this->llar_api_response( $user_data );

			return $response;
		}

		// If no integration object is provided, deny the request
		return array( 'result' => 'deny' );

	}


	/**
	 * Register new user standard WP
	 */
	public function llar_submit_login_form_register()
	{
		if ( ! $this->is_limit_registration() ) {
			return;
		}

		if ( empty( $_POST['user_login'] ) && empty( $_POST['user_email'] ) ) {
			return;
		}

		$user_login = $_POST['user_login'];
		$user_email = $_POST['user_email'];

		// Only if both fields are empty we exit the check
		if ( ( empty( $user_login ) || ! validate_username( $user_login ) )  && ( empty( $user_email ) || ! is_email( $user_email ) ) ) {
			return;
		}

		$user_login_sanitize = sanitize_user( $_POST['user_login'] );
		$user_email_sanitize = sanitize_email( $_POST['user_email'] );

		// Check any non-empty
		$check_combo = ! empty( $user_login_sanitize ) ? $user_login_sanitize : $user_email_sanitize;

		$response = $this->llar_api_response( $check_combo );

		// If $user_login is not empty, we will also check $user_email
		if ( ! empty( $user_login_sanitize ) && $response['result'] !== 'deny' ) {

			if ( empty( $user_email ) || ! is_email( $user_email ) ) {
				return;
			}

			$response = $this->llar_api_response( $user_email_sanitize );
		}

		if ( $response['result'] === 'deny' ) {

			// Set variables to empty to prevent Wordpress from accessing the database
			$_POST['user_login'] = '';
			$_POST['user_email'] = '';

			// Set the marker and the error
			$this->user_blocking = true;
			$this->error_messages = __( 'Registration is currently disabled.', 'limit-login-attempts-reloaded' );
		}
	}


	/**
	 * Correcting errors in the presence of a registration prohibition marker
	 * @param $errors
	 * @param $sanitized_user_login
	 * @param $user_email
	 *
	 * @return mixed
	 */
	public function llar_submit_registration_errors( $errors, $sanitized_user_login, $user_email )
	{
		// Checking the marker and the presence of empty variables
		if ( $this->user_blocking && ( empty( $sanitized_user_login ) && empty( $user_email ) ) ) {
			$errors->remove('empty_username');
			$errors->remove('empty_email');
			$errors->add( 'user_blocking', $this->error_messages );
		}

		return $errors;
	}
}

