<?php
/**
 * Admin Login Notification Module
 *
 * Sends email when an administrator establishes a dashboard session via the WordPress login flow.
 *
 * @package LoginEmailNotification
 */

namespace LoginEmailNotification;

use WP_PowerSuite\Core\AbstractModule;
use WP_PowerSuite\Core\TransientPurger;
use WP_PowerSuite\Email\EmailComponents;
use WP_PowerSuite\Email\EmailDesign;
use WP_PowerSuite\Email\EmailRenderer;

class Module extends AbstractModule {

	private const MODULE_ID          = 'login-email-notification';
	private const MODULE_VERSION     = '1.2.0';
	private const MAX_EXCLUDE_IPS    = 100;
	private const MAX_PATTERN_LENGTH = 100;
	private const MAX_RECIPIENTS     = 20;
	private const GEO_TRANSIENT_PREFIX = 'wpps_aln_geo_';

	private const GEO_KEYS_OPTION = 'wpps_aln_geo_keys';

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

	/** Cached template content (file read once per request). */
	private static ?string $cached_template = null;

	/** Exactly one outbound alert per PHP request when multiple hooks fire the same cookie login. */
	private static bool $notification_sent_this_request = false;

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

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

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

	public function getDescription(): string {
		return __( 'Notifies you by email when an administrator logs in—useful for spotting unfamiliar access.', 'wp-powersuite' );
	}

	/**
	 * Login runs on frontend/login and 2FA AJAX — not on generic wp-admin screens.
	 *
	 * @return list<string>|string
	 */
	public function getContext(): string|array {
		return [ 'frontend', 'ajax', 'login' ];
	}

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

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

	/**
	 * Abort admin or AJAX handlers when the premium license is inactive.
	 */
	private function denyUnlicensedRequest( bool $ajax = false ): void {
		$message = __( 'Admin Login Alerts requires an active license.', 'wp-powersuite' );

		if ( $ajax ) {
			wp_send_json_error(
				[
					'message'      => $message,
					'code'         => 'license_required',
					'license_page' => admin_url( 'admin.php?page=powersuite-license' ),
				]
			);
		}

		wp_die(
			esc_html( $message ),
			esc_html__( 'License required', 'wp-powersuite' ),
			[ 'response' => 403, 'back_link' => true ]
		);
	}

	/**
	 * @return array<string, mixed>
	 */
	public function getDefaultSettings(): array {
		return [
			'emails'                   => '',
			'exclude_ips'              => '',
			'geo_lookup_enabled'       => 0,
			'include_user_agent'       => 1,
			'ip_header'                => '',
			'notify_alternate_logins'  => 1,
			'strict_login_detection'   => 0,
			'throttle_minutes'         => 5,
		];
	}

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

