<?php

declare(strict_types=1);

namespace App\Services;

use App\Core\Auth;
use App\Core\Database;
use App\Core\Request;
use App\Core\Session;
use PDO;

/**
 * AuthService
 *
 * Orchestrates the full login flow: rate limiting (IP+email), the
 * per-account lockout on top of it, credential verification, 2FA
 * challenge (if enabled on the account), password-expiry flagging, and
 * — only once all of that clears — establishing the session via
 * App\Core\Auth::login(). Also owns "remember me" token issuance and
 * verification.
 *
 * Two-step login (when 2FA is enabled): attempt() verifies the
 * password and, if correct, either logs the user in directly (2FA off)
 * or stores a short-lived "pending 2FA" marker in the session and
 * returns requires_2fa => true. completeTwoFactor() is then called
 * with the 6-digit code (or a recovery code) to finish the login.
 */
final class AuthService
{
    private const PENDING_2FA_SESSION_KEY = '_pending_2fa_user_id';

    public function __construct(
        private readonly ?Request $request = null,
        private readonly PasswordPolicyService $passwordPolicy = new PasswordPolicyService(),
        private readonly TwoFactorService $twoFactor = new TwoFactorService()
    ) {
    }

    /**
     * @return array{success:bool, message:string, user?:array, requires_2fa?:bool, password_expired?:bool, nearing_expiry?:bool}
     */
    public function attempt(string $email, string $password): array
    {
        $ip = $this->request?->ip() ?? '0.0.0.0';
        $limits = config('security.login_rate_limit');

        if ($this->isRateLimited($email, $ip, $limits['max_attempts'], $limits['decay_minutes'])) {
            return [
                'success' => false,
                'message' => "Too many login attempts. Please try again in {$limits['decay_minutes']} minutes.",
            ];
        }

        $user = $this->findActiveUserByEmail($email);

        if ($user !== null && $this->isAccountLocked($user)) {
            $minutesLeft = (int) ceil((strtotime($user['locked_until']) - time()) / 60);

            return [
                'success' => false,
                'message' => "This account is temporarily locked due to repeated failed sign-in attempts. Try again in {$minutesLeft} minute(s).",
            ];
        }

        if ($user === null || !password_verify($password, $user['password_hash'])) {
            $this->recordAttempt($email, $ip, success: false);

            if ($user !== null) {
                $this->registerFailedAttempt((int) $user['id']);
            }

            return ['success' => false, 'message' => 'Incorrect email or password.'];
        }

        $this->recordAttempt($email, $ip, success: true);
        $this->rehashIfNeeded($user, $password);
        $this->clearFailedAttempts((int) $user['id']);

        if ((bool) $user['two_factor_enabled']) {
            // Don't establish the real session yet — only a short-lived
            // marker that a *password* was already verified for this
            // user id, consumed by completeTwoFactor().
            Session::set(self::PENDING_2FA_SESSION_KEY, (int) $user['id']);

            return ['success' => true, 'requires_2fa' => true, 'message' => 'Enter your two-factor authentication code.'];
        }

        return $this->finishLogin($user);
    }

    /** @return array{success:bool, message:string, user?:array} */
    public function completeTwoFactor(string $code, bool $isRecoveryCode = false): array
    {
        $userId = Session::get(self::PENDING_2FA_SESSION_KEY);

        if (!is_int($userId)) {
            return ['success' => false, 'message' => 'Your session has expired. Please sign in again.'];
        }

        $user = $this->findUserById($userId);
        if ($user === null) {
            Session::remove(self::PENDING_2FA_SESSION_KEY);

            return ['success' => false, 'message' => 'Account not found.'];
        }

        $valid = $isRecoveryCode
            ? $this->twoFactor->verifyAndConsumeRecoveryCode($userId, $code)
            : $this->twoFactor->verify((string) $user['two_factor_secret'], $code);

        if (!$valid) {
            ActivityLogger::logAction('Failed 2FA verification attempt', 'security', 'user', $userId, $this->request);

            return ['success' => false, 'message' => 'Invalid authentication code.'];
        }

        Session::remove(self::PENDING_2FA_SESSION_KEY);

        return $this->finishLogin($user);
    }

