<?php

declare(strict_types=1);

namespace Atdt;

/**
 * User domain API (SPEC §10.1, §10.2, §19.1–19.3).
 * Login throttling: 5 failures per IP+handle per 15 minutes, tracked as
 * security rows in `events` (pruned by cron; no extra table needed).
 */
final class Users
{
    // [ and ] allowed by sysop request (C64 import heritage); nothing more
    // exotic, because DOS doors receive the handle in drop files.
    public const HANDLE_RE = '/^[A-Za-z0-9 ._\[\]-]{3,20}$/';
    private const THROTTLE_MAX = 5;
    private const THROTTLE_WINDOW = 900; // 15 min

    private static ?string $dummyHash = null;

    /** Case-insensitive lookup (NOCASE / utf8mb4_unicode_ci collation). */
    public static function findByHandle(Db $db, string $handle): ?array
    {
        return $db->one('SELECT * FROM users WHERE handle = ?', [$handle]);
    }

    public static function handleExists(Db $db, string $handle): bool
    {
        return self::findByHandle($db, $handle) !== null;
    }

    /**
     * The prohibited-words gate for NEW handles (1.6.1): the sysop's list
     * (nua_banned_words, one word per line) refuses any application whose
     * handle CONTAINS a listed word, anywhere in the name and in any case
     * -- "test" catches Testicle, Test, and Youtestit alike. Existing
     * accounts are never touched: this runs only at the application desk.
     * Returns the word that matched, or null when the name passes.
     */
    public static function bannedWord(Settings $settings, string $handle): ?string
    {
        $hay = mb_strtolower($handle);
        foreach (preg_split('/[\r\n,]+/', (string)($settings->get('nua_banned_words', '') ?? '')) ?: [] as $word) {
            $w = mb_strtolower(trim($word));
            if ($w !== '' && mb_strpos($hay, $w) !== false) {
                return $w;
            }
        }
        return null;
    }

    /**
     * Matrix login attempt. Codes: ok | badlogin | pending | locked | throttled.
     * Unknown handle and wrong password intentionally share one code/string.
     *
     * @return array{code: string, user: ?array<string, mixed>}
     */
    public static function authenticate(Db $db, string $handle, string $pass, string $ip): array
    {
        $key = self::throttleKey($ip, $handle);
        $since = gmdate('Y-m-d\TH:i:s\Z', time() - self::THROTTLE_WINDOW);
        $fails = (int)$db->val(
            "SELECT COUNT(*) FROM events WHERE level = 'security' AND actor = ? AND at > ?",
            [$key, $since]
        );
        if ($fails >= self::THROTTLE_MAX) {
            return ['code' => 'throttled', 'user' => null];
        }

        $user = self::findByHandle($db, $handle);
        // Verify against a dummy hash when the handle is unknown so timing
        // doesn't reveal which handles exist (§19.2 spirit).
        $ok = password_verify($pass, $user['pass_hash'] ?? self::dummyHash());

        if ($user === null || !$ok || $user['status'] === 'deleted') {
            $db->insert('events', [
                'at' => now_utc(), 'level' => 'security', 'actor' => mb_substr($key, 0, 80),
                'msg' => 'Failed login for handle "' . mb_substr($handle, 0, 40) . '"',
            ]);
            if ($fails + 1 >= self::THROTTLE_MAX) {
                $db->insert('events', [
                    'at' => now_utc(), 'level' => 'security', 'actor' => 'lockout',
                    'msg' => 'Login lockout for ' . mb_substr($key, 0, 120) . ' (15 min)',
                ]);
            }
            return ['code' => 'badlogin', 'user' => null];
        }
        if ($user['status'] === 'pending') {
            return ['code' => 'pending', 'user' => null];
        }
        if ($user['status'] === 'locked') {
            return ['code' => 'locked', 'user' => null];
        }
        return ['code' => 'ok', 'user' => $user];
    }

    /**
     * The node this account is live on right now, or null (1.1.0: one
     * login per handle). "Live" means an open session that the reaper
     * would not take -- a browser gone for 90+ seconds does not block
     * its own next call.
     */
    public static function onlineNode(Db $db, int $userId, int $exceptSession = 0): ?int
    {
        $cutoff = gmdate('Y-m-d\TH:i:s\Z', time() - BbsSession::REAP_AFTER);
        $node = $db->val(
            'SELECT node FROM sessions WHERE user_id = ? AND active = 1 AND last_seen >= ? AND id != ?',
            [$userId, $cutoff, $exceptSession]
        );
        return is_numeric($node) ? (int)$node : null;
    }

    /**
     * Post-verification bookkeeping: calls++ and last_login. (Sessions are
     * untimed by owner decree — no daily clocks anywhere.)
     *
     * @param array<string, mixed> $user
     * @return array<string, mixed>
     */
    public static function recordLoginSuccess(Db $db, Settings $settings, array $user): array
    {
        $db->q(
            'UPDATE users SET calls = calls + 1, last_login = ? WHERE id = ?',
            [now_utc(), (int)$user['id']]
        );
        return self::findByHandle($db, (string)$user['handle']) ?? $user;
    }

