<?php
require_once __DIR__ . '/PasswordPolicy.php';

class Auth
{
    private PDO $pdo;

    /**
     * After this many failed attempts against one account within the
     * lockout window, sign-in is blocked for that account regardless of
     * whether the password submitted is correct — protects a specific
     * account from being brute-forced even from many different IPs.
     * Paired with RateLimiter's IP-scoped throttle in api/auth/sign-in.php,
     * which protects against a single IP spraying many different accounts.
     */
    private const LOCKOUT_THRESHOLD = 6;
    private const LOCKOUT_WINDOW_SECONDS = 900; // 15 minutes

    /**
     * A real, precomputed Argon2id hash of a value nobody will ever type.
     * Used to run password_verify() against when the email lookup finds
     * no user (or the account is locked), so that path costs roughly the
     * same CPU time as a real check — otherwise the "user not found" case
     * returns near-instantly while a real check takes tens of
     * milliseconds, and that timing gap is enough for an attacker to
     * distinguish "this email exists" from "it doesn't" without ever
     * seeing a different error message.
     */
    private const DUMMY_HASH = '$argon2id$v=19$m=65536,t=4,p=1$Y0FrLlhDUWlhSTJ0MTBYTw$eFfamSDWppL7thwmZ4xVfg1DU4bWzvjQT9Q/5GPT8lM';

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    public function signUp(string $email, string $password, array $onboardingContext = []): array
    {
        $email = strtolower(trim($email));

        if (!filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 255) {
            return ['error' => 'invalid_email', 'message' => 'Please enter a valid email address.'];
        }

        $passwordErrors = PasswordPolicy::validate($password, ['email' => $email]);
        if (!empty($passwordErrors)) {
            return ['error' => 'weak_password', 'message' => implode(' ', $passwordErrors)];
        }

        $stmt = $this->pdo->prepare('SELECT id FROM users WHERE email = :email AND deleted_at IS NULL');
        $stmt->execute([':email' => $email]);
        if ($stmt->fetch()) {
            return ['error' => 'email_taken', 'message' => 'An account with this email already exists.'];
        }

        $userId = ScopedDb::uuid();
        $passwordHash = password_hash($password, PASSWORD_ARGON2ID, PasswordPolicy::hashOptions());

        $this->pdo->beginTransaction();
        try {
            $stmt = $this->pdo->prepare('INSERT INTO users (id, email, password_hash) VALUES (:id, :email, :hash)');
            $stmt->execute([':id' => $userId, ':email' => $email, ':hash' => $passwordHash]);

            // BETA policy (config/config.php DEFAULT_SIGNUP_TIER): every new
            // account is granted Elite during usability testing, not the
            // column's own 'free' default. See config.php for the full
            // rationale and the single switch that reverts this at launch.
            $stmt = $this->pdo->prepare('INSERT INTO user_profiles (user_id, tier, onboarding_context) VALUES (:uid, :tier, :ctx)');
            $stmt->execute([':uid' => $userId, ':tier' => DEFAULT_SIGNUP_TIER, ':ctx' => json_encode($onboardingContext)]);

            $this->pdo->commit();
            // 2026-08-19 security fix (pre-launch audit finding M-3,
            // closed in full): no longer auto-logs the new account in.
            // Immediately starting a real session here — versus doing
            // nothing at all for the 'email_taken' branch just above —
            // was the residual signal that survived the first M-3 fix
            // (which only closed the response TEXT leak): a scripted
            // enumeration probe could still tell the two cases apart by
            // checking for a Set-Cookie header / an authenticated
            // redirect target, even with an identical error message.
            // api/auth/sign-up.php now redirects BOTH the success and
            // email_taken cases to the exact same URL with the exact
            // same message and no session change either way, so this
            // response is unconditionally observation-identical to a
            // duplicate-email attempt. The account is real and fully
            // created (nothing about that changed) — the trader signs in
            // normally after verifying their email, the same as any
            // returning user; api/auth/sign-in.php now sends a first-time
            // signed-in user to /onboarding (checked via
            // user_profiles.onboarding_complete) instead of /dashboard,
            // preserving the onboarding flow this auto-login used to be
            // the only way to reach.
            return ['success' => true, 'user_id' => $userId];
        } catch (\Throwable $e) {
            $this->pdo->rollBack();
            return ['error' => 'server_error', 'message' => 'Something went wrong. Please try again.'];
        }
    }

