<?php

declare(strict_types=1);

namespace SecurWP\Modules\Hardening;

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

use SecurWP\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 = 'securwp_active_lockout_registry';

	/**
	 * 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 ) {
			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 ) {
			return;
		}

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

		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( 'securwp_lockout_' . md5( $ip ) );
			}
		}

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

		delete_option( self::OPTION_NAME );
	}

	/**
	 * 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() );
		$registry_out = $registry;
		$dirty        = false;
		$active_ips   = [];
		$active_users = [];

		foreach ( $registry['ip'] as $ip => $entry ) {
			$transient_value = get_transient( 'securwp_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( 'securwp_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',
				100
			),
			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( 'securwp_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( 'securwp_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
	 */
	private static function store_registry( array $registry ): void {
		update_option( self::OPTION_NAME, $registry, false );
	}
}
