<?php

declare(strict_types=1);

namespace PpPhp\Core;

/**
 * One-time password-reset codes for platform accounts.
 *
 * A request creates a single active code per user: a 6-digit OTP the user can
 * type, plus a long random token used by the emailed link. Both point at the
 * same record, so either route works.
 *
 * Security notes:
 *   • Only hashes are stored — a leaked database row cannot be replayed.
 *   • Codes expire (default 30 minutes) and are single-use.
 *   • Requesting a new code invalidates any previous one for that user.
 *   • Attempts are capped, so a 6-digit code can't be brute-forced.
 */
class PasswordReset
{
    public const TTL_MINUTES  = 30;
    private const MAX_ATTEMPTS = 6;

    /** Registered with the installer so fresh installs get the table up front. */
    public static function schemaSql(): string
    {
        return "CREATE TABLE IF NOT EXISTS `password_resets` (
            `id` INT AUTO_INCREMENT PRIMARY KEY,
            `user_id` INT NOT NULL,
            `token_hash` VARCHAR(255) NOT NULL,
            `otp_hash` VARCHAR(255) NOT NULL,
            `attempts` INT NOT NULL DEFAULT 0,
            `used_at` DATETIME DEFAULT NULL,
            `expires_at` DATETIME NOT NULL,
            `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            INDEX `idx_pr_user` (`user_id`),
            INDEX `idx_pr_expires` (`expires_at`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
    }

    /** Also created on first use, so existing installs upgrade without a re-install. */
    public static function ensureSchema(): void
    {
        try {
            DB::Init();
            DB::SetConnection();
            DB::Exec(self::schemaSql());
        } catch (\Throwable $e) {
            // Best effort — the caller reports a friendly failure.
        }
    }

    /**
     * Issue a fresh code for a user, invalidating any earlier one.
     *
     * @return array{otp:string,token:string}
     */
    public static function issue(int $userId): array
    {
        self::ensureSchema();
        self::invalidateFor($userId);

        $otp   = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
        $token = bin2hex(random_bytes(32));

        // Expiry is computed BY THE DATABASE, never by PHP. The two clocks can
        // sit in different timezones (PHP on UTC, MySQL on local time is common),
        // and a PHP-written timestamp compared against MySQL's NOW() would make
        // every code look instantly expired.
        DB::Query(
            'INSERT INTO `password_resets` (`user_id`, `token_hash`, `otp_hash`, `expires_at`)
             VALUES (?, ?, ?, NOW() + INTERVAL ' . self::TTL_MINUTES . ' MINUTE)',
            [$userId, hash('sha256', $token), password_hash($otp, PASSWORD_DEFAULT)]
        );

        return ['otp' => $otp, 'token' => $token];
    }

    /** Find the live record for an emailed link token. */
    public static function findByToken(string $token): ?array
    {
        if ($token === '') {
            return null;
        }
        self::ensureSchema();
        $rows = DB::FetchAll(
            'SELECT * FROM `password_resets` WHERE `token_hash` = ? AND `used_at` IS NULL AND `expires_at` > NOW() ORDER BY id DESC LIMIT 1',
            [hash('sha256', $token)]
        );
        return $rows[0] ?? null;
    }

    /**
     * Verify a typed OTP for an account. Returns the record on success.
     * Wrong guesses are counted; too many burn the code.
     */
    public static function verifyOtp(int $userId, string $otp): ?array
    {
        self::ensureSchema();
        $rows = DB::FetchAll(
            'SELECT * FROM `password_resets` WHERE `user_id` = ? AND `used_at` IS NULL AND `expires_at` > NOW() ORDER BY id DESC LIMIT 1',
            [$userId]
        );
        $row = $rows[0] ?? null;
        if ($row === null) {
            return null;
        }
        if ((int) $row['attempts'] >= self::MAX_ATTEMPTS) {
            return null;
        }
        if (!password_verify($otp, (string) $row['otp_hash'])) {
            DB::Update('password_resets', ['attempts' => (int) $row['attempts'] + 1], ['id' => $row['id']]);
            return null;
        }
        return $row;
    }

    /** Burn a code once it has done its job. */
    public static function consume(int $id): void
    {
        // Stamped by the database, for the same clock-skew reason as issue().
        DB::Query('UPDATE `password_resets` SET `used_at` = NOW() WHERE `id` = ?', [$id]);
    }

    /** Drop any outstanding codes for a user. */
    public static function invalidateFor(int $userId): void
    {
        try {
            DB::Query('DELETE FROM `password_resets` WHERE `user_id` = ? AND `used_at` IS NULL', [$userId]);
        } catch (\Throwable $e) {
            // Nothing to clean up.
        }
    }

    /** How many reset requests this address made recently (crude rate limit). */
    public static function recentRequests(int $userId, int $withinMinutes = 15): int
    {
        try {
            self::ensureSchema();
            $rows = DB::FetchAll(
                'SELECT COUNT(*) AS n FROM `password_resets` WHERE `user_id` = ? AND `created_at` > (NOW() - INTERVAL ? MINUTE)',
                [$userId, $withinMinutes]
            );
            return (int) ($rows[0]['n'] ?? 0);
        } catch (\Throwable $e) {
            return 0;
        }
    }
}
