<?php

declare(strict_types=1);

namespace App\Auth;

use App\Logging\LogIdentifier;
use Psr\Log\LoggerInterface;

/**
 * Brute-force lockout for the local-admin sign-in (SPEC §M14.2).
 *
 * Two parallel buckets are evaluated on every attempt:
 *
 *   - per-(username, ip): catches single-source spray. Looser triggers so a
 *     legit admin retrying from one IP isn't locked out by a few typos.
 *     Failure ladder: 5 / 10 / 15 → 60 s / 300 s / 1800 s.
 *
 *   - per-(username): catches a distributed spray (residential proxy pool,
 *     botnet) where each new IP would otherwise be a fresh per-IP bucket.
 *     Tighter ladder so the global bucket trips before the attacker exhausts
 *     a meaningful Argon2id budget, but loose enough that an attacker can't
 *     trivially hold the legit admin out indefinitely.
 *     Failure ladder: 25 / 50 / 100 → 60 s / 300 s / 1800 s.
 *
 * `isLocked()` returns true if either bucket is locked; `lockoutSeconds…`
 * returns the larger of the two remaining windows so the user-facing
 * message reflects the worst-case wait. `recordFailure()` increments both,
 * `clear()` (called on success) resets both.
 *
 * Storage is delegated to a {@see ThrottleStore}. In production we use
 * {@see FileThrottleStore}, which serialises read-modify-write across all
 * FrankenPHP workers via `flock()` on a sibling lock file. That fixes
 * SEC_REVIEW F6: a worker recycle no longer wipes counters and parallel
 * workers no longer multiply the allowed attempts. The file lives on the
 * container's writable layer (no shared volume), so a container restart
 * still clears it — the documented "operator unlocks the admin" path.
 *
 * Multi-replica caveat: each replica has its own counters. A determined
 * attacker who can reach multiple replicas through a non-sticky LB would
 * see the per-username step at 25*N total attempts. Single-replica is the
 * documented topology for the ui container; if you scale, put the LB in
 * sticky mode.
 */
final class LoginThrottle
{
    private readonly ThrottleStore $store;

    /** @var \Closure(): int */
    private \Closure $timeFn;

    public function __construct(
        private readonly LoggerInterface $logger,
        ?\Closure $timeFn = null,
        ?ThrottleStore $store = null,
    ) {
        $this->timeFn = $timeFn ?? static fn (): int => time();
        $this->store = $store ?? new InMemoryThrottleStore();
    }

    public function isLocked(string $username, string $ip): bool
    {
        return $this->lockoutSecondsRemaining($username, $ip) > 0;
    }

    public function lockoutSecondsRemaining(string $username, string $ip): int
    {
        $now = ($this->timeFn)();
        $state = $this->store->load();
        $perIp = $this->remaining($state['ip'], self::ipKey($username, $ip), $now);
        $perUser = $this->remaining($state['username'], self::userKey($username), $now);

        return max($perIp, $perUser);
    }

