<?php

declare(strict_types=1);

namespace App\Auth;

use Psr\Log\LoggerInterface;

/**
 * File-backed {@see ThrottleStore}. Persists the per-IP and per-username
 * lockout counters to a JSON file on the container's writable layer
 * (e.g. `/tmp/irdb_login_throttle.json`).
 *
 * **Why this exists (SEC_REVIEW F6):** the previous in-memory store lived
 * inside the FrankenPHP worker process. Worker recycling — on memory
 * pressure, fixed request counts, or a crash — wiped counters and gave
 * the attacker fresh attempts; multi-worker FrankenPHP also let counters
 * drift across N parallel workers. Persisting to a file shared by all
 * workers in the container fixes both.
 *
 * **Concurrency:** all mutations take an exclusive `flock()` on a
 * sibling lock file (`<path>.lock`) to serialise read-modify-write across
 * workers. Writes go to a random temp file in the same directory and are
 * `rename()`d into place under the lock — readers therefore see either
 * the previous or the next snapshot, never a torn file.
 *
 * **Lifetime:** the file lives on the container's ephemeral writable
 * layer (no shared volume), so a container restart still clears it —
 * the documented operator-unlock path.
 *
 * **Multi-replica caveat:** each replica keeps its own file. If the
 * `ui` service is scaled, run the LB in sticky mode (the SPEC's
 * documented topology); see {@see LoginThrottle} for details.
 *
 * **GC:** entries whose `lockedUntil` has been past for more than
 * {@see self::STALE_LOCK_SECONDS} are dropped at the next mutation. This
 * bounds disk growth from one-shot username/IP combinations without
 * shortening the documented in-window lockout ladder.
 */
final class FileThrottleStore implements ThrottleStore
{
    /**
     * Drop lock entries whose `lockedUntil` is older than `now - 24h`.
     * Longer than the steepest 30-minute ladder step, so it never
     * shortens an active lockout.
     */
    private const STALE_LOCK_SECONDS = 86400;

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

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

    public function load(): array
    {
        $raw = @file_get_contents($this->filePath);
        if ($raw === false || $raw === '') {
            return ['ip' => [], 'username' => []];
        }

        return $this->decode($raw);
    }

    public function mutate(callable $fn): void
    {
        $dir = dirname($this->filePath);
        if (!is_dir($dir) && !@mkdir($dir, 0o700, true) && !is_dir($dir)) {
            throw new \RuntimeException('throttle store directory unavailable: ' . $dir);
        }

        $lockPath = $this->filePath . '.lock';
        $lockFh = @fopen($lockPath, 'c');
        if ($lockFh === false) {
            throw new \RuntimeException('throttle store lock open failed: ' . $lockPath);
        }
        try {
            if (!flock($lockFh, LOCK_EX)) {
                throw new \RuntimeException('throttle store lock acquisition failed');
            }
            try {
                $current = $this->load();
                $next = $fn($current);
                $next = [
                    'ip' => $this->gc($next['ip']),
                    'username' => $this->gc($next['username']),
                ];
                $payload = json_encode($next, JSON_THROW_ON_ERROR);
                $tmp = $this->filePath . '.tmp.' . bin2hex(random_bytes(4));
                if (file_put_contents($tmp, $payload) === false) {
                    throw new \RuntimeException('throttle store temp write failed: ' . $tmp);
                }
                if (!@rename($tmp, $this->filePath)) {
                    @unlink($tmp);
                    throw new \RuntimeException('throttle store rename failed: ' . $this->filePath);
                }
            } finally {
                flock($lockFh, LOCK_UN);
            }
        } finally {
            fclose($lockFh);
        }
    }

    public function reset(): void
    {
        @unlink($this->filePath);
        @unlink($this->filePath . '.lock');
    }

    /**
     * @return array{
     *     ip: array<string, array{count: int, lockedUntil: int}>,
     *     username: array<string, array{count: int, lockedUntil: int}>
     * }
     */
    private function decode(string $raw): array
    {
        try {
            /** @var mixed $decoded */
            $decoded = json_decode($raw, true, 16, JSON_THROW_ON_ERROR);
        } catch (\JsonException $e) {
            $this->logger->warning('throttle store decode failed; resetting', ['error' => $e->getMessage()]);

            return ['ip' => [], 'username' => []];
        }
        if (!is_array($decoded)) {
            return ['ip' => [], 'username' => []];
        }

        return [
            'ip' => $this->normaliseBucket($decoded['ip'] ?? null),
            'username' => $this->normaliseBucket($decoded['username'] ?? null),
        ];
    }

    /**
     * @param array<string, array{count: int, lockedUntil: int}> $bucket
     *
     * @return array<string, array{count: int, lockedUntil: int}>
     */
    private function gc(array $bucket): array
    {
        $now = ($this->timeFn)();
        $out = [];
        foreach ($bucket as $key => $entry) {
            if ($entry['lockedUntil'] > 0 && ($entry['lockedUntil'] + self::STALE_LOCK_SECONDS) < $now) {
                continue;
            }
            $out[$key] = $entry;
        }

        return $out;
    }

    /**
     * @return array<string, array{count: int, lockedUntil: int}>
     */
    private function normaliseBucket(mixed $raw): array
    {
        if (!is_array($raw)) {
            return [];
        }
        $out = [];
        foreach ($raw as $key => $entry) {
            if (
                is_string($key)
                && is_array($entry)
                && isset($entry['count'], $entry['lockedUntil'])
            ) {
                $out[$key] = [
                    'count' => (int) $entry['count'],
                    'lockedUntil' => (int) $entry['lockedUntil'],
                ];
            }
        }

        return $out;
    }
}