    public function signIn(string $email, string $password): array
    {
        $email = strtolower(trim($email));

        if ($email === '' || $password === '') {
            return ['error' => 'invalid_credentials', 'message' => 'Invalid email or password.'];
        }

        $stmt = $this->pdo->prepare(
            'SELECT id, email, password_hash, failed_login_attempts, last_failed_login
             FROM users WHERE email = :email AND deleted_at IS NULL'
        );
        $stmt->execute([':email' => $email]);
        $user = $stmt->fetch(PDO::FETCH_ASSOC);

        $attempts = $user ? (int)($user['failed_login_attempts'] ?? 0) : 0;
        $lastFailed = $user ? ($user['last_failed_login'] ?? null) : null;
        $lockedUntil = $lastFailed ? strtotime($lastFailed) + self::LOCKOUT_WINDOW_SECONDS : 0;
        $isLocked = $user && $attempts >= self::LOCKOUT_THRESHOLD && $lastFailed && time() < $lockedUntil;

        if ($isLocked) {
            // Spend comparable time to a real check so a locked account
            // doesn't respond in a way that's distinguishable from a normal
            // wrong-password attempt.
            password_verify($password, self::DUMMY_HASH);
            return [
                'error' => 'account_locked',
                'message' => 'Too many failed sign-in attempts. Please wait a few minutes and try again, or reset your password.',
            ];
        }

        $validPassword = $user
            ? password_verify($password, $user['password_hash'])
            : password_verify($password, self::DUMMY_HASH);

        if (!$user || !$validPassword) {
            if ($user) {
                $this->recordFailedAttempt($user['id'], $attempts, $lastFailed);
            }
            return ['error' => 'invalid_credentials', 'message' => 'Invalid email or password.'];
        }

        // Opportunistically upgrade any password hashed under weaker/older
        // cost parameters (or a different algorithm entirely) the moment we
        // have the plaintext in hand to do it safely — never touches
        // accounts that don't sign in, never invalidates anyone.
        if (password_needs_rehash($user['password_hash'], PASSWORD_ARGON2ID, PasswordPolicy::hashOptions())) {
            $newHash = password_hash($password, PASSWORD_ARGON2ID, PasswordPolicy::hashOptions());
            $rehash = $this->pdo->prepare('UPDATE users SET password_hash = :hash WHERE id = :uid');
            $rehash->execute([':hash' => $newHash, ':uid' => $user['id']]);
        }

        $reset = $this->pdo->prepare(
            'UPDATE users SET failed_login_attempts = 0, last_failed_login = NULL, last_login_at = NOW() WHERE id = :uid'
        );
        $reset->execute([':uid' => $user['id']]);

        $this->startSession($user['id'], $user['email']);
        return ['success' => true, 'user_id' => $user['id']];
    }

    private function recordFailedAttempt(string $userId, int $currentAttempts, ?string $lastFailed): void
    {
        // A streak that's aged out of the lockout window shouldn't keep
        // compounding indefinitely — start counting fresh instead.
        $windowExpired = $lastFailed && (strtotime($lastFailed) + self::LOCKOUT_WINDOW_SECONDS) < time();
        $nextCount = $windowExpired ? 1 : $currentAttempts + 1;

        $stmt = $this->pdo->prepare(
            'UPDATE users SET failed_login_attempts = :n, last_failed_login = NOW() WHERE id = :uid'
        );
        $stmt->execute([':n' => $nextCount, ':uid' => $userId]);
    }

    public function signOut(): void
    {
        $_SESSION = [];
        if (ini_get('session.use_cookies')) {
            $params = session_get_cookie_params();
            setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
        }
        session_destroy();
    }

    public function isLoggedIn(): bool
    {
        return isset($_SESSION['user_id']);
    }

    public function getUserId(): ?string
    {
        return $_SESSION['user_id'] ?? null;
    }

    public function requireAuth(): string
    {
        if (!$this->isLoggedIn()) {
            header('Location: ' . APP_URL . '/sign-in');
            exit;
        }
        return $this->getUserId();
    }

    /**
     * Was the currently-logged-in session established BEFORE this user's
     * most recent password change? (2026-08-19 security fix.) Both
     * api/settings/update.php's own password-change handler and
     * public/reset-password.php already write `users.last_password_change`
     * and already regenerate the CURRENT session's own ID on a successful
     * change — but neither one touches any OTHER session that same user
     * might have open on another device/browser. Without this check, a
     * stolen/shared session on device B stayed valid forever even after the
     * legitimate user changed their password specifically because they
     * suspected device B was compromised. index.php calls this on every
     * request; a true result means the request is treated as logged out.
     * Cheap — one indexed primary-key lookup, only ever run while a session
     * already claims to be logged in.
     */
    public function sessionPredatesPasswordChange(): bool
    {
        $userId = $this->getUserId();
        $sessionStartedAt = (int)($_SESSION['logged_in_at'] ?? 0);
        if (!$userId || !$sessionStartedAt) {
            return false;
        }
        $stmt = $this->pdo->prepare('SELECT last_password_change FROM users WHERE id = :uid');
        $stmt->execute([':uid' => $userId]);
        $changedAt = $stmt->fetchColumn();
        if (!$changedAt) {
            return false;
        }
        return strtotime($changedAt) > $sessionStartedAt;
    }

    /**
     * Public entry point for establishing a session for a user who has
     * already been verified through some other means outside of
     * signIn()/signUp() themselves (currently: auto-login right after a
     * successful email-verification click). Goes through the exact same
     * session_regenerate_id() path as every other login — no auth entry
     * point should ever assign $_SESSION['user_id'] directly, since that
     * skips session-fixation protection.
     */
    public function loginUser(string $userId, string $email): void
    {
        $this->startSession($userId, $email);
    }

    private function startSession(string $userId, string $email): void
    {
        if (session_status() === PHP_SESSION_NONE) {
            session_name(SESSION_NAME);
            session_start();
        }
        session_regenerate_id(true);
        $_SESSION['user_id'] = $userId;
        $_SESSION['email'] = $email;
        $_SESSION['logged_in_at'] = time();
    }
}