    private function finishLogin(array $user): array
    {
        Auth::login((int) $user['id']);
        $this->touchLastLogin((int) $user['id'], $this->request?->ip() ?? '0.0.0.0');

        ActivityLogger::logAction('User logged in', 'security', 'user', (int) $user['id'], $this->request);

        $expiry = $this->passwordPolicy->expiryStatus($user['password_changed_at']);

        unset($user['password_hash'], $user['two_factor_secret']);

        return [
            'success' => true,
            'message' => 'Logged in successfully.',
            'user' => $user,
            'password_expired' => $expiry['expired'],
            'nearing_expiry' => $this->passwordPolicy->isNearingExpiry($user['password_changed_at'] ?? null),
        ];
    }

    /** Issues a remember-me token and returns the raw cookie VALUE (selector:validator) — the caller sets the actual cookie. */
    public function issueRememberToken(int $userId, ?string $userAgent, ?string $ip, int $daysValid = 30): string
    {
        $selector = bin2hex(random_bytes(12));
        $validator = bin2hex(random_bytes(32));

        Database::connection()->prepare(
            'INSERT INTO remember_tokens (user_id, selector, validator_hash, user_agent, ip_address, expires_at, created_at)
             VALUES (:user_id, :selector, :validator_hash, :ua, :ip, :expires, NOW())'
        )->execute([
            'user_id' => $userId,
            'selector' => $selector,
            'validator_hash' => hash('sha256', $validator),
            'ua' => $userAgent,
            'ip' => $ip,
            'expires' => date('Y-m-d H:i:s', time() + ($daysValid * 86400)),
        ]);

        return "{$selector}:{$validator}";
    }

    /** Verifies a remember-me cookie value and logs the user in if valid, rotating the validator (one-time-use pattern). */
    public function attemptRememberToken(string $cookieValue): bool
    {
        if (!str_contains($cookieValue, ':')) {
            return false;
        }

        [$selector, $validator] = explode(':', $cookieValue, 2);
        $pdo = Database::connection();

        $stmt = $pdo->prepare('SELECT * FROM remember_tokens WHERE selector = :selector AND expires_at > NOW() LIMIT 1');
        $stmt->execute(['selector' => $selector]);
        $row = $stmt->fetch();

        if ($row === false || !hash_equals($row['validator_hash'], hash('sha256', $validator))) {
            return false;
        }

        $user = $this->findUserById((int) $row['user_id']);
        if ($user === null) {
            return false;
        }

        Auth::login((int) $user['id']);
        ActivityLogger::logAction('Logged in via remember-me token', 'security', 'user', (int) $user['id'], $this->request);

        // Rotate: delete the used token so a stolen, already-used cookie
        // value can never be replayed.
        $pdo->prepare('DELETE FROM remember_tokens WHERE id = :id')->execute(['id' => $row['id']]);

        return true;
    }

    public function forgetRememberToken(string $cookieValue): void
    {
        if (!str_contains($cookieValue, ':')) {
            return;
        }

        [$selector] = explode(':', $cookieValue, 2);
        Database::connection()->prepare('DELETE FROM remember_tokens WHERE selector = :selector')->execute(['selector' => $selector]);
    }

    public function logout(): void
    {
        $userId = Auth::id();

        if ($userId !== null) {
            ActivityLogger::logAction('User logged out', 'security', 'user', $userId, $this->request);
        }

        Auth::logout();
    }