    /**
     * Create a caller account (new user application, §10.2).
     * @return int new user id
     */
    public static function create(
        Db $db,
        string $handle,
        string $pass,
        string $location,
        string $note,
        string $status,
        string $email = ''
    ): int {
        $id = $db->insert('users', [
            'handle' => $handle,
            'pass_hash' => password_hash($pass, PASSWORD_DEFAULT),
            'level' => 10,
            'flags' => '',
            'status' => $status,
            'location' => $location,
            'note' => $note,
            'created_at' => now_utc(),
            'calls' => 0,
            'posts' => 0,
            'theme' => '', // follow the board's default theme
            'prefs' => '{}',
            'sig' => '',
            'email' => $email, // only the SysOp sees it (1.5.0)
            'last_bull' => 0,  // a new caller walks every bulletin at first logon
        ]);
        // 1.7.0 (import §14.6): an imported archive never greets a brand-new
        // caller as two hundred thousand unread messages. The seed watermark
        // covers exactly the imported history; organic posts (including
        // everything after the import) stay unread, the way they always were.
        try {
            $seed = json_decode((string)((new Settings($db))->get('import_seed', '{}') ?? '{}'), true) ?: [];
            foreach ($seed as $areaId => $num) {
                if ((int)$num > 0 && $db->one('SELECT id FROM message_areas WHERE id = ?', [(int)$areaId]) !== null) {
                    $db->insert('lastread', [
                        'user_id' => $id, 'area_id' => (int)$areaId, 'last_num' => (int)$num,
                    ]);
                }
            }
        } catch (\Throwable $e) {
            // seeding is a courtesy; account creation never fails over it
        }
        return $id;
    }

    /**
     * Arm a password-reset token for a caller (1.5.0): returns the raw
     * token to put in the link, storing only its hash. 20-minute life;
     * arming again replaces the old token. Refuses (null) inside the
     * 15-minute request throttle so the matrix cannot be used to hose
     * somebody's inbox.
     */
    public static function armReset(Db $db, array $user): ?string
    {
        $expires = (string)$user['reset_at'];
        if ($expires !== '' && strtotime($expires) > time() + 300) {
            return null; // a fresh token is already out; wait out the throttle
        }
        $token = bin2hex(random_bytes(20));
        $db->q(
            'UPDATE users SET reset_hash = ?, reset_at = ? WHERE id = ?',
            [hash('sha256', $token), gmdate('Y-m-d\TH:i:s\Z', time() + 20 * 60), (int)$user['id']]
        );
        return $token;
    }

    /** The account a live (unexpired) reset token belongs to, or null. */
    public static function byResetToken(Db $db, string $token): ?array
    {
        if (preg_match('/^[0-9a-f]{40}$/', $token) !== 1) {
            return null;
        }
        $u = $db->one('SELECT * FROM users WHERE reset_hash = ?', [hash('sha256', $token)]);
        if ($u === null || (string)$u['reset_at'] === '' || strtotime((string)$u['reset_at']) < time()) {
            return null;
        }
        return $u;
    }

    /** Burn the token and set the new password (single use). */
    public static function completeReset(Db $db, array $user, string $pass): void
    {
        $db->q(
            "UPDATE users SET pass_hash = ?, reset_hash = '', reset_at = '' WHERE id = ?",
            [password_hash($pass, PASSWORD_DEFAULT), (int)$user['id']]
        );
    }

    /** Read one users.prefs JSON key (beep, baud, area, …). */
    public static function getPref(Db $db, ?array $user, string $key, mixed $default = null): mixed
    {
        if ($user === null) {
            return $default;
        }
        $prefs = json_decode((string)($user['prefs'] ?? '{}'), true);
        return is_array($prefs) && array_key_exists($key, $prefs) ? $prefs[$key] : $default;
    }

    /** Write one users.prefs JSON key; returns the refreshed prefs JSON. */
    public static function setPref(Db $db, array $user, string $key, mixed $value): void
    {
        $prefs = json_decode((string)($user['prefs'] ?? '{}'), true);
        if (!is_array($prefs)) {
            $prefs = [];
        }
        $prefs[$key] = $value;
        $db->q('UPDATE users SET prefs = ? WHERE id = ?', [
            json_encode($prefs, JSON_UNESCAPED_UNICODE), (int)$user['id'],
        ]);
    }

    private static function throttleKey(string $ip, string $handle): string
    {
        return 'login:' . $ip . ':' . mb_strtolower(trim($handle));
    }

    private static function dummyHash(): string
    {
        if (self::$dummyHash === null) {
            self::$dummyHash = password_hash('ATDT-dummy-timing-pad', PASSWORD_DEFAULT);
        }
        return self::$dummyHash;
    }
}
