<?php

declare(strict_types=1);

namespace PpPhp\Core\Auth;

use PpPhp\Core\DB;

/**
 * Abuse protection for the authentication pages of a generated app.
 *
 * Covers the things a sign-in screen is actually attacked with:
 *   • password guessing        → failed-attempt lockout, per account AND per IP
 *   • scripted signup / spam   → per-action throttling
 *   • weak credentials         → password policy enforcement
 *   • account enumeration      → callers get one generic message to show
 *
 * Attempts live in a `auth_attempts` table rather than the session, because a
 * session-based limiter is worthless against brute force: the attacker simply
 * discards the cookie between tries. (`Security::RateLimit()` in core is
 * session-based and must not be relied on for this.)
 *
 * FAIL-OPEN BY DESIGN. If the table cannot be read or written, every check
 * returns "allowed". A throttle that fails closed hands anyone a denial of
 * service against the whole application by breaking one table, and locking
 * every user out of their own app is a worse outcome than a slow brute force
 * that the other protections still apply to.
 */
final class AuthGuard
{
    private const TABLE = 'auth_attempts';

    /** Runtime policy, overridden by the generated app's config. */
    private static array $policy = [];
    private static ?bool $ready = null;

    /** Defaults chosen to be safe for a small business app out of the box. */
    public static function defaults(): array
    {
        return [
            'lockout_enabled'   => true,
            'max_attempts'      => 5,     // failures before the account locks
            'attempt_window'    => 15,    // minutes the failures are counted over
            'lockout_minutes'   => 15,    // how long the lock lasts
            'ip_max_attempts'   => 20,    // failures from one IP across all accounts
            'guest_only'        => true,  // signed-in users bounce off auth pages
            'register_max'      => 5,     // signups per IP per window
            'register_window'   => 60,
            'forgot_max'        => 5,     // reset requests per IP per window
            'forgot_window'     => 60,
            'remember_days'     => 30,
            'generic_errors'    => true,  // never reveal whether an account exists
            'password_min_length'        => 8,
            'password_require_numbers'   => false,
            'password_require_mixed_case'=> false,
            'password_require_special'   => false,
            'password_max_length'        => 200,   // bcrypt only reads 72 bytes
            'password_block_common'      => true,  // reject the usual suspects
            'password_block_identity'    => true,  // reject name/email fragments
            'require_username'  => true,
            'require_email'     => true,
            'require_fullname'  => false,
            'email_domain_mode' => 'any',   // any | allow | block
            'email_domains'     => '',      // comma separated
            'bot_trap'          => true,    // honeypot + minimum fill time
            'min_fill_seconds'  => 2,
            'reset_token_hours' => 24,
            'enable_password_reset' => true,
            'case_insensitive_login' => false,
        ];
    }

    /** Install the project's policy (called from the generated app's runtime). */
    public static function configure(array $policy): void
    {
        self::$policy = array_merge(self::defaults(), array_filter(
            $policy,
            static fn($v) => $v !== null && $v !== ''
        ));
    }

    public static function policy(): array
    {
        return self::$policy !== [] ? self::$policy : self::defaults();
    }

    public static function get(string $key)
    {
        return self::policy()[$key] ?? null;
    }

