<?php

declare(strict_types=1);

namespace SiteFort\Modules\Hardening;

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

use SiteFort\Modules\AuditLog\AuditLog;

/**
 * Tracks active login-lockout subjects so reporting can enumerate candidates
 * without depending on wp_options internals or audit-log side effects.
 *
 * The transient remains the enforcement source of truth. This registry only
 * stores candidate subjects and always verifies them against the current
 * transient state before returning data or counts.
 */
final class LockoutRegistry {
	private const OPTION_NAME = 'sitefort_active_lockout_registry';
	private const MAX_IP_ENTRIES = 1000;
	private const MAX_USER_ENTRIES = 1000;
	private const MAX_IP_LENGTH = 128;
	private const MAX_SUBJECT_KEY_LENGTH = 320;
	private const MAX_DISPLAY_LENGTH = 255;
	private const BACKFILL_LIMIT = 100;

	/**
	 * Persist an active IP lockout candidate.
	 */
	public static function add_ip_lockout( string $ip, int $locked_at ): void {
		$ip = sanitize_text_field( trim( $ip ) );
		if ( '' === $ip || self::string_length( $ip ) > self::MAX_IP_LENGTH ) {
			return;
		}

		$registry = self::load_registry();
		$registry['ip'][ $ip ] = [
			'locked_at' => max( 0, $locked_at ),
		];

		self::store_registry( $registry );
	}

	/**
	 * Persist an active user lockout candidate.
	 */
	public static function add_user_lockout( string $subject_key, string $display, int $locked_at ): void {
		$subject_key = trim( $subject_key );
		if ( '' === $subject_key || self::string_length( $subject_key ) > self::MAX_SUBJECT_KEY_LENGTH ) {
			return;
		}

		$registry = self::load_registry();
		$registry['user'][ $subject_key ] = [
			'locked_at' => max( 0, $locked_at ),
			'display'   => self::limit_string( Hardening::sanitize_login_identifier_input( $display ), self::MAX_DISPLAY_LENGTH ),
		];

		self::store_registry( $registry );
	}

	/**
	 * Remove an IP lockout candidate.
	 */
	public static function clear_ip_lockout( string $ip ): void {
		$ip = sanitize_text_field( trim( $ip ) );
		if ( '' === $ip ) {
			return;
		}

		$registry = self::load_registry();
		if ( isset( $registry['ip'][ $ip ] ) ) {
			unset( $registry['ip'][ $ip ] );
			self::store_registry( $registry );
		}
	}

	/**
	 * Remove one or more user lockout candidates.
	 *
	 * @param array<int,string> $subject_keys
	 */
	public static function clear_user_lockouts( array $subject_keys ): void {
		if ( empty( $subject_keys ) ) {
			return;
		}

		$registry = self::load_registry();
		$changed  = false;

		foreach ( $subject_keys as $subject_key ) {
			if ( ! is_string( $subject_key ) ) {
				continue;
			}

			$subject_key = trim( $subject_key );
			if ( '' === $subject_key || ! isset( $registry['user'][ $subject_key ] ) ) {
				continue;
			}

			unset( $registry['user'][ $subject_key ] );
			$changed = true;
		}

		if ( $changed ) {
			self::store_registry( $registry );
		}
	}

	/**
	 * Clear all active login lockout transients and remove the registry.
	 */
	public static function clear_all_lockouts(): void {
		$registry = self::maybe_backfill_from_audit_log( self::load_registry() );

		foreach ( array_keys( $registry['ip'] ) as $ip ) {
			if ( is_string( $ip ) && $ip !== '' ) {
				delete_transient( 'sitefort_lockout_' . md5( $ip ) );
			}
		}

		foreach ( array_keys( $registry['user'] ) as $subject_key ) {
			if ( is_string( $subject_key ) && $subject_key !== '' ) {
				delete_transient( 'sitefort_lockout_user_' . md5( $subject_key ) );
			}
		}

		delete_option( self::OPTION_NAME );
	}

	/**
	 * Remove registry entries whose enforcement transient has expired.
	 */
	public static function prune_stale_entries(): void {
		self::store_registry( self::prune_inactive_registry( self::load_registry() ) );
	}