    public function recordFailure(string $username, string $ip): void
    {
        $now = ($this->timeFn)();
        $ipKey = self::ipKey($username, $ip);
        $userKey = self::userKey($username);

        /** @var list<array{level: string, message: string, context: array<string, mixed>}> $logEvents */
        $logEvents = [];

        // SEC_REVIEW F34: log non-reversible fingerprints of the attempted
        // username and source IP rather than the raw values. Triage by
        // counting hits on a single fingerprint still works; a SIEM reader
        // no longer sees passwords typed in the username field or raw
        // client addresses.
        $usernameFp = LogIdentifier::fingerprint($username);
        $sourceIpFp = LogIdentifier::fingerprint($ip);

        $this->store->mutate(static function (array $state) use (
            $now,
            $ipKey,
            $userKey,
            $usernameFp,
            $sourceIpFp,
            &$logEvents,
        ): array {
            $ipEntry = $state['ip'][$ipKey] ?? ['count' => 0, 'lockedUntil' => 0];
            $ipEntry['count']++;
            $ipLock = self::ipLockoutSecondsForAttempt($ipEntry['count']);
            if ($ipLock > 0) {
                $ipEntry['lockedUntil'] = $now + $ipLock;
                $logEvents[] = [
                    'level' => 'error',
                    'message' => 'local login lockout triggered',
                    'context' => [
                        'bucket' => 'ip',
                        'username_fp' => $usernameFp,
                        'source_ip_fp' => $sourceIpFp,
                        'failure_count' => $ipEntry['count'],
                        'lock_seconds' => $ipLock,
                    ],
                ];
            } elseif ($ipEntry['count'] === 1) {
                // SEC_REVIEW F74: log-rate cap. Emit a warning at most
                // once per (username, ip) bucket — the FIRST failure
                // — then stay silent until either the window resets
                // or a lockout fires (which always emits the error
                // above). The lockout's `failure_count` field
                // captures the total burst size, so the per-attempt
                // visibility is preserved at aggregate without 4×
                // log lines per IP and 24× per username.
                $logEvents[] = [
                    'level' => 'warning',
                    'message' => 'local login failure',
                    'context' => [
                        'bucket' => 'ip',
                        'username_fp' => $usernameFp,
                        'source_ip_fp' => $sourceIpFp,
                        'failure_count' => $ipEntry['count'],
                    ],
                ];
            }
            $state['ip'][$ipKey] = $ipEntry;

            $userEntry = $state['username'][$userKey] ?? ['count' => 0, 'lockedUntil' => 0];
            $userEntry['count']++;
            $userLock = self::usernameLockoutSecondsForAttempt($userEntry['count']);
            if ($userLock > 0) {
                $userEntry['lockedUntil'] = $now + $userLock;
                $logEvents[] = [
                    'level' => 'error',
                    'message' => 'local login lockout triggered',
                    'context' => [
                        'bucket' => 'username',
                        'username_fp' => $usernameFp,
                        'source_ip_fp' => $sourceIpFp,
                        'failure_count' => $userEntry['count'],
                        'lock_seconds' => $userLock,
                    ],
                ];
            }
            $state['username'][$userKey] = $userEntry;

            return $state;
        });

        foreach ($logEvents as $ev) {
            if ($ev['level'] === 'error') {
                $this->logger->error($ev['message'], $ev['context']);
            } else {
                $this->logger->warning($ev['message'], $ev['context']);
            }
        }
    }

    public function clear(string $username, string $ip): void
    {
        $ipKey = self::ipKey($username, $ip);
        $userKey = self::userKey($username);
        $this->store->mutate(static function (array $state) use ($ipKey, $userKey): array {
            unset($state['ip'][$ipKey], $state['username'][$userKey]);

            return $state;
        });
    }

    /**
     * Reset all entries. Used by tests; not used at runtime (a container
     * restart is the documented unlock path).
     */
    public function reset(): void
    {
        $this->store->reset();
    }

    /**
     * @param array<string, array{count: int, lockedUntil: int}> $bucket
     */
    private function remaining(array $bucket, string $key, int $now): int
    {
        $entry = $bucket[$key] ?? null;
        if ($entry === null || $entry['lockedUntil'] <= $now) {
            return 0;
        }

        return $entry['lockedUntil'] - $now;
    }

    /**
     * Per-IP lockout duration; 0 means no lockout at this count yet.
     */
    private static function ipLockoutSecondsForAttempt(int $count): int
    {
        if ($count >= 15) {
            return 1800;
        }
        if ($count >= 10) {
            return 300;
        }
        if ($count >= 5) {
            return 60;
        }

        return 0;
    }

    /**
     * Per-username (cross-IP) lockout duration; thresholds are higher than
     * per-IP because this bucket is global and a too-tight ladder would let
     * an attacker DoS the legit admin trivially.
     */
    private static function usernameLockoutSecondsForAttempt(int $count): int
    {
        if ($count >= 100) {
            return 1800;
        }
        if ($count >= 50) {
            return 300;
        }
        if ($count >= 25) {
            return 60;
        }

        return 0;
    }

    private static function ipKey(string $username, string $ip): string
    {
        // Lower-case the username so casing doesn't multiply buckets, but
        // keep the IP byte-for-byte (v6 is case-sensitive in canonical form
        // already).
        return strtolower($username) . '|' . $ip;
    }

    private static function userKey(string $username): string
    {
        return strtolower($username);
    }
}
