<?php
/*
 * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the LICENSE file in the project root for the full license text.
 */

declare(strict_types=1);

namespace App\Repositories;

use DateTimeImmutable;
use DateTimeZone;
use PDO;

/**
 * Per-(ip, email) brute-force throttle for the local-admin login path
 * (R01-N06). The table is the single source of truth — process restarts
 * and parallel workers see the same counter.
 *
 * Policy is in `computeLockout()`:
 *   1-4 attempts within the window → no lock
 *   5-9                            → lock 5 minutes
 *   10-19                          → lock 30 minutes
 *   20+                            → lock 60 minutes
 *
 * The window is `WINDOW_SECONDS` (15 min) of *no failures*. After a
 * quiet stretch the counter resets to 1 on the next failure, so an
 * honest user who later fat-fingers their password isn't penalised
 * forever. A successful sign-in clears the row outright.
 *
 * Time is injected by the caller so unit tests don't have to sleep.
 */
final class AuthThrottleRepository
{
    public const WINDOW_SECONDS = 900;

    public function __construct(private readonly PDO $pdo)
    {
    }

    /**
     * Returns the active lock end time for ($ip, $email) — or null if
     * there is no row, or the row's lock has already expired relative
     * to $now.
     */
    public function lockoutFor(string $ip, string $email, DateTimeImmutable $now): ?DateTimeImmutable
    {
        $row = $this->fetch($ip, $email);
        if ($row === null || $row['locked_until'] === null) {
            return null;
        }
        $until = self::parse((string) $row['locked_until']);
        return $until > $now ? $until : null;
    }

    /**
     * Increment the failure counter and (re)compute the lockout. Returns
     * the new lock end (null when below the first threshold).
     */
    public function recordFailure(string $ip, string $email, DateTimeImmutable $now): ?DateTimeImmutable
    {
        $row = $this->fetch($ip, $email);

        $attempts = 1;
        $first    = $now;
        if ($row !== null) {
            $last     = self::parse((string) $row['last_failure_at']);
            $idleSecs = $now->getTimestamp() - $last->getTimestamp();
            if ($idleSecs > self::WINDOW_SECONDS) {
                // Window stale → start a fresh counter.
                $attempts = 1;
                $first    = $now;
            } else {
                $attempts = ((int) $row['attempts']) + 1;
                $first    = self::parse((string) $row['first_failure_at']);
            }
        }

        $lockedUntil = self::computeLockout($attempts, $now);
        $this->upsert($ip, $email, $attempts, $first, $now, $lockedUntil);
        return $lockedUntil;
    }

    /** Drop the throttle row on a successful sign-in. */
    public function clear(string $ip, string $email): void
    {
        $stmt = $this->pdo->prepare(
            'DELETE FROM auth_throttle WHERE ip_address = ? AND email = ?'
        );
        $stmt->execute([self::keyIp($ip), self::keyEmail($email)]);
    }

    /** Housekeeping helper; not wired into a cron yet. */
    public function purgeExpired(DateTimeImmutable $olderThan): int
    {
        $stmt = $this->pdo->prepare(
            'DELETE FROM auth_throttle
             WHERE last_failure_at < ?
               AND (locked_until IS NULL OR locked_until <= ?)'
        );
        $stmt->execute([self::format($olderThan), self::format($olderThan)]);
        return $stmt->rowCount();
    }

    /**
     * Pure policy: maps the post-increment attempt count to a lock-end
     * time. Extracted so tests can pin the matrix without DB.
     */
    public static function computeLockout(int $attempts, DateTimeImmutable $now): ?DateTimeImmutable
    {
        if ($attempts < 5) {
            return null;
        }
        if ($attempts < 10) {
            return $now->modify('+5 minutes');
        }
        if ($attempts < 20) {
            return $now->modify('+30 minutes');
        }
        return $now->modify('+1 hour');
    }

    /** @return array<string,mixed>|null */
    private function fetch(string $ip, string $email): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM auth_throttle WHERE ip_address = ? AND email = ?'
        );
        $stmt->execute([self::keyIp($ip), self::keyEmail($email)]);
        $row = $stmt->fetch();
        return is_array($row) ? $row : null;
    }

    private function upsert(
        string $ip,
        string $email,
        int $attempts,
        DateTimeImmutable $first,
        DateTimeImmutable $last,
        ?DateTimeImmutable $lockedUntil,
    ): void {
        $stmt = $this->pdo->prepare(
            'INSERT INTO auth_throttle
                 (ip_address, email, attempts, first_failure_at, last_failure_at, locked_until)
             VALUES (?, ?, ?, ?, ?, ?)
             ON CONFLICT(ip_address, email) DO UPDATE SET
                 attempts         = excluded.attempts,
                 first_failure_at = excluded.first_failure_at,
                 last_failure_at  = excluded.last_failure_at,
                 locked_until     = excluded.locked_until'
        );
        $stmt->execute([
            self::keyIp($ip),
            self::keyEmail($email),
            $attempts,
            self::format($first),
            self::format($last),
            $lockedUntil === null ? null : self::format($lockedUntil),
        ]);
    }

    private static function keyIp(string $ip): string
    {
        return trim($ip);
    }

    private static function keyEmail(string $email): string
    {
        return strtolower(trim($email));
    }

    private static function format(DateTimeImmutable $dt): string
    {
        return $dt->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z');
    }

    private static function parse(string $s): DateTimeImmutable
    {
        return new DateTimeImmutable($s);
    }
}
