<?php

declare(strict_types=1);

namespace SecurWP\Modules\Hardening;

use SecurWP\Core\Database\SchemaInspector;

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

/**
 * Database-backed login attempt counter.
 *
 * Tracks per-subject failed login attempts without relying on WordPress
 * transients, so counters remain correct on sites with persistent object
 * caches such as Redis or Memcached.
 */
final class LoginAttemptStore {

	public const TABLE_NAME = 'securwp_login_attempts';

	/**
	 * Record a failed login attempt for a subject within the configured window.
	 *
	 * A single atomic upsert handles both increment and window reset:
	 * - when the previous first_attempt_at is still inside the window, count++
	 * - when the previous window has expired, the row is reset to count=1
	 *
	 * @return array{count:int,first_attempt:int}
	 */
	public function record_attempt( string $subject_type, string $subject_key, int $window_seconds ): array {
		global $wpdb;

		$subject_type = $this->normalize_subject_type( $subject_type );
		$subject_hash = hash( 'sha256', $subject_key );
		$now          = time();
		$window       = max( 60, $window_seconds );
		$reset_before = $now - $window;
		$expires_at   = $now + $window + 60;
		$table        = self::table_identifier( $wpdb->prefix . self::TABLE_NAME );

		try {
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$result = $wpdb->query(
				$wpdb->prepare(
					"INSERT INTO {$table} (subject_type, subject_hash, first_attempt_at, last_attempt_at, attempt_count, expires_at)
					 VALUES (%s, %s, %d, %d, 1, %d)
					 ON DUPLICATE KEY UPDATE
					 attempt_count = IF(first_attempt_at <= %d, 1, attempt_count + 1),
					 first_attempt_at = IF(first_attempt_at <= %d, VALUES(first_attempt_at), first_attempt_at),
					 last_attempt_at = VALUES(last_attempt_at),
					 expires_at = VALUES(expires_at)",
					$subject_type,
					$subject_hash,
					$now,
					$now,
					$expires_at,
					$reset_before,
					$reset_before
				)
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

			if ( false === $result ) {
				$this->log_db_error( 'record_attempt', (string) $wpdb->last_error );
				return [
					'count'         => 1,
					'first_attempt' => $now,
				];
			}

			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$row = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT attempt_count, first_attempt_at
					 FROM {$table}
					 WHERE subject_type = %s AND subject_hash = %s
					 LIMIT 1",
					$subject_type,
					$subject_hash
				),
				ARRAY_A
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

			if ( ! is_array( $row ) ) {
				return [
					'count'         => 1,
					'first_attempt' => $now,
				];
			}

			return [
				'count'         => max( 1, (int) ( $row['attempt_count'] ?? 1 ) ),
				'first_attempt' => max( 0, (int) ( $row['first_attempt_at'] ?? $now ) ),
			];
		} catch ( \Throwable $throwable ) {
			$this->log_db_error( 'record_attempt', $throwable->getMessage() );

			return [
				'count'         => 1,
				'first_attempt' => $now,
			];
		}
	}

	/**
	 * Remove the active attempt window for a subject.
	 */
	public function clear_attempts( string $subject_type, string $subject_key ): void {
		global $wpdb;

		$subject_type = $this->normalize_subject_type( $subject_type );
		$subject_hash = hash( 'sha256', $subject_key );
		$table        = self::table_identifier( $wpdb->prefix . self::TABLE_NAME );

		try {
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->query(
				$wpdb->prepare(
					"DELETE FROM {$table} WHERE subject_type = %s AND subject_hash = %s",
					$subject_type,
					$subject_hash
				)
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		} catch ( \Throwable $throwable ) {
			$this->log_db_error( 'clear_attempts', $throwable->getMessage() );
		}
	}

	/**
	 * Delete expired attempt rows.
	 *
	 * @return int Number of rows removed.
	 */
	public function purge_expired( ?int $now = null ): int {
		global $wpdb;

		$cutoff = max( 0, (int) ( $now ?? time() ) );
		$table  = self::table_identifier( $wpdb->prefix . self::TABLE_NAME );

		try {
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$deleted = $wpdb->query(
				$wpdb->prepare(
					"DELETE FROM {$table} WHERE expires_at > 0 AND expires_at <= %d",
					$cutoff
				)
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

			return max( 0, (int) $deleted );
		} catch ( \Throwable $throwable ) {
			$this->log_db_error( 'purge_expired', $throwable->getMessage() );
			return 0;
		}
	}

	public static function is_schema_current(): bool {
		global $wpdb;

		$table = $wpdb->prefix . self::TABLE_NAME;

		if ( ! SchemaInspector::table_exists( $table ) ) {
			return false;
		}

		return SchemaInspector::has_columns(
			$table,
			[
				'id',
				'subject_type',
				'subject_hash',
				'first_attempt_at',
				'last_attempt_at',
				'attempt_count',
				'expires_at',
			]
		);
	}

	private function normalize_subject_type( string $subject_type ): string {
		return 'user' === strtolower( $subject_type ) ? 'user' : 'ip';
	}

	private function log_db_error( string $method, string $error ): void {
		static $logged = false;

		if ( $logged ) {
			return;
		}

		$logged = true;
		securwp_log( 'SecurWP LoginAttemptStore: DB error in ' . $method . ' - ' . $error );
	}

	private static function table_identifier( string $table ): string {
		if ( $table === '' || strlen( $table ) > 64 || ! preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) {
			throw new \InvalidArgumentException( 'Invalid table identifier.' );
		}

		return '`' . str_replace( '`', '``', $table ) . '`';
	}
}