    public function isRateLimited(string $email, string $ip, int $maxAttempts, int $decayMinutes): bool
    {
        $cutoff = date('Y-m-d H:i:s', time() - ($decayMinutes * 60));

        $stmt = Database::connection()->prepare(
            'SELECT COUNT(*) FROM login_attempts
             WHERE was_successful = 0 AND attempted_at > :cutoff
             AND (email = :email OR ip_address = :ip)'
        );
        $stmt->execute(['cutoff' => $cutoff, 'email' => $email, 'ip' => $ip]);

        return (int) $stmt->fetchColumn() >= $maxAttempts;
    }

    private function isAccountLocked(array $user): bool
    {
        return $user['locked_until'] !== null && strtotime($user['locked_until']) > time();
    }

    private function registerFailedAttempt(int $userId): void
    {
        $policy = config('security.account_lockout');
        $pdo = Database::connection();

        $stmt = $pdo->prepare('UPDATE users SET failed_login_count = failed_login_count + 1 WHERE id = :id');
        $stmt->execute(['id' => $userId]);

        $countStmt = $pdo->prepare('SELECT failed_login_count FROM users WHERE id = :id');
        $countStmt->execute(['id' => $userId]);
        $count = (int) $countStmt->fetchColumn();

        if ($count >= $policy['max_failed_attempts']) {
            $lockedUntil = date('Y-m-d H:i:s', time() + ($policy['lockout_minutes'] * 60));
            $pdo->prepare('UPDATE users SET locked_until = :locked_until WHERE id = :id')
                ->execute(['locked_until' => $lockedUntil, 'id' => $userId]);

            ActivityLogger::logAction(
                "Account locked after {$count} failed login attempts",
                'security',
                'user',
                $userId
            );
        }
    }

    private function clearFailedAttempts(int $userId): void
    {
        Database::connection()
            ->prepare('UPDATE users SET failed_login_count = 0, locked_until = NULL WHERE id = :id')
            ->execute(['id' => $userId]);
    }

    private function recordAttempt(string $email, string $ip, bool $success): void
    {
        $stmt = Database::connection()->prepare(
            'INSERT INTO login_attempts (email, ip_address, was_successful, user_agent, attempted_at)
             VALUES (:email, :ip, :success, :ua, NOW())'
        );
        $stmt->execute([
            'email' => $email,
            'ip' => $ip,
            'success' => $success ? 1 : 0,
            'ua' => $this->request?->userAgent(),
        ]);
    }

    private function findActiveUserByEmail(string $email): ?array
    {
        $stmt = Database::connection()->prepare(
            "SELECT * FROM users WHERE email = :email AND deleted_at IS NULL AND status = 'active' LIMIT 1"
        );
        $stmt->execute(['email' => $email]);

        $user = $stmt->fetch(PDO::FETCH_ASSOC);

        return $user === false ? null : $user;
    }

    private function findUserById(int $id): ?array
    {
        $stmt = Database::connection()->prepare(
            "SELECT * FROM users WHERE id = :id AND deleted_at IS NULL AND status = 'active' LIMIT 1"
        );
        $stmt->execute(['id' => $id]);
        $user = $stmt->fetch(PDO::FETCH_ASSOC);

        return $user === false ? null : $user;
    }

    /** Transparently upgrades an old bcrypt hash to Argon2id (or a lower cost factor) the next time the user logs in successfully. */
    private function rehashIfNeeded(array $user, string $plaintextPassword): void
    {
        $policy = config('security.password');

        if (!password_needs_rehash($user['password_hash'], $policy['hash_algo'], $policy['hash_options'])) {
            return;
        }

        $newHash = password_hash($plaintextPassword, $policy['hash_algo'], $policy['hash_options']);

        Database::connection()
            ->prepare('UPDATE users SET password_hash = :hash WHERE id = :id')
            ->execute(['hash' => $newHash, 'id' => $user['id']]);
    }

    private function touchLastLogin(int $userId, string $ip): void
    {
        Database::connection()
            ->prepare('UPDATE users SET last_login_at = NOW(), last_login_ip = :ip WHERE id = :id')
            ->execute(['ip' => $ip, 'id' => $userId]);
    }
}