		add_action( 'wp_login', [ $this, 'onWpLogin' ], 99, 2 );
		add_action( 'wp_powersuite_magic_login_authenticated', [ $this, 'onAlternateAuthenticated' ], 10, 1 );
		add_action( 'wp_powersuite_oauth_login_completed', [ $this, 'onAlternateAuthenticated' ], 10, 1 );
		add_action( 'tl_after_login_success', [ $this, 'onTemporaryLoginSuccess' ], 10, 2 );
	}

	/**
	 * @param string   $user_login Username.
	 * @param \WP_User $user       User object.
	 */
	public function onWpLogin( string $user_login, \WP_User $user ): void {
		$this->maybeSendAlert( $user, $user_login );
	}

	/**
	 * Magic-link and OAuth completions (no password POST / login_init).
	 *
	 * @param \WP_User $user Authenticated user.
	 */
	public function onAlternateAuthenticated( \WP_User $user ): void {
		if ( ! $this->getSettings()['notify_alternate_logins'] ) {
			return;
		}
		$this->maybeSendAlert( $user, $user->user_login, true );
	}

	/**
	 * Temporary-login link completion.
	 *
	 * @param int $login_id Temporary login row ID.
	 * @param int $user_id  User ID.
	 */
	public function onTemporaryLoginSuccess( int $login_id, int $user_id ): void {
		unset( $login_id );
		if ( ! $this->getSettings()['notify_alternate_logins'] ) {
			return;
		}
		$user = get_userdata( $user_id );
		if ( ! $user instanceof \WP_User ) {
			return;
		}
		$this->maybeSendAlert( $user, $user->user_login, true );
	}

	/**
	 * Central guard + send. Idempotent within the request.
	 *
	 * @param bool $force_eligible_context Skip dashboard heuristic (magic/OAuth/temporary login).
	 */
	private function maybeSendAlert( \WP_User $user, string $user_login, bool $force_eligible_context = false ): void {
		if ( ! $this->isLicenseActive() ) {
			return;
		}

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

		if ( ! $force_eligible_context && ! $this->dashboardLoginSignalsPass( $user, $user_login ) ) {
			return;
		}

		/**
		 * Controls whether a successful qualifying login triggers a notification.
		 * Default: administrators (manage_options).
		 *
		 * @param bool     $notify      Whether to send.
		 * @param \WP_User $user       User who logged in.
		 * @param string   $user_login Login handle.
		 */
		$notify = (bool) apply_filters(
			'wp_powersuite_login_email_notification_should_notify',
			user_can( $user, 'manage_options' ),
			$user,
			$user_login
		);
		if ( ! $notify ) {
			return;
		}

		$settings = $this->getSettings();
		if ( trim( (string) ( $settings['emails'] ?? '' ) ) === '' ) {
			return;
		}
		$recipients = self::parseEmailsList( (string) ( $settings['emails'] ?? '' ) );
		if ( $recipients === [] ) {
			return;
		}

		$ip = $this->getClientIp( $settings );
		if ( $this->isIpExcluded( $ip, $settings ) ) {
			return;
		}

		if ( $this->isThrottled( (int) $user->ID, $ip, $settings ) ) {
			return;
		}

		self::$notification_sent_this_request = true;
		$this->markThrottle( (int) $user->ID, $ip, $settings );

		add_action(
			'shutdown',
			function () use ( $recipients, $user, $ip, $settings ): void {
				$this->sendNotificationEmail( $recipients, $user, $ip, $settings );
			},
			10
		);
	}

	/**
	 * @param list<string>         $recipients
	 * @param array<string, mixed> $settings
	 */
	private function sendNotificationEmail( array $recipients, \WP_User $user, string $ip, array $settings ): void {
		$subject = $this->replacePlaceholders( $this->getDefaultSubject(), $user, $ip, $settings, false );
		$message = $this->getMessageBody( $user, $ip, $settings );

		$content_type = static function (): string {
			return 'text/html';
		};
		add_filter( 'wp_mail_content_type', $content_type );

		$result = wp_mail( $recipients, $subject, $message );

		remove_filter( 'wp_mail_content_type', $content_type );

		if ( ! $result ) {
			error_log(
				sprintf(
					'WP PowerSuite Admin Login Notification: wp_mail failed for user %d (%s).',
					(int) $user->ID,
					$user->user_login
				)
			);
		}
	}

	/**
	 * True when the current request looks like a normal WordPress password login—not WooCommerce storefront login.
	 */
	private function isWordPressCredentialPost(): bool {
		if ( ( $_SERVER['REQUEST_METHOD'] ?? '' ) !== 'POST' ) {
			return false;
		}
		if ( $this->isLikelyWooCommerceAccountLogin() ) {
			return false;
		}

		return isset( $_POST['log'], $_POST['pwd'] ) && is_string( $_POST['log'] ) && is_string( $_POST['pwd'] );
	}

	private function isLikelyWooCommerceAccountLogin(): bool {
		return function_exists( 'WC' ) && isset( $_POST['woocommerce-login-nonce'] );
	}

	/**
	 * Raw redirect hints from REQUEST (covers plugins renaming fields or passing via GET).
	 */
	private function getRawRedirectCandidate(): string {
		foreach ( [ 'redirect_to', 'redirect' ] as $key ) {
			if ( ! isset( $_REQUEST[ $key ] ) || ! is_string( $_REQUEST[ $key ] ) ) {
				continue;
			}
			$val = trim( wp_unslash( $_REQUEST[ $key ] ) );
			if ( $val !== '' ) {
				return $val;
			}
		}
		return '';
	}

	/**
	 * PowerSuite 2FA completes in admin-ajax.php (`wpps_2fa_verify`): no login_init and no username/password POST fields.
	 */
	private function isWpPowerSuiteTwoFactorAjaxCompletion(): bool {
		if ( ! wp_doing_ajax() ) {
			return false;
		}
		$action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( (string) $_REQUEST['action'] ) ) : '';
		return $action === 'wpps_2fa_verify';
	}

	/**
	 * Detect “this is meant to notify” before checking manage_options filter.
	 *
	 * @param \WP_User $user       Authenticated user.
	 * @param string   $user_login Login slug.
	 */
	private function dashboardLoginSignalsPass( \WP_User $user, string $user_login ): bool {
		if ( $this->isLikelyWooCommerceAccountLogin() ) {
			return false;
		}

		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return false;
		}

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

		$settings = $this->getSettings();
		$strict   = ! empty( $settings['strict_login_detection'] );
		$computed = false;

		if ( $this->isWpPowerSuiteTwoFactorAjaxCompletion() ) {
			$computed = true;
		}

		if ( ! $computed ) {
			$had_login_screen = did_action( 'login_init' ) > 0;
			$credential_post  = $this->isWordPressCredentialPost();

			if ( ! $had_login_screen && ! $credential_post ) {
				$computed = false;
			} else {
				$raw = $this->getRawRedirectCandidate();
				if ( $raw === '' ) {
					$computed = true;
				} else {
					$validated = wp_validate_redirect( $raw, '' );
					if ( $validated === '' ) {
						$computed = false;
					} elseif ( $this->urlTargetsWpAdmin( $validated ) ) {
						$computed = true;
					} elseif ( ! $strict ) {
						// Hide-login setups often redirect to the homepage after wp-login.php authenticates.
						$computed = true;
					}
				}
			}
		}

		/**
		 * Filters whether this login qualifies for an admin/dashboard notification.
		 *
		 * @param bool     $eligible   Candidate result from PowerSuite detection.
		 * @param \WP_User $user       User object.
		 * @param string   $user_login Login handle.
		 */
		return (bool) apply_filters(
			'wp_powersuite_login_email_notification_is_wp_admin_login_context',
			$computed,
			$user,
			$user_login
		);
	}

	/**
	 * @param array<string, mixed> $settings
	 */
	private function getMessageBody( \WP_User $user, string $ip, array $settings ): string {
		$inner = $this->getNotificationContentTemplate();
		$inner = $this->replacePlaceholders( $inner, $user, $ip, $settings, true );

		$site_name = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
		$title     = $this->replacePlaceholders(
			__( 'New administrator login detected', 'wp-powersuite' ),
			$user,
			$ip,
			$settings,
			true
		);

		return EmailRenderer::wrap(
			[
				'badge'        => __( 'Security alert', 'wp-powersuite' ),
				'site_name'    => $site_name,
				'title'        => wp_strip_all_tags( $title ),
				'content'      => $inner,
				'footer_brand' => sprintf(
					/* translators: 1: site name, 2: year */
					__( 'Sent by Admin Login Notification from WP PowerSuite · %1$s · © %2$s', 'wp-powersuite' ),
					$site_name,
					gmdate( 'Y' )
				),
			]
		);
	}

	/**
	 * Inner notification body (placeholders replaced before wrapping).
	 */
	private function getNotificationContentTemplate(): string {
		if ( self::$cached_template === null ) {
			self::$cached_template = $this->buildNotificationContentTemplate();
		}
		return self::$cached_template;
	}

	private function buildNotificationContentTemplate(): string {
		$accent = EmailRenderer::accentColor();

		$content  = EmailComponents::paragraph(
			esc_html__( 'We noticed a new sign-in to an administrator account on your site. Review the details below.', 'wp-powersuite' )
		);
		$content .= EmailComponents::detailsBox(
			__( 'Login details', 'wp-powersuite' ),
			[
				[ 'label' => __( 'Username', 'wp-powersuite' ), 'value' => '[username]' ],
				[ 'label' => __( 'Role', 'wp-powersuite' ), 'value' => '[RoleName]' ],
				[ 'label' => __( 'Date & time', 'wp-powersuite' ), 'value' => '[Date] at [Time]' ],
				[ 'label' => __( 'IP address', 'wp-powersuite' ), 'value' => '[IP Address]' ],
				[ 'label' => __( 'Location / network', 'wp-powersuite' ), 'value' => '[IP Details]' ],
				[ 'label' => __( 'Browser / device', 'wp-powersuite' ), 'value' => '[User Agent]' ],
			]
		);
		$content .= EmailComponents::notice(
			'<strong>' . esc_html__( 'Was this you?', 'wp-powersuite' ) . '</strong> ' . esc_html__( 'If you recognize this login, no further action is needed.', 'wp-powersuite' ),
			'success'
		);
		$content .= EmailComponents::notice(
			'<strong>' . esc_html__( "Don't recognize this login?", 'wp-powersuite' ) . '</strong> ' . esc_html__( 'Secure the account immediately—change the password and review recent activity.', 'wp-powersuite' ),
			'warning'
		);
		$content .= EmailComponents::button(
			'[SecureAccountUrl]',
			__( 'Secure account', 'wp-powersuite' ),
			[ 'color' => $accent ]
		);
		$content .= EmailComponents::divider();
		$content .= EmailComponents::paragraph(
			esc_html__( "This is an automated notification from your site's security settings. It does not include passwords or session tokens.", 'wp-powersuite' ),
			[ 'size' => EmailDesign::FONT_SMALL, 'muted' => true, 'margin' => '0 0 12px' ]
		);
		$content .= EmailComponents::paragraph(
			esc_html__( 'Stay safe,', 'wp-powersuite' ) . '<br><strong>[WordPress Site Name]</strong>',
			[ 'margin' => '0' ]
		);
		$content .= EmailComponents::paragraph(
			'<a href="[SiteUrl]" style="color:' . esc_attr( $accent ) . ';">[SiteUrl]</a>',
			[ 'size' => EmailDesign::FONT_SMALL, 'muted' => true, 'margin' => '12px 0 0' ]
		);

		return $content;
	}

	private function getFallbackMessage(): string {
		return "Dear Admin,<br>\nWe noticed a new login to your account: Username: [username] User Role: [RoleName] Date and Time: [Date] at [Time]<br>\nIP address: [IP Address]<br>\nLocation / network: [IP Details]<br>\nBrowser hint: [User Agent]<br>\nIf this was you, no further action is needed. If you do not recognize this login, please secure your account immediately by changing your password and reviewing your account activity. Stay safe, [WordPress Site Name]. This email was sent from Admin Login Notification from WP PowerSuite";
	}

	private function getDefaultSubject(): string {
		return __( 'New Login Detected at [WordPress Site Name]', 'wp-powersuite' );
	}

	/**
	 * Whether a URL points inside this site's admin area (wp-admin path).
	 */
	private function urlTargetsWpAdmin( string $url ): bool {
		$url = esc_url_raw( $url );
		if ( $url === '' ) {
			return false;
		}

		$admin_url = admin_url();
		$u         = wp_parse_url( $url );
		$a         = wp_parse_url( $admin_url );

		if ( empty( $u['path'] ) || empty( $a['path'] ) ) {
			return false;
		}

		$admin_base  = trailingslashit( $a['path'] );
		$target_path = trailingslashit( $u['path'] );
		if ( ! str_starts_with( $target_path, $admin_base ) ) {
			return false;
		}

		if ( isset( $u['host'], $a['host'] ) && strcasecmp( $u['host'], $a['host'] ) !== 0 ) {
			return false;
		}

		return true;
	}

	/**
	 * @param array<string, mixed> $settings
	 */
	private function getClientIp( array $settings ): string {
		$custom_header = isset( $settings['ip_header'] ) && is_string( $settings['ip_header'] ) ? trim( $settings['ip_header'] ) : '';

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

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

		$raw = isset( $_SERVER['REMOTE_ADDR'] ) ? wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) : '';
		$ip  = sanitize_text_field( $raw );
		if ( $ip === '' || filter_var( $ip, FILTER_VALIDATE_IP ) === false ) {
			$ip = '';
		}

		/**
		 * Client IP recorded in the email (defaults to CF-Connecting-IP or REMOTE_ADDR).
		 *
		 * @param string $ip Validated IP or empty.
		 */
		$ip = (string) apply_filters( 'wp_powersuite_login_email_notification_client_ip', $ip );
		if ( $ip !== '' && filter_var( $ip, FILTER_VALIDATE_IP ) === false ) {
			return '';
		}

		return $ip;
	}

	/**
	 * @param array<string, mixed> $settings
	 */
	private function isThrottled( int $user_id, string $ip, array $settings ): bool {
		$minutes = max( 0, min( 60, (int) ( $settings['throttle_minutes'] ?? 0 ) ) );
		if ( $minutes <= 0 || $user_id < 1 ) {
			return false;
		}

		return (bool) get_transient( $this->getThrottleKey( $user_id, $ip ) );
	}

	/**
	 * @param array<string, mixed> $settings
	 */
	private function markThrottle( int $user_id, string $ip, array $settings ): void {
		$minutes = max( 0, min( 60, (int) ( $settings['throttle_minutes'] ?? 0 ) ) );
		if ( $minutes <= 0 || $user_id < 1 ) {
			return;
		}

		set_transient( $this->getThrottleKey( $user_id, $ip ), 1, $minutes * MINUTE_IN_SECONDS );
	}

	private function getThrottleKey( int $user_id, string $ip ): string {
		return 'wpps_aln_sent_' . $user_id . '_' . md5( $ip );
	}

	/**
	 * @param array<string, mixed> $settings Merged module settings.
	 * @return list<string>
	 */
	private function getExcludedIps( array $settings ): array {
		$raw = $settings['exclude_ips'] ?? '';
		if ( ! is_string( $raw ) ) {
			return [];
		}
		$lines = preg_split( '/[\r\n,]+/', $raw, -1, PREG_SPLIT_NO_EMPTY );
		$ips   = [];
		$count = 0;
		foreach ( $lines as $line ) {
			if ( $count >= self::MAX_EXCLUDE_IPS ) {
				break;
			}
			$lip = trim( $line );
			if ( $lip !== '' ) {
				$ips[] = $lip;
				++$count;
			}
		}
		return $ips;
	}

	/**
	 * @param array<string, mixed> $settings Merged module settings.
	 */
	private function isIpExcluded( string $ip, array $settings ): bool {
		if ( $ip === '' ) {
			return false;
		}
		foreach ( $this->getExcludedIps( $settings ) as $excluded_ip ) {
			if ( $ip === $excluded_ip ) {
				return true;
			}
			if ( strpos( $excluded_ip, '*' ) !== false && $this->ipMatchesPattern( $ip, $excluded_ip ) ) {
				return true;
			}
		}
		return false;
	}

	private function ipMatchesPattern( string $ip, string $pattern ): bool {
		if ( strlen( $pattern ) > self::MAX_PATTERN_LENGTH ) {
			return false;
		}
		$pattern = preg_quote( $pattern, '#' );
		$pattern = str_replace( '\*', '.*', $pattern );
		return (bool) preg_match( '#^' . $pattern . '$#', $ip );
	}

	/**
	 * @return list<string>
	 */
	public static function parseEmailsList( string $raw ): array {
		if ( $raw === '' ) {
			return [];
		}
		$emails = [];
		$parts  = preg_split( '/[\r\n,]+/', $raw, -1, PREG_SPLIT_NO_EMPTY );
		foreach ( $parts as $part ) {
			if ( count( $emails ) >= self::MAX_RECIPIENTS ) {
				break;
			}
			$email = sanitize_email( trim( $part ) );
			if ( is_email( $email ) && ! in_array( $email, $emails, true ) ) {
				$emails[] = $email;
			}
		}
		return $emails;
	}

	/**
	 * @param array<string, mixed> $raw
	 * @return array<string, mixed>
	 */
	public static function normalizeSettings( array $raw ): array {
		$module = new self();
		$defaults = $module->getDefaultSettings();

		$emails = self::parseEmailsList( (string) ( $raw['emails'] ?? '' ) );

		return [
			'emails'                  => implode( ', ', $emails ),
			'exclude_ips'             => self::normalizeExcludeIps( $raw['exclude_ips'] ?? '' ),
			'geo_lookup_enabled'      => ! empty( $raw['geo_lookup_enabled'] ) ? 1 : 0,
			'include_user_agent'      => ! isset( $raw['include_user_agent'] ) || ! empty( $raw['include_user_agent'] ) ? 1 : 0,
			'ip_header'               => self::normalizeIpHeader( $raw['ip_header'] ?? '' ),
			'notify_alternate_logins' => ! isset( $raw['notify_alternate_logins'] ) || ! empty( $raw['notify_alternate_logins'] ) ? 1 : 0,
			'strict_login_detection'  => ! empty( $raw['strict_login_detection'] ) ? 1 : 0,
			'throttle_minutes'        => max( 0, min( 60, (int) ( $raw['throttle_minutes'] ?? $defaults['throttle_minutes'] ) ) ),
		];
	}

	/**
	 * @param mixed $raw
	 */
	public static function normalizeExcludeIps( $raw ): string {
		if ( is_array( $raw ) ) {
			$lines = $raw;
		} else {
			$lines = preg_split( '/[\r\n,]+/', (string) $raw, -1, PREG_SPLIT_NO_EMPTY );
			$lines = is_array( $lines ) ? $lines : [];
		}

		$valid = [];
		foreach ( $lines as $line ) {
			if ( count( $valid ) >= self::MAX_EXCLUDE_IPS ) {
				break;
			}
			$entry = trim( (string) $line );
			if ( $entry === '' || ! self::isValidExcludeIpEntry( $entry ) ) {
				continue;
			}
			$valid[] = $entry;
		}

		return implode( "\n", $valid );
	}

	private static function isValidExcludeIpEntry( string $entry ): bool {
		if ( strpos( $entry, '*' ) !== false ) {
			return strlen( $entry ) <= self::MAX_PATTERN_LENGTH
				&& (bool) preg_match( '/^[0-9a-fA-F.*:]+$/', $entry );
		}

		return filter_var( $entry, FILTER_VALIDATE_IP ) !== false;
	}

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

		return $header;
	}

	/**
	 * Remove cached geo lookup transients (module disable / cleanup).
	 */
	public static function clearGeoTransients(): void {
		TransientPurger::deleteRemembered( self::GEO_KEYS_OPTION );
		TransientPurger::deleteByPrefix( self::GEO_TRANSIENT_PREFIX );
	}

	/**
	 * @param array<string, mixed> $settings
	 */
	private function replacePlaceholders( string $text, \WP_User $user, string $ip, array $settings, bool $for_html ): string {
		$role_label = $this->getRoleLabels( $user );
		$date       = wp_date( get_option( 'date_format' ) );
		$time       = wp_date( get_option( 'time_format' ) );
		$year       = wp_date( 'Y' );
		$site_name  = (string) get_bloginfo( 'name', 'display' );
		$site_url   = home_url( '/' );

		$secure_account_url = apply_filters(
			'wp_powersuite_login_email_secure_account_url',
			wp_lostpassword_url(),
			$user,
			$ip
		);
		if ( ! is_string( $secure_account_url ) || $secure_account_url === '' ) {
			$secure_account_url = wp_lostpassword_url();
		}

		$needs_geo = str_contains( $text, '[IP Details]' );
		$needs_ua  = str_contains( $text, '[User Agent]' );

		$geo_summary = '';
		if ( $needs_geo ) {
			$geo_summary = $this->formatGeoLinesForPlaceholder(
				$this->lookupGeoLinesForIp( $ip, $settings ),
				$for_html
			);
		}

		$include_ua = ! empty( $settings['include_user_agent'] );
		$include_ua = (bool) apply_filters( 'wp_powersuite_login_email_include_user_agent', $include_ua, $user, $ip );

		$user_agent = '';
		if ( $needs_ua ) {
			$user_agent = $this->getSanitizedUserAgentForEmail( $include_ua );
		}

		$values = [
			'[username]'            => $user->user_login,
			'[RoleName]'            => $role_label,
			'[Date]'                => $date,
			'[Time]'                => $time,
			'[Year]'                => $year,
			'[IP Address]'          => $ip !== '' ? $ip : __( 'Unknown', 'wp-powersuite' ),
			'[IP Details]'          => $geo_summary !== '' ? $geo_summary : ( $ip !== '' ? __( 'Location unavailable', 'wp-powersuite' ) : __( 'Unknown', 'wp-powersuite' ) ),
			'[User Agent]'          => $user_agent !== '' ? $user_agent : __( 'Not reported', 'wp-powersuite' ),
			'[WordPress Site Name]' => $site_name,
			'[SiteName]'            => $site_name,
			'[SiteUrl]'             => $site_url,
			'[SecureAccountUrl]'    => $secure_account_url,
		];

		foreach ( $values as $token => &$val ) {
			if ( ! is_string( $val ) ) {
				continue;
			}
			if ( $for_html ) {
				if ( '[SiteUrl]' === $token || '[SecureAccountUrl]' === $token ) {
					$val = esc_url( $val );
				} elseif ( '[IP Details]' === $token ) {
					// Each line already passed through esc_html() in formatGeoLinesForPlaceholder().
				} else {
					$val = esc_html( $val );
				}
			} elseif ( '[SiteUrl]' === $token || '[SecureAccountUrl]' === $token ) {
				$val = esc_url_raw( $val );
			} elseif ( '[IP Details]' === $token ) {
				$val = sanitize_text_field( str_replace( [ '<br>', '<br/>', '<br />' ], ' ', $val ) );
			} else {
				$val = sanitize_text_field( $val );
			}
		}
		unset( $val );

		return str_replace( array_keys( $values ), array_values( $values ), $text );
	}

	private function getRoleLabels( \WP_User $user ): string {
		if ( empty( $user->roles ) || ! is_array( $user->roles ) ) {
			return '';
		}

		$wp_roles = wp_roles();
		$names    = $wp_roles->get_names();
		$labels   = [];

		foreach ( $user->roles as $role_slug ) {
			$slug = (string) $role_slug;
			if ( $slug === '' ) {
				continue;
			}
			$labels[] = (string) ( $names[ $slug ] ?? $slug );
		}

		return implode( ', ', $labels );
	}

	/**
	 * @param list<string> $lines Plain phrases from geo lookup (already translated where fixed strings).
	 */
	private function formatGeoLinesForPlaceholder( array $lines, bool $for_html ): string {
		if ( $lines === [] ) {
			return '';
		}
		if ( $for_html ) {
			return implode( '<br>', array_map( 'esc_html', $lines ) );
		}
		return implode( '; ', $lines );
	}

	/**
	 * Cached geo lookup for notification emails (same provider shape as PowerSuite 2FA verification mail).
	 *
	 * @param array<string, mixed> $settings
	 * @return list<string>
	 */
	private function lookupGeoLinesForIp( string $ip, array $settings ): array {
		if ( $ip === '' || filter_var( $ip, FILTER_VALIDATE_IP ) === false ) {
			return [];
		}

		if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
			return [
				__( 'Private or reserved address — city/country lookup not available.', 'wp-powersuite' ),
			];
		}

		$geo_enabled = ! empty( $settings['geo_lookup_enabled'] );
		$geo_enabled = (bool) apply_filters( 'wp_powersuite_login_email_geo_lookup_enabled', $geo_enabled, $ip );
		if ( ! $geo_enabled ) {
			return [];
		}

		$cache_key = self::GEO_TRANSIENT_PREFIX . md5( $ip );
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) ) {
			return array_values(
				array_filter(
					array_map(
						static fn( $v ) => is_string( $v ) ? $v : '',
						$cached
					)
				)
			);
		}

		$url = apply_filters(
			'wp_powersuite_login_email_geo_api_url',
			'https://ipwho.is/' . rawurlencode( $ip ) . '?fields=success,message,country,region,city,type,connection',
			$ip
		);

		$response = wp_remote_get(
			$url,
			[
				'timeout'     => (int) apply_filters( 'wp_powersuite_login_email_geo_http_timeout', 4, $ip ),
				'redirection' => 1,
				'user-agent'  => 'WP-PowerSuite/LoginNotification',
			]
		);

		$lines = [];
		if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
			set_transient( $cache_key, $lines, (int) apply_filters( 'wp_powersuite_login_email_geo_cache_ttl_fail', MINUTE_IN_SECONDS * 15, $ip ) );
			TransientPurger::remember( self::GEO_KEYS_OPTION, $cache_key );
			return [];
		}

		$data = json_decode( (string) wp_remote_retrieve_body( $response ), true );
		if ( ! is_array( $data ) || empty( $data['success'] ) ) {
			set_transient( $cache_key, $lines, (int) apply_filters( 'wp_powersuite_login_email_geo_cache_ttl_fail', MINUTE_IN_SECONDS * 15, $ip ) );
			TransientPurger::remember( self::GEO_KEYS_OPTION, $cache_key );
			return [];
		}

		$city    = isset( $data['city'] ) ? trim( (string) $data['city'] ) : '';
		$region  = isset( $data['region'] ) ? trim( (string) $data['region'] ) : '';
		$country = isset( $data['country'] ) ? trim( (string) $data['country'] ) : '';
		$loc     = array_filter( [ $city, $region, $country ] );
		if ( $loc !== [] ) {
			/* translators: %s: comma-separated city, region, country */
			$lines[] = sprintf( __( 'Approximate location: %s', 'wp-powersuite' ), implode( ', ', $loc ) );
		}

		$conn = isset( $data['connection'] ) && is_array( $data['connection'] ) ? $data['connection'] : [];
		$isp  = isset( $conn['isp'] ) ? trim( (string) $conn['isp'] ) : '';
		$org  = isset( $conn['org'] ) ? trim( (string) $conn['org'] ) : '';
		if ( $isp !== '' || $org !== '' ) {
			$net = implode(
				' · ',
				array_unique(
					array_filter( [ $isp, $org ] )
				)
			);
			if ( $net !== '' ) {
				/* translators: %s: ISP / organization */
				$lines[] = sprintf( __( 'Network: %s', 'wp-powersuite' ), $net );
			}
		}

		if ( ! empty( $data['type'] ) ) {
			/* translators: %s: connection category from geo API */
			$lines[] = sprintf( __( 'Connection type: %s', 'wp-powersuite' ), (string) $data['type'] );
		}

		$ttl = (int) apply_filters( 'wp_powersuite_login_email_geo_cache_ttl_ok', HOUR_IN_SECONDS, $ip );
		set_transient( $cache_key, $lines, $ttl > 60 ? $ttl : HOUR_IN_SECONDS );
		TransientPurger::remember( self::GEO_KEYS_OPTION, $cache_key );

		return $lines;
	}

	private function getSanitizedUserAgentForEmail( bool $include ): string {
		if ( ! $include ) {
			return '';
		}
		if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
			return '';
		}
		$ua = sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) );
		if ( $ua === '' ) {
			return '';
		}
		if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) && mb_strlen( $ua ) > 400 ) {
			return mb_substr( $ua, 0, 400 ) . '…';
		}
		if ( strlen( $ua ) > 400 ) {
			return substr( $ua, 0, 400 ) . '…';
		}
		return $ua;
	}

	/**
	 * @return array<string, mixed>
	 */
	private function getSettings(): array {
		$defaults = $this->getDefaultSettings();
		$saved    = $this->moduleSettings()->all( [] );
		if ( ! is_array( $saved ) ) {
			$saved = [];
		}

		return self::normalizeSettings( array_merge( $defaults, $saved ) );
	}

	/**
	 * Send a sample notification to verify deliverability.
	 */
	public function sendTestEmail(): void {
		if ( ! $this->isLicenseActive() ) {
			$this->denyUnlicensedRequest( true );
		}

		$user_id  = get_current_user_id();
		$rate_key = 'wpps_aln_test_' . $user_id;
		$attempts = (int) get_transient( $rate_key );
		if ( $attempts >= 3 ) {
			wp_send_json_error(
				[
					'message' => __( 'Rate limit exceeded. Please wait 5 minutes before sending another test email.', 'wp-powersuite' ),
				]
			);
		}

		$test_email = isset( $_POST['test_email'] ) ? sanitize_email( wp_unslash( (string) $_POST['test_email'] ) ) : '';
		if ( ! is_email( $test_email ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid email address.', 'wp-powersuite' ) ] );
		}

		$settings = $this->getSettings();
		if ( trim( (string) ( $settings['emails'] ?? '' ) ) === '' ) {
			wp_send_json_error(
				[
					'message' => __( 'Add at least one recipient and save settings before sending a test email.', 'wp-powersuite' ),
				]
			);
		}

		$user = wp_get_current_user();
		if ( ! $user instanceof \WP_User || $user->ID < 1 ) {
			wp_send_json_error( [ 'message' => __( 'Could not resolve the current user.', 'wp-powersuite' ) ] );
		}

		$ip = $this->getClientIp( $settings );
		if ( $ip === '' ) {
			$ip = '203.0.113.1';
		}

		$subject = $this->replacePlaceholders(
			'[TEST] ' . $this->getDefaultSubject(),
			$user,
			$ip,
			$settings,
			false
		);
		$message = $this->getMessageBody( $user, $ip, $settings );

		$content_type = static function (): string {
			return 'text/html';
		};
		add_filter( 'wp_mail_content_type', $content_type );
		$result = wp_mail( [ $test_email ], $subject, $message );
		remove_filter( 'wp_mail_content_type', $content_type );

		if ( $result ) {
			set_transient( $rate_key, $attempts + 1, 5 * MINUTE_IN_SECONDS );
			wp_send_json_success(
				[
					'message' => __( 'Test email sent successfully!', 'wp-powersuite' ),
				]
			);
		}

		wp_send_json_error( [ 'message' => __( 'Failed to send test email. Check your mail configuration.', 'wp-powersuite' ) ] );
	}

}
