<?php

namespace KeyLair;

/**
 * "Forgot password" flow. Only ever stores a hash of the reset token —
 * the raw token exists solely in the emailed link — and never reveals
 * whether a given username/email actually matches an account, to avoid
 * leaking which accounts exist.
 */
class PasswordReset
{
    private const TOKEN_LIFETIME_SECONDS = 3600;

    /**
     * Looks up the account by username or email and, if found, emails a
     * reset link plus a username reminder. Always safe to call with
     * untrusted input; never throws or reveals whether a match was found.
     */
    public static function request(string $usernameOrEmail): void
    {
        if (!Settings::passwordResetEnabled()) {
            return;
        }

        $pdo = Database::connection();
        $stmt = $pdo->prepare('SELECT * FROM admin_users WHERE (username = :u1 OR email = :u2) AND is_active = 1 LIMIT 1');
        $stmt->execute(['u1' => $usernameOrEmail, 'u2' => $usernameOrEmail]);
        $admin = $stmt->fetch();

        // Demo accounts can't have their password changed even by their own
        // owner — including via "forgot password" — same as the account
        // page itself. Treated the same as "no match" so this endpoint never
        // reveals a demo account's existence either.
        if (!$admin || $admin['role'] === 'demo') {
            return;
        }

        $rawToken = Helpers::randomToken(32);
        $tokenHash = hash('sha256', $rawToken);

        // expires_at is computed by MySQL itself (NOW() + INTERVAL ...) rather
        // than in PHP, so it's never compared against a differently-configured
        // clock/timezone later in validate()'s "expires_at > NOW()" check —
        // mixing a PHP-computed timestamp with MySQL's NOW() would silently
        // show a token as already expired if the two disagree on timezone.
        $insert = $pdo->prepare(
            'INSERT INTO admin_password_resets (admin_user_id, token_hash, expires_at, created_at)
             VALUES (:admin_id, :hash, NOW() + INTERVAL :seconds SECOND, NOW())'
        );
        $insert->execute([
            'admin_id' => $admin['id'],
            'hash' => $tokenHash,
            'seconds' => self::TOKEN_LIFETIME_SECONDS,
        ]);

        $resetUrl = Helpers::baseUrl() . '/admin/reset_password.php?token=' . $rawToken;
        $siteName = Settings::siteName();

        $body = "Someone (hopefully you) requested a password reset for your {$siteName} admin account.\n\n"
            . "Your username is: {$admin['username']}\n\n"
            . "To choose a new password, open this link within the next hour:\n{$resetUrl}\n\n"
            . "If you didn't request this, you can safely ignore this email — your password won't change.";

        Mailer::send($admin['email'], "Reset your {$siteName} password", $body);
        AuditLog::log((int) $admin['id'], 'password_reset_requested', null);
    }

    /**
     * Admin-initiated variant of request() for the Users page's "Reset
     * Password" action: looked up by id (never ambiguous, unlike a typed
     * username/email) and, unlike the public "forgot password" form,
     * deliberately NOT gated by Settings::passwordResetEnabled() -- that
     * toggle is about whether an anonymous visitor on the login page can
     * request a reset themselves, a different trust boundary than an
     * already-authenticated, permission-checked admin explicitly resetting
     * a colleague's password. Never called for a demo account -- its
     * placeholder @demo.local address receives nothing, so the caller
     * resets its password directly instead. Returns whether the mail
     * handoff itself succeeded, so the caller can tell the admin if it
     * didn't.
     */
    public static function requestForAccountId(int $adminId): bool
    {
        $pdo = Database::connection();
        $stmt = $pdo->prepare('SELECT * FROM admin_users WHERE id = :id LIMIT 1');
        $stmt->execute(['id' => $adminId]);
        $admin = $stmt->fetch();
        if (!$admin) {
            return false;
        }

        $rawToken = Helpers::randomToken(32);
        $tokenHash = hash('sha256', $rawToken);

        $insert = $pdo->prepare(
            'INSERT INTO admin_password_resets (admin_user_id, token_hash, expires_at, created_at)
             VALUES (:admin_id, :hash, NOW() + INTERVAL :seconds SECOND, NOW())'
        );
        $insert->execute([
            'admin_id' => $admin['id'],
            'hash' => $tokenHash,
            'seconds' => self::TOKEN_LIFETIME_SECONDS,
        ]);

        $resetUrl = Helpers::baseUrl() . '/admin/reset_password.php?token=' . $rawToken;
        $siteName = Settings::siteName();

        $body = "An administrator reset your {$siteName} admin account password.\n\n"
            . "Your username is: {$admin['username']}\n\n"
            . "To choose a new password, open this link within the next hour:\n{$resetUrl}\n\n"
            . "If you weren't expecting this, contact whoever administers your {$siteName} installation.";

        $sent = Mailer::send($admin['email'], "Your {$siteName} password was reset", $body);
        AuditLog::log($admin['id'], 'password_reset_requested_by_admin', null);

        return $sent;
    }

    /** Returns the matching admin row for a valid, unexpired, unused token, or null. */
    public static function validate(string $rawToken): ?array
    {
        $pdo = Database::connection();
        $tokenHash = hash('sha256', $rawToken);

        $stmt = $pdo->prepare(
            'SELECT r.*, u.username, u.email
             FROM admin_password_resets r
             JOIN admin_users u ON u.id = r.admin_user_id
             WHERE r.token_hash = :hash AND r.used_at IS NULL AND r.expires_at > NOW()
             LIMIT 1'
        );
        $stmt->execute(['hash' => $tokenHash]);
        $row = $stmt->fetch();

        return $row ?: null;
    }

    /**
     * Sets the new password if the token is still valid, marks it (and any
     * other outstanding tokens for the same account) used, and returns
     * whether it succeeded.
     */
    public static function consume(string $rawToken, string $newPassword): bool
    {
        $reset = self::validate($rawToken);
        if (!$reset) {
            return false;
        }

        $pdo = Database::connection();
        $pdo->beginTransaction();
        try {
            $update = $pdo->prepare('UPDATE admin_users SET password_hash = :hash WHERE id = :id');
            $update->execute([
                'hash' => password_hash($newPassword, PASSWORD_DEFAULT),
                'id' => $reset['admin_user_id'],
            ]);

            // Invalidate every outstanding reset token for this account, not
            // just the one used — a stale link from an earlier request
            // should stop working once the password has actually changed.
            $invalidate = $pdo->prepare(
                'UPDATE admin_password_resets SET used_at = NOW() WHERE admin_user_id = :id AND used_at IS NULL'
            );
            $invalidate->execute(['id' => $reset['admin_user_id']]);

            $pdo->commit();
        } catch (\Throwable $e) {
            $pdo->rollBack();
            throw $e;
        }

        AuditLog::log((int) $reset['admin_user_id'], 'password_reset_completed', null);
        return true;
    }
}