	/**
	 * Return active lockouts for dashboard rendering.
	 *
	 * @return array{ips: array<int,array{ip:string,locked_at:int}>, users: array<int,array{username:string,locked_at:int}>}
	 */
	public static function get_active_lockouts( int $limit = 10 ): array {
		$active = self::collect_active_lockouts();

		if ( $limit > 0 ) {
			$active['ips']   = array_slice( $active['ips'], 0, $limit );
			$active['users'] = array_slice( $active['users'], 0, $limit );
		}

		return $active;
	}

	/**
	 * Return active lockout counts for metrics/state push.
	 *
	 * @return array{ip:int,user:int}
	 */
	public static function count_active_lockouts(): array {
		$active = self::collect_active_lockouts();

		return [
			'ip'   => count( $active['ips'] ),
			'user' => count( $active['users'] ),
		];
	}

	/**
	 * Verify registry candidates against the real transient state and prune stale entries.
	 *
	 * @return array{ips: array<int,array{ip:string,locked_at:int}>, users: array<int,array{username:string,locked_at:int}>}
	 */
	private static function collect_active_lockouts(): array {
		$registry     = self::maybe_backfill_from_audit_log( self::load_registry() );
		$normalized   = self::normalize_registry( $registry );
		$dirty        = $normalized !== $registry;
		$registry     = $normalized;
		$registry_out = $registry;
		$active_ips   = [];
		$active_users = [];

		foreach ( $registry['ip'] as $ip => $entry ) {
			$transient_value = get_transient( 'sitefort_lockout_' . md5( $ip ) );
			if ( false === $transient_value ) {
				unset( $registry_out['ip'][ $ip ] );
				$dirty = true;
				continue;
			}

			$locked_at = self::resolve_locked_at( $entry, $transient_value );
			$active_ips[] = [
				'ip'        => $ip,
				'locked_at' => $locked_at,
			];
		}

		foreach ( $registry['user'] as $subject_key => $entry ) {
			$transient_value = get_transient( 'sitefort_lockout_user_' . md5( $subject_key ) );
			if ( false === $transient_value ) {
				unset( $registry_out['user'][ $subject_key ] );
				$dirty = true;
				continue;
			}

			$username = self::resolve_user_display( $subject_key, $entry );
			if ( '' === $username ) {
				unset( $registry_out['user'][ $subject_key ] );
				$dirty = true;
				continue;
			}

			$locked_at = self::resolve_locked_at( $entry, $transient_value );
			$active_users[] = [
				'username'  => $username,
				'locked_at' => $locked_at,
			];
		}

		usort(
			$active_ips,
			static function ( array $left, array $right ): int {
				return (int) $right['locked_at'] <=> (int) $left['locked_at'];
			}
		);
		usort(
			$active_users,
			static function ( array $left, array $right ): int {
				return (int) $right['locked_at'] <=> (int) $left['locked_at'];
			}
		);

		if ( $dirty ) {
			self::store_registry( $registry_out );
		}

		return [
			'ips'   => $active_ips,
			'users' => $active_users,
		];
	}