    // ── storage ─────────────────────────────────────────────────────────
    /** Create the attempts table once per request; never throws. */
    private static function ready(): bool
    {
        if (self::$ready !== null) {
            return self::$ready;
        }
        try {
            DB::Exec('CREATE TABLE IF NOT EXISTS `' . self::TABLE . '` (
                `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                `kind` VARCHAR(32) NOT NULL,
                `identifier` VARCHAR(190) NOT NULL,
                `ip` VARCHAR(45) NOT NULL,
                `created_at` DATETIME NOT NULL,
                INDEX `idx_aa_lookup` (`kind`, `identifier`, `created_at`),
                INDEX `idx_aa_ip` (`kind`, `ip`, `created_at`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
            self::$ready = true;
        } catch (\Throwable $e) {
            self::$ready = false;
        }
        return self::$ready;
    }

    public static function ip(): string
    {
        // Only REMOTE_ADDR is trusted: X-Forwarded-For is attacker-controlled
        // unless a known proxy is in front, and trusting it would let anyone
        // reset their own rate limit by spoofing a header.
        return substr((string) ($_SERVER['REMOTE_ADDR'] ?? 'cli'), 0, 45);
    }

    /**
     * Timestamps are written with the DATABASE clock (NOW()), never PHP's.
     * The two can disagree by hours, and comparing across them would either
     * expire locks instantly or hold them far too long.
     */
    private static function record(string $kind, string $identifier): void
    {
        if (!self::ready()) return;
        try {
            DB::Query(
                'INSERT INTO `' . self::TABLE . '` (`kind`,`identifier`,`ip`,`created_at`) VALUES (?,?,?,NOW())',
                [$kind, mb_substr($identifier, 0, 190), self::ip()]
            );
        } catch (\Throwable $e) {
            // fail open
        }
    }

    private static function countSince(string $kind, string $column, string $value, int $minutes): int
    {
        if (!self::ready()) return 0;
        try {
            $col = $column === 'ip' ? 'ip' : 'identifier';
            $mins = max(1, $minutes);            // int we control — safe to inline
            return (int) DB::FetchOne(
                'SELECT COUNT(*) FROM `' . self::TABLE . '`
                  WHERE `kind` = ? AND `' . $col . '` = ? AND `created_at` >= (NOW() - INTERVAL ' . $mins . ' MINUTE)',
                [$kind, $value]
            );
        } catch (\Throwable $e) {
            return 0;
        }
    }

    // ── login lockout ───────────────────────────────────────────────────
    /**
     * Lock state for a sign-in attempt.
     *
     * @return array{locked:bool, retry_after:int, remaining:int, reason:string}
     */
    public static function loginLockState(string $username): array
    {
        $p = self::policy();
        $out = ['locked' => false, 'retry_after' => 0, 'remaining' => (int) $p['max_attempts'], 'reason' => ''];
        if (empty($p['lockout_enabled']) || !self::ready()) {
            return $out;
        }
        $user = mb_strtolower(trim($username));
        $window = (int) $p['attempt_window'];

        $byUser = $user !== '' ? self::countSince('login', 'identifier', $user, $window) : 0;
        $byIp   = self::countSince('login', 'ip', self::ip(), $window);

        $userLocked = $byUser >= (int) $p['max_attempts'];
        $ipLocked   = $byIp   >= (int) $p['ip_max_attempts'];

        if (!$userLocked && !$ipLocked) {
            $out['remaining'] = max(0, (int) $p['max_attempts'] - $byUser);
            return $out;
        }

        // Lock runs from the LAST failure, so continued guessing extends it.
        $lockMinutes = (int) $p['lockout_minutes'];
        try {
            $lockMinutes = max(1, $lockMinutes);   // int we control — safe to inline
            $seconds = (int) DB::FetchOne(
                'SELECT TIMESTAMPDIFF(SECOND, NOW(), `created_at` + INTERVAL ' . $lockMinutes . ' MINUTE)
                   FROM `' . self::TABLE . '`
                  WHERE `kind` = "login" AND ' . ($userLocked ? '`identifier` = ?' : '`ip` = ?') . '
               ORDER BY `created_at` DESC LIMIT 1',
                [$userLocked ? $user : self::ip()]
            );
        } catch (\Throwable $e) {
            return $out;                      // fail open
        }
        if ($seconds <= 0) {
            return $out;                      // lock has expired
        }
        return [
            'locked'      => true,
            'retry_after' => $seconds,
            'remaining'   => 0,
            'reason'      => $userLocked ? 'account' : 'ip',
        ];
    }

    public static function recordLoginFailure(string $username): void
    {
        self::record('login', mb_strtolower(trim($username)));
    }

    /** Clear an account's failures after a genuine sign-in. */
    public static function clearLoginFailures(string $username): void
    {
        if (!self::ready()) return;
        try {
            DB::Query(
                'DELETE FROM `' . self::TABLE . '` WHERE `kind` = "login" AND `identifier` = ?',
                [mb_strtolower(trim($username))]
            );
        } catch (\Throwable $e) {
            // fail open
        }
    }

    // ── generic per-IP throttle (register, password reset, …) ───────────
    public static function tooMany(string $action, int $max, int $windowMinutes): bool
    {
        if ($max <= 0 || !self::ready()) return false;
        return self::countSince($action, 'ip', self::ip(), $windowMinutes) >= $max;
    }

    public static function hit(string $action): void
    {
        self::record($action, self::ip());
    }

    // ── password policy ─────────────────────────────────────────────────
    /**
     * The 40 or so passwords that dominate every credential-stuffing list.
     * A deliberately small, embedded set: it costs nothing, needs no network
     * call, and blocks the overwhelming majority of trivially guessed choices.
     */
    private const COMMON = [
        'password','passw0rd','password1','password123','123456','12345678','123456789','1234567890',
        'qwerty','qwerty123','abc123','111111','000000','iloveyou','admin','admin123','administrator',
        'welcome','welcome1','letmein','monkey','dragon','sunshine','princess','football','baseball',
        'master','shadow','superman','trustno1','changeme','secret','pass123','test123','root',
        'login','guest','user','default','starwars','whatever',
    ];

    /**
     * @param string $password
     * @param array{username?:string,email?:string,fullname?:string} $identity
     * @return list<string> human-readable problems; empty means acceptable
     */
    public static function passwordProblems(string $password, array $identity = []): array
    {
        $p = self::policy();
        $problems = [];
        $min = max(1, (int) $p['password_min_length']);
        $max = max($min, (int) ($p['password_max_length'] ?? 200));
        if (mb_strlen($password) > $max) {
            // Also a real hashing concern: bcrypt silently ignores everything
            // past 72 bytes, so an unbounded field gives false confidence.
            $problems[] = "be no longer than {$max} characters";
        }
        if (mb_strlen($password) < $min) {
            $problems[] = "be at least {$min} characters long";
        }
        if (!empty($p['password_require_numbers']) && !preg_match('/\d/', $password)) {
            $problems[] = 'include a number';
        }
        if (!empty($p['password_require_mixed_case']) && !(preg_match('/[a-z]/', $password) && preg_match('/[A-Z]/', $password))) {
            $problems[] = 'include both upper and lower case letters';
        }
        if (!empty($p['password_require_special']) && !preg_match('/[^A-Za-z0-9]/', $password)) {
            $problems[] = 'include a symbol';
        }
        if (!empty($p['password_block_common']) && self::isCommon($password)) {
            $problems[] = 'not be a commonly used password';
        }
        if (!empty($p['password_block_identity']) && self::echoesIdentity($password, $identity)) {
            $problems[] = 'not contain your name or email address';
        }
        return $problems;
    }

    /** Case- and digit-suffix-insensitive membership test ("Password1!" counts). */
    private static function isCommon(string $password): bool
    {
        $p = mb_strtolower($password);
        $stripped = rtrim($p, '0123456789!@#$%^&*');
        foreach (self::COMMON as $bad) {
            if ($p === $bad || $stripped === $bad) return true;
        }
        return false;
    }

    /** Does the password echo the user's own identifiers? */
    private static function echoesIdentity(string $password, array $identity): bool
    {
        $p = mb_strtolower($password);
        if ($p === '') return false;
        $parts = [];
        foreach (['username', 'fullname'] as $k) {
            foreach (preg_split('/[\s._-]+/', (string) ($identity[$k] ?? '')) ?: [] as $bit) {
                if (mb_strlen($bit) >= 4) $parts[] = mb_strtolower($bit);
            }
        }
        $email = (string) ($identity['email'] ?? '');
        if ($email !== '') {
            $local = mb_strtolower((string) strstr($email, '@', true));
            if (mb_strlen($local) >= 4) $parts[] = $local;
        }
        foreach (array_unique($parts) as $bit) {
            if (str_contains($p, $bit)) return true;
        }
        return false;
    }

    /** Is this email address acceptable under the domain rules? */
    public static function emailAllowed(string $email): bool
    {
        $p = self::policy();
        $mode = (string) ($p['email_domain_mode'] ?? 'any');
        $list = array_values(array_filter(array_map(
            static fn($d) => mb_strtolower(trim((string) $d, " \t.@")),
            preg_split('/[,;\s]+/', (string) ($p['email_domains'] ?? '')) ?: []
        )));
        if ($mode === 'any' || $list === []) return true;
        $domain = mb_strtolower((string) substr((string) strrchr($email, '@'), 1));
        if ($domain === '') return false;
        // A listed domain also covers its subdomains (mail.example.com ⊂ example.com)
        $hit = false;
        foreach ($list as $d) {
            if ($domain === $d || str_ends_with($domain, '.' . $d)) { $hit = true; break; }
        }
        return $mode === 'allow' ? $hit : !$hit;
    }

    /**
     * Bot trap for public forms: a field humans never see plus a minimum time
     * on the page. Costs a real user nothing and stops naive form-stuffers
     * without a third-party CAPTCHA or any external request.
     */
    public static function botTrapFields(): string
    {
        if (empty(self::policy()['bot_trap'])) return '';
        $ts = time();
        return '<div aria-hidden="true" style="position:absolute;left:-9999px;top:-9999px;height:0;overflow:hidden">'
            . '<label>Leave this field empty<input type="text" name="pp_hp" tabindex="-1" autocomplete="off" value=""></label>'
            . '</div><input type="hidden" name="pp_ts" value="' . $ts . '">';
    }

    /** True when the submission looks automated. */
    public static function looksAutomated(array $post): bool
    {
        $p = self::policy();
        if (empty($p['bot_trap'])) return false;
        if (trim((string) ($post['pp_hp'] ?? '')) !== '') return true;      // filled the invisible field
        $ts = (int) ($post['pp_ts'] ?? 0);
        $min = max(0, (int) ($p['min_fill_seconds'] ?? 2));
        if ($ts <= 0) return false;                 // no stamp — do not punish
        return (time() - $ts) < $min;               // submitted implausibly fast
    }

    /** One sentence suitable for showing next to the password field. */
    public static function passwordRule(): string
    {
        $p = self::policy();
        $bits = ['at least ' . max(1, (int) $p['password_min_length']) . ' characters'];
        if (!empty($p['password_require_numbers']))    $bits[] = 'a number';
        if (!empty($p['password_require_mixed_case'])) $bits[] = 'upper and lower case';
        if (!empty($p['password_require_special']))    $bits[] = 'a symbol';
        return 'Use ' . implode(', ', $bits) . '.';
    }

    public static function passwordError(string $password, array $identity = []): string
    {
        $problems = self::passwordProblems($password, $identity);
        return $problems === [] ? '' : 'Password must ' . implode(', ', $problems) . '.';
    }

    /** Friendly "try again in …" text for a lockout. */
    public static function retryText(int $seconds): string
    {
        if ($seconds >= 120)  return 'about ' . (int) ceil($seconds / 60) . ' minutes';
        if ($seconds > 60)    return 'about a minute';
        return $seconds . ' seconds';
    }

    /** Housekeeping: drop rows older than a day. Safe to call often. */
    public static function prune(): void
    {
        if (!self::ready()) return;
        try {
            DB::Exec('DELETE FROM `' . self::TABLE . '` WHERE `created_at` < (NOW() - INTERVAL 1 DAY)');
        } catch (\Throwable $e) {
            // fail open
        }
    }
}