	/**
	 * Seed the registry from recent lockout audit events after upgrades so
	 * currently active lockouts become visible immediately.
	 *
	 * @param array{ip: array<string,array<string,mixed>>, user: array<string,array<string,mixed>>} $registry
	 * @return array{ip: array<string,array<string,mixed>>, user: array<string,array<string,mixed>>}
	 */
	private static function maybe_backfill_from_audit_log( array $registry ): array {
		if ( ! empty( $registry['ip'] ) || ! empty( $registry['user'] ) ) {
			return $registry;
		}

		global $wpdb;

			$audit_table_name = $wpdb->prefix . AuditLog::TABLE_NAME;
			if ( $audit_table_name === '' || strlen( $audit_table_name ) > 64 || ! preg_match( '/^[A-Za-z0-9_]+$/', $audit_table_name ) ) {
				return $registry;
			}

			$audit_table      = '`' . str_replace( '`', '``', $audit_table_name ) . '`';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Checks existence of the plugin-owned audit table before optional upgrade backfill.
		$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $audit_table_name ) ) );
		if ( $table_exists !== $audit_table_name ) {
			return $registry;
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT event, ip_address, meta, created_at
				 FROM {$audit_table}
				 WHERE event IN (%s, %s)
				 ORDER BY created_at DESC
				 LIMIT %d",
				'IP Lockout',
				'User Lockout',
				self::BACKFILL_LIMIT
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( ! is_array( $rows ) || empty( $rows ) ) {
			return $registry;
		}

		$changed = false;
		foreach ( $rows as $row ) {
			$event = isset( $row['event'] ) ? (string) $row['event'] : '';
			if ( 'IP Lockout' === $event ) {
				$meta = json_decode( (string) ( $row['meta'] ?? '' ), true );
				if ( ! is_array( $meta ) ) {
					$meta = [];
				}

				$ip = sanitize_text_field( trim( (string) ( $meta['ip'] ?? '' ) ) );
				if ( '' === $ip ) {
					$ip = sanitize_text_field( trim( (string) ( $row['ip_address'] ?? '' ) ) );
				}
				if ( '' === $ip || false === get_transient( 'sitefort_lockout_' . md5( $ip ) ) ) {
					continue;
				}

				if ( ! isset( $registry['ip'][ $ip ] ) ) {
					$registry['ip'][ $ip ] = [
						'locked_at' => (int) ( $row['created_at'] ?? 0 ),
					];
					$changed = true;
				}
				continue;
			}

			if ( 'User Lockout' !== $event ) {
				continue;
			}

			$meta = json_decode( (string) ( $row['meta'] ?? '' ), true );
			if ( ! is_array( $meta ) ) {
				$meta = [];
			}

			$display      = Hardening::sanitize_login_identifier_input( $meta['username'] ?? '' );
			$subject_keys = [];
			$lockout_key  = trim( (string) ( $meta['lockout_key'] ?? '' ) );
			if ( '' !== $lockout_key ) {
				$subject_keys[] = $lockout_key;
			}
			if ( '' !== $display ) {
				$subject_keys = array_merge( $subject_keys, Hardening::get_user_lockout_key_candidates( $display ) );
			}

			$subject_keys = array_values( array_unique( array_filter( $subject_keys, 'is_string' ) ) );
			foreach ( $subject_keys as $subject_key ) {
				if ( false === get_transient( 'sitefort_lockout_user_' . md5( $subject_key ) ) ) {
					continue;
				}

				if ( ! isset( $registry['user'][ $subject_key ] ) ) {
					$registry['user'][ $subject_key ] = [
						'locked_at' => (int) ( $row['created_at'] ?? 0 ),
						'display'   => $display,
					];
					$changed = true;
				}
				break;
			}
		}

		if ( $changed ) {
			self::store_registry( $registry );
		}

		return $registry;
	}

	/**
	 * @param mixed $transient_value
	 * @param mixed $entry
	 */
	private static function resolve_locked_at( $entry, $transient_value ): int {
		$locked_at = 0;
		if ( is_array( $entry ) ) {
			$locked_at = isset( $entry['locked_at'] ) ? (int) $entry['locked_at'] : 0;
		}

		if ( $locked_at <= 0 && is_scalar( $transient_value ) ) {
			$locked_at = (int) $transient_value;
		}

		return $locked_at > 0 ? $locked_at : time();
	}

	/**
	 * @param mixed $entry
	 */
	private static function resolve_user_display( string $subject_key, $entry ): string {
		$display = '';
		if ( is_array( $entry ) ) {
			$display = Hardening::sanitize_login_identifier_input( $entry['display'] ?? '' );
		}

		if ( '' !== $display ) {
			return $display;
		}

		if ( str_starts_with( $subject_key, 'identifier:' ) ) {
			return substr( $subject_key, strlen( 'identifier:' ) );
		}

		return $subject_key;
	}

	/**
	 * @return array{ip: array<string,array<string,mixed>>, user: array<string,array<string,mixed>>}
	 */
	private static function load_registry(): array {
		$registry = get_option( self::OPTION_NAME, [] );
		if ( ! is_array( $registry ) ) {
			$registry = [];
		}

		$registry['ip'] = isset( $registry['ip'] ) && is_array( $registry['ip'] ) ? $registry['ip'] : [];
		$registry['user'] = isset( $registry['user'] ) && is_array( $registry['user'] ) ? $registry['user'] : [];

		return $registry;
	}

	/**
	 * @param array{ip: array<string,array<string,mixed>>, user: array<string,array<string,mixed>>} $registry
	 * @return array{ip: array<string,array<string,mixed>>, user: array<string,array<string,mixed>>}
	 */
	private static function prune_inactive_registry( array $registry ): array {
		$registry = self::normalize_registry( $registry );

		foreach ( array_keys( $registry['ip'] ) as $ip ) {
			if ( false === get_transient( 'sitefort_lockout_' . md5( (string) $ip ) ) ) {
				unset( $registry['ip'][ $ip ] );
			}
		}

		foreach ( array_keys( $registry['user'] ) as $subject_key ) {
			if ( false === get_transient( 'sitefort_lockout_user_' . md5( (string) $subject_key ) ) ) {
				unset( $registry['user'][ $subject_key ] );
			}
		}

		return $registry;
	}

	/**
	 * @param array<string,mixed> $registry
	 * @return array{ip: array<string,array<string,mixed>>, user: array<string,array<string,mixed>>}
	 */
	private static function normalize_registry( array $registry ): array {
		$normalized = [
			'ip'   => [],
			'user' => [],
		];

		$ip_entries = isset( $registry['ip'] ) && is_array( $registry['ip'] ) ? $registry['ip'] : [];
		foreach ( $ip_entries as $ip => $entry ) {
			if ( ! is_string( $ip ) && ! is_numeric( $ip ) ) {
				continue;
			}

			$ip = sanitize_text_field( trim( (string) $ip ) );
			if ( '' === $ip || self::string_length( $ip ) > self::MAX_IP_LENGTH ) {
				continue;
			}

			$normalized['ip'][ $ip ] = [
				'locked_at' => self::normalize_locked_at( $entry ),
			];
		}

		$user_entries = isset( $registry['user'] ) && is_array( $registry['user'] ) ? $registry['user'] : [];
		foreach ( $user_entries as $subject_key => $entry ) {
			if ( ! is_string( $subject_key ) && ! is_numeric( $subject_key ) ) {
				continue;
			}

			$subject_key = trim( (string) $subject_key );
			if ( '' === $subject_key || self::string_length( $subject_key ) > self::MAX_SUBJECT_KEY_LENGTH ) {
				continue;
			}

			$display = '';
			if ( is_array( $entry ) ) {
				$display = self::limit_string( Hardening::sanitize_login_identifier_input( $entry['display'] ?? '' ), self::MAX_DISPLAY_LENGTH );
			}

			$normalized['user'][ $subject_key ] = [
				'locked_at' => self::normalize_locked_at( $entry ),
				'display'   => $display,
			];
		}

		$normalized['ip'] = self::limit_registry_entries( $normalized['ip'], self::MAX_IP_ENTRIES );
		$normalized['user'] = self::limit_registry_entries( $normalized['user'], self::MAX_USER_ENTRIES );

		return $normalized;
	}

	/**
	 * @param array<string,array<string,mixed>> $entries
	 * @return array<string,array<string,mixed>>
	 */
	private static function limit_registry_entries( array $entries, int $limit ): array {
		if ( $limit <= 0 || count( $entries ) <= $limit ) {
			return $entries;
		}

		uasort(
			$entries,
			static function ( array $left, array $right ): int {
				return (int) ( $right['locked_at'] ?? 0 ) <=> (int) ( $left['locked_at'] ?? 0 );
			}
		);

		return array_slice( $entries, 0, $limit, true );
	}

	/**
	 * @param mixed $entry
	 */
	private static function normalize_locked_at( $entry ): int {
		if ( is_array( $entry ) && isset( $entry['locked_at'] ) ) {
			return max( 0, (int) $entry['locked_at'] );
		}

		if ( is_scalar( $entry ) ) {
			return max( 0, (int) $entry );
		}

		return 0;
	}

	private static function limit_string( string $value, int $max_length ): string {
		if ( $max_length <= 0 ) {
			return '';
		}

		if ( function_exists( 'mb_substr' ) ) {
			$limited = mb_substr( $value, 0, $max_length, 'UTF-8' );
			if ( is_string( $limited ) ) {
				return $limited;
			}
		}

		return substr( $value, 0, $max_length );
	}

	private static function string_length( string $value ): int {
		if ( function_exists( 'mb_strlen' ) ) {
			$length = mb_strlen( $value, 'UTF-8' );
			if ( is_int( $length ) ) {
				return $length;
			}
		}

		return strlen( $value );
	}

	/**
	 * @param array{ip: array<string,array<string,mixed>>, user: array<string,array<string,mixed>>} $registry
	 */
	private static function store_registry( array $registry ): void {
		$registry = self::normalize_registry( $registry );
		if ( empty( $registry['ip'] ) && empty( $registry['user'] ) ) {
			delete_option( self::OPTION_NAME );
			return;
		}

		update_option( self::OPTION_NAME, $registry, false );
	}
}
