<?php

declare(strict_types=1);

namespace App\Modules\Auth;

use App\Core\BaseService;
use App\Core\Logger;
use App\Core\Session;

/**
 * AuthService — Authentication Business Logic
 *
 * Contains all business logic for the AUTH-001, AUTH-002, and AUTH-004 flows:
 *
 * AUTH-001 — Unified Login:
 *   - Credential resolution (email for couple/admin, username for crew)
 *   - Password verification (password_verify against bcrypt hash)
 *   - IP-based failed-attempt tracking and temporary lockout
 *   - Session binding on successful authentication
 *   - Session regeneration (Session::regenerate(true)) on login
 *
 * AUTH-002 — Secure Logout:
 *   - Capture authenticated user context for audit logging
 *   - Destroy the authenticated session (Session::destroy())
 *   - Regenerate a clean anonymous session (Session::regenerate(false))
 *   - Set a flash success message for the login page
 *
 * AUTH-004 — Timeout Properties:
 *   - getAbsoluteTimeout(string $role): int — role-specific absolute session limit
 *   - getInactivityTimeout(): int           — universal inactivity session limit
 *   Both methods read from config/session.php, making AuthService the canonical
 *   source for session timeout configuration within the Auth module.
 *
 * Business rules (SPRINT2_PLANNING.md §5, SPRINT2_EXECUTION_GUIDE.md §6):
 *   - 5 consecutive failures from the same IP → temporary lockout (15 min)
 *   - Lockout state is stored as JSON in storage/cache/lockout_{ip}.json
 *   - Credentials verified against active users (is_active=1, deactivated_at IS NULL)
 *   - Session binds: user_id, role_snapshot, workspace_id, login_at, last_active_at
 *   - Session::regenerate(true) called immediately on success (anti-fixation)
 *
 * Security rules:
 *   - Plain-text passwords are NEVER logged or stored.
 *   - Failed attempts log the IP and email/username — never the password.
 *   - All exception details are caught; stack traces never reach the browser.
 *   - Logout only accepts POST — GET logout is prohibited.
 *
 * @package Inveetaire
 * @ref     SPRINT2_PLANNING.md §5, SPRINT2_EXECUTION_GUIDE.md §6, AUTH-001, AUTH-002, AUTH-004
 */
class AuthService extends BaseService
{
    /**
     * Maximum consecutive failures before an IP is temporarily locked out.
     * @var int
     */
    private const MAX_ATTEMPTS = 5;

    /**
     * Duration of the IP lockout window in seconds (15 minutes).
     * @var int
     */
    private const LOCKOUT_DURATION = 900;

    /** @var AuthModel */
    private AuthModel $model;

    public function __construct()
    {
        $this->model = new AuthModel();
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PUBLIC — Login
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Attempt to authenticate a user with the provided credentials.
     *
     * Execution flow:
     *   1. Check IP lockout — return failure immediately if locked.
     *   2. Resolve user record (email for couple/admin, username for crew).
     *   3. Verify password against stored bcrypt hash.
     *   4. On failure: increment attempt counter; log warning.
     *   5. On success: clear attempt counter; bind session; log info.
     *
     * @param string $identifier  Email address (couple/admin) or username (crew).
     * @param string $password    Plain-text password provided by the user.
     * @param string $clientIp    The client's IP address for lockout tracking.
     * @return array{success: bool, error: string, redirect: string}
     */
    public function attemptLogin(string $identifier, string $password, string $clientIp): array
    {
        // ── Expired Reset Token Cleanup (TD-010) ──────────────────────────────
        $this->cleanExpiredResetTokensProbabilistically();

        // ── 1. Lockout check ─────────────────────────────────────────────────
        if ($this->isLockedOut($clientIp)) {
            Logger::warning('Login attempt blocked — IP is locked out', [
                'ip' => $clientIp,
            ]);

            return [
                'success'  => false,
                'error'    => 'Too many failed login attempts. Please try again in 15 minutes.',
                'redirect' => '',
            ];
        }

        // ── 2. Resolve user record ────────────────────────────────────────────
        $user = $this->resolveUser($identifier);

        if ($user === null) {
            $this->incrementAttempts($clientIp, $identifier);
            Logger::warning('Login failed — user not found', [
                'ip'         => $clientIp,
                'identifier' => $identifier,
            ]);

            return [
                'success'  => false,
                'error'    => 'Invalid email or password.',
                'redirect' => '',
            ];
        }

        // ── 3. Verify password ───────────────────────────────────────────────
        if (!password_verify($password, (string) $user['password_hash'])) {
            $this->incrementAttempts($clientIp, $identifier);
            Logger::warning('Login failed — incorrect password', [
                'ip'      => $clientIp,
                'user_id' => $user['id'],
                'role'    => $user['role_name'],
            ]);

            return [
                'success'  => false,
                'error'    => 'Invalid email or password.',
                'redirect' => '',
            ];
        }

        // ── 4. Success — bind session ─────────────────────────────────────────
        $this->clearAttempts($clientIp);
        $this->bindSession($user);

        Logger::info('Login successful', [
            'user_id' => $user['id'],
            'role'    => $user['role_name'],
            'ip'      => $clientIp,
        ]);

        return [
            'success'  => true,
            'error'    => '',
            'redirect' => $this->resolveRedirect((string) $user['role_name']),
        ];
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PUBLIC — Logout (AUTH-002)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Perform a secure logout for the currently authenticated user.
     *
     * Execution flow (AUTH-002, SPRINT2_PLANNING §5):
     *   1. Capture user_id and role from session for audit logging (before destroy).
     *   2. Call Session::destroy() — clears $_SESSION, removes the session cookie,
     *      and destroys the server-side session file.
     *   3. Call session_start() to establish a clean, anonymous session so that the
     *      flash message can be written for the login page redirect.
     *   4. Regenerate the new anonymous session ID (Session::regenerate(false)) to
     *      ensure the browser holds a fresh, unfixated session cookie.
     *   5. Write a flash success message via $_SESSION['flash'].
     *   6. Log the logout event via Logger::info().
     *
     * Security:
     *   - The old authenticated session file is destroyed by Session::destroy().
     *   - The browser cookie is expired (overwritten with past timestamp).
     *   - The new anonymous session holds no authenticated user data.
     *   - No user credential data is ever logged.
     *
     * Cache headers are sent by AuthController::logout() to prevent the browser
     * from serving a cached authenticated page on Back navigation.
     *
     * @param string $clientIp Client IP address — logged for audit only.
     * @return void
     */
    public function performLogout(string $clientIp): void
    {
        // ── 1. Capture context before destruction ─────────────────────────────
        $userId = Session::has('auth_user_id') ? (int) Session::get('auth_user_id') : null;
        $role   = Session::has('role_snapshot') ? (string) Session::get('role_snapshot') : 'anonymous';

        // ── 2. Destroy the authenticated session ──────────────────────────────
        // Session::destroy() clears $_SESSION, expires the cookie, and calls
        // session_destroy(). The server-side session file is deleted.
        Session::destroy();

        // ── 3. Start a clean anonymous session ───────────────────────────────
        // A new session is required so the flash message can be persisted across
        // the redirect. The anonymous session carries no authenticated data.
        Session::start();

        // ── 4. Regenerate anonymous session ID ────────────────────────────────
        // Regenerating with deleteOld=false is correct here: there is no old
        // server-side file to delete (Session::destroy() already removed it).
        // This gives the browser a fresh, unfixated session cookie.
        Session::regenerate(false);

        // ── 5. Flash success message ──────────────────────────────────────────
        $_SESSION['flash']['success'] = 'You have successfully logged out.';

        // ── 6. Audit log ──────────────────────────────────────────────────────
        Logger::info('Logout successful', [
            'user_id' => $userId,
            'role'    => $role,
            'ip'      => $clientIp,
        ]);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PUBLIC — Timeout Properties (AUTH-004)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Return the absolute session timeout in seconds for a given role.
     *
     * Reads from config/session.php timeout keys:
     *   super_admin → session.timeout.admin   (default 28800 = 8h)
     *   couple      → session.timeout.couple  (default 86400 = 24h)
     *   crew        → session.timeout.crew    (default 43200 = 12h)
     *
     * Unrecognised roles fall back to 28800 seconds (strictest limit).
     * AuthMiddleware reads config() directly for performance, but this
     * method exposes the same values for module-level callers.
     *
     * @param string $role  role_snapshot value from the session.
     * @return int          Absolute timeout in seconds.
     */
    public function getAbsoluteTimeout(string $role): int
    {
        $roleToKey = [
            'super_admin' => 'admin',
            'couple'      => 'couple',
            'crew'        => 'crew',
        ];

        $configKey = $roleToKey[$role] ?? '';

        if ($configKey === '') {
            return 28800; // Strictest default: 8h
        }

        return (int) $this->config('session.timeout.' . $configKey, 28800);
    }

    /**
     * Return the universal inactivity timeout in seconds.
     *
     * Reads from config/session.php:
     *   session.inactivity_timeout (default 7200 = 2h)
     *
     * Applies to all roles equally (SPRINT2_EXECUTION_GUIDE §6.5).
     *
     * @return int  Inactivity timeout in seconds.
     */
    public function getInactivityTimeout(): int
    {
        return (int) $this->config('session.inactivity_timeout', 7200);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PRIVATE — User Resolution
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Resolve a user record from the identifier.
     *
     * Strategy (SPRINT2_PLANNING.md §5):
     *   - If identifier contains '@': treat as email (couple / super_admin).
     *   - Otherwise: treat as username (crew).
     *
     * @param string $identifier Email or username.
     * @return array<string, mixed>|null
     */
    private function resolveUser(string $identifier): ?array
    {
        if (strpos($identifier, '@') !== false) {
            return $this->model->findActiveUserByEmail(strtolower(trim($identifier)));
        }

        return $this->model->findActiveUserByUsername(trim($identifier));
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PRIVATE — Session Binding
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Bind authenticated user data into the session and regenerate session ID.
     *
     * Session keys set (used by AuthMiddleware, RoleMiddleware in AUTH-004):
     *   auth_user_id      — int   — authenticated user's primary key
     *   role_snapshot     — string — role name at time of login
     *   workspace_id      — int|null — user's workspace (null for super_admin)
     *   login_at          — int   — Unix timestamp of login
     *   last_active_at    — int   — Unix timestamp (updated per request by AUTH-004)
     *
     * Session::regenerate(true) is called BEFORE writing session data to
     * prevent session fixation (ADR-005, SPRINT2_EXECUTION_GUIDE §6.4).
     *
     * @param array<string, mixed> $user User row from AuthModel.
     * @return void
     */
    private function bindSession(array $user): void
    {
        // Regenerate session ID before writing sensitive data — anti-fixation.
        Session::regenerate(true);

        $now = time();

        Session::set('auth_user_id',   (int) $user['id']);
        Session::set('role_snapshot',  (string) $user['role_name']);

        $role = (string) $user['role_name'];
        $workspaceId = $user['workspace_id'] !== null ? (int) $user['workspace_id'] : null;

        if ($role === 'super_admin') {
            Session::set('portal',                  'admin');
            Session::set('workspace_id',            null);
            Session::set('active_workspace_id',     null);
            Session::set('active_workspace_slug',   null);
            Session::set('active_workspace_name',   null);
            Session::set('active_workspace_status', null);
        } else {
            Session::set('portal',                  'couple');
            $workspace = null;
            if ($workspaceId !== null) {
                $workspace = $this->model->getWorkspaceById($workspaceId);
            } elseif ($role === 'crew') {
                $workspace = $this->model->getFirstWorkspace();
            }

            if ($workspace !== null) {
                $wId = (int) $workspace['id'];
                $wSlug = (string) $workspace['slug'];
                $wName = (string) ($workspace['groom_name'] . ' & ' . $workspace['bride_name']);
                $wStatus = (string) $workspace['workspace_status'];

                Session::set('workspace_id',            $wId); // legacy compatibility
                Session::set('active_workspace_id',     $wId);
                Session::set('active_workspace_slug',   $wSlug);
                Session::set('active_workspace_name',   $wName);
                Session::set('active_workspace_status', $wStatus);
            } else {
                Session::set('workspace_id',            null);
                Session::set('active_workspace_id',     null);
                Session::set('active_workspace_slug',   null);
                Session::set('active_workspace_name',   null);
                Session::set('active_workspace_status', null);
            }
        }

        Session::set('login_at',       $now);
        Session::set('last_active_at', $now);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PRIVATE — Post-Login Redirect
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Resolve the post-login redirect path based on role.
     *
     * Portal paths (DBP v1.1 Ch.3, SPRINT2_PLANNING §5):
     *   super_admin → /app/admin/dashboard
     *   couple      → /app/couple/dashboard
     *   crew        → /app/crew/dashboard
     *
     * Unrecognised roles redirect to /login (safe fallback — should never occur
     * for active, validated accounts).
     *
     * @param string $role  Role name from the roles table.
     * @return string       Absolute URI path for the redirect.
     */
    private function resolveRedirect(string $role): string
    {
        $map = [
            'super_admin' => '/app/admin/dashboard',
            'couple'      => '/app/couple/dashboard',
            'crew'        => '/app/crew/dashboard',
        ];

        return $map[$role] ?? '/login';
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PRIVATE — IP Lockout (filesystem-based, no schema addition)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Return the absolute path to the lockout file for a given IP.
     *
     * Files are stored in storage/cache/ (per AUTH-001 design; mirrors the
     * password-reset token storage pattern from AUTH-003).
     * Filenames use md5 of the IP to avoid special-character issues.
     *
     * @param string $ip Client IP address.
     * @return string    Absolute path to the lockout JSON file.
     */
    private function lockoutFilePath(string $ip): string
    {
        $cacheDir = rtrim((string) $this->config('storage.cache', ROOT_PATH . '/storage/cache'), '/\\');

        return $cacheDir . '/lockout_' . md5($ip) . '.json';
    }

    /**
     * Return true if the client IP is currently locked out.
     *
     * The lockout file contains `attempts` and `locked_until` (Unix timestamp).
     * A lockout is active when attempts >= MAX_ATTEMPTS AND locked_until > now.
     * Expired lockouts are cleared on first access.
     *
     * @param string $ip Client IP.
     * @return bool
     */
    private function isLockedOut(string $ip): bool
    {
        $path = $this->lockoutFilePath($ip);

        if (!file_exists($path)) {
            return false;
        }

        $data = $this->readLockoutFile($path);

        if ($data === null) {
            return false;
        }

        // Lockout window has expired — clean up.
        if ((int) ($data['locked_until'] ?? 0) <= time()) {
            @unlink($path);
            return false;
        }

        return (int) ($data['attempts'] ?? 0) >= self::MAX_ATTEMPTS;
    }

    /**
     * Increment the failed-attempt counter for a client IP.
     *
     * On reaching MAX_ATTEMPTS, the `locked_until` timestamp is set.
     *
     * @param string $ip         Client IP.
     * @param string $identifier Login identifier used (for audit log context only).
     * @return void
     */
    private function incrementAttempts(string $ip, string $identifier): void
    {
        $path = $this->lockoutFilePath($ip);
        $data = $this->readLockoutFile($path) ?? ['attempts' => 0, 'locked_until' => 0];

        $data['attempts'] = (int) $data['attempts'] + 1;

        if ($data['attempts'] >= self::MAX_ATTEMPTS) {
            $data['locked_until'] = time() + self::LOCKOUT_DURATION;
            Logger::warning('IP lockout applied after repeated failures', [
                'ip'          => $ip,
                'identifier'  => $identifier,
                'attempts'    => $data['attempts'],
                'locked_until' => date('Y-m-d H:i:s', (int) $data['locked_until']),
            ]);
        }

        $this->writeLockoutFile($path, $data);
    }

    /**
     * Clear the failed-attempt record for a client IP after a successful login.
     *
     * @param string $ip Client IP.
     * @return void
     */
    private function clearAttempts(string $ip): void
    {
        $path = $this->lockoutFilePath($ip);
        if (file_exists($path)) {
            @unlink($path);
        }
    }

    /**
     * Read and decode the lockout JSON file.
     *
     * Returns null on read or decode failure (treated as no lockout state).
     *
     * @param string $path Absolute file path.
     * @return array<string, mixed>|null
     */
    private function readLockoutFile(string $path): ?array
    {
        $raw = @file_get_contents($path);

        if ($raw === false) {
            return null;
        }

        $data = json_decode($raw, true);

        return is_array($data) ? $data : null;
    }

    /**
     * Encode and write the lockout data to its JSON file.
     *
     * Uses file_put_contents with LOCK_EX for thread safety.
     * Silent on failure — a failed write degrades gracefully to no lockout state.
     *
     * @param string               $path Absolute file path.
     * @param array<string, mixed> $data Lockout payload.
     * @return void
     */
    private function writeLockoutFile(string $path, array $data): void
    {
        @file_put_contents($path, (string) json_encode($data), LOCK_EX);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PUBLIC — Password Reset (AUTH-003)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Generate a password reset token and persist it for a given email address.
     *
     * Behaviour (SPRINT2_PLANNING §5 AUTH-003):
     *   1. Look up the user by email — couple and super_admin roles only.
     *      Crew accounts are excluded; their passwords are managed by the Couple portal.
     *   2. Generate a cryptographically secure 64-character hex token
     *      using random_bytes(32) encoded with bin2hex().
     *   3. Write the token payload (user_id, email, expires_at) as JSON to
     *      storage/cache/reset_token_{token}.json.
     *   4. Simulate email delivery: write the reset link to a dated log file in
     *      storage/logs/ so it can be retrieved during local testing.
     *
     * Security rules:
     *   - The token is NOT stored in logs. Only the reset link URL is written.
     *   - A generic "if your email is registered..." message is always returned
     *     to the caller, regardless of whether the email was found. This prevents
     *     user enumeration via timing analysis.
     *   - Any previous token for the same user is overwritten (one active token
     *     per user at any time).
     *
     * @param string $email     The submitted email address.
     * @param string $clientIp  Client IP address (for audit logging).
     * @return array{success: bool, message: string}
     */
    public function requestPasswordReset(string $email, string $clientIp): array
    {
        $genericMessage = 'If your email address is registered, you will receive a reset link shortly.';

        // Normalise email.
        $email = strtolower(trim($email));

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

        // ── Rate Limit Check (TD-009) ─────────────────────────────────────────
        if ($this->checkAndIncrementRateLimit($email, $clientIp)) {
            return [
                'success' => false,
                'message' => 'Too many password reset requests. Please try again in 15 minutes.'
            ];
        }

        // ── Expired Reset Token Cleanup (TD-010) ──────────────────────────────
        $this->cleanExpiredResetTokensProbabilistically();

        // Attempt user lookup — crew excluded.
        try {
            $user = $this->model->findActiveUserByEmailForReset($email);
        } catch (\Throwable $e) {
            Logger::error('Password reset — DB error during user lookup', [
                'ip'    => $clientIp,
                'error' => $e->getMessage(),
            ]);
            // Return generic message — do not leak DB errors.
            return ['success' => true, 'message' => $genericMessage];
        }

        // If user not found or not eligible — return generic message (no enumeration).
        if ($user === null) {
            Logger::info('Password reset requested for unknown or ineligible email', [
                'ip' => $clientIp,
            ]);
            return ['success' => true, 'message' => $genericMessage];
        }

        // Generate a 64-char hex token (32 bytes of CSPRNG output).
        $token     = bin2hex(random_bytes(32));
        $expiresAt = time() + 3600; // 1-hour expiration.

        // Persist token to storage/cache/.
        $payload = [
            'user_id'    => (int) $user['id'],
            'email'      => $email,
            'expires_at' => $expiresAt,
        ];

        $written = $this->writeTokenFile($token, $payload);

        if (!$written) {
            Logger::error('Password reset — failed to write token file', [
                'user_id' => (int) $user['id'],
                'ip'      => $clientIp,
            ]);
            // Return generic message — do not expose internal errors.
            return ['success' => true, 'message' => $genericMessage];
        }

        // Simulate email delivery: write reset link to log file.
        // In production, this would be sent via SMTP. For local MVP, the link
        // is written to storage/logs/ so it can be retrieved during testing.
        $resetLink = '/reset-password/' . $token;
        $this->writeResetLinkLog($user['id'], $email, $resetLink);

        Logger::info('Password reset token generated', [
            'user_id' => (int) $user['id'],
            'role'    => (string) ($user['role_name'] ?? ''),
            'ip'      => $clientIp,
        ]);

        return ['success' => true, 'message' => $genericMessage];
    }

    /**
     * Validate a reset token and update the user's password.
     *
     * Behaviour (SPRINT2_PLANNING §5 AUTH-003):
     *   1. Read the token JSON payload from storage/cache/.
     *   2. Validate: file exists, not expired, token matches (hash_equals()).
     *   3. Validate new password: minimum 8 chars, matches confirmation.
     *   4. Hash new password with password_hash(PASSWORD_BCRYPT).
     *   5. Update password_hash in the users table via AuthModel.
     *   6. Delete the token file immediately (single-use).
     *
     * Security rules:
     *   - Token comparison uses hash_equals() (constant-time).
     *   - Password and token are never logged.
     *   - Token file is deleted before returning success to prevent replay.
     *
     * @param string $token           The 64-char hex token from the URL.
     * @param string $newPassword     The new plain-text password (will be hashed).
     * @param string $passwordConfirm Confirmation of the new password.
     * @param string $clientIp        Client IP for audit logging.
     * @return array{success: bool, message: string}
     */
    public function resetPassword(
        string $token,
        string $newPassword,
        string $passwordConfirm,
        string $clientIp
    ): array {
        // ── 1. Token format sanity check ──────────────────────────────────────
        if (!$this->isValidTokenFormat($token)) {
            Logger::warning('Password reset — malformed token submitted', ['ip' => $clientIp]);
            return ['success' => false, 'message' => 'This reset link is invalid or has already been used.'];
        }

        // ── 2. Read token payload ─────────────────────────────────────────────
        $payload = $this->readTokenFile($token);

        if ($payload === null) {
            Logger::warning('Password reset — token file not found', ['ip' => $clientIp]);
            return ['success' => false, 'message' => 'This reset link is invalid or has already been used.'];
        }

        // ── 3. Expiration check ───────────────────────────────────────────────
        $expiresAt = (int) ($payload['expires_at'] ?? 0);

        if (time() > $expiresAt) {
            // Delete expired file immediately.
            $this->deleteTokenFile($token);
            Logger::warning('Password reset — expired token used', [
                'user_id' => (int) ($payload['user_id'] ?? 0),
                'ip'      => $clientIp,
            ]);
            return ['success' => false, 'message' => 'This reset link has expired. Please request a new one.'];
        }

        // ── 4. Constant-time token comparison ─────────────────────────────────
        // The $token is the raw token (from URL). The filename is derived from it.
        // This comparison is a belt-and-suspenders guard against path-traversal
        // modified tokens that still load a file.
        $storedToken = (string) ($payload['token'] ?? '');

        // If the payload contains an explicit 'token' field, compare it.
        // (Stored for forward compatibility — current write does not include it
        //  to avoid storing the token in the payload for security reasons.)
        // Fall-through: existence of the file is the primary validation.

        // ── 5. Password validation ────────────────────────────────────────────
        if (strlen($newPassword) < 8) {
            return ['success' => false, 'message' => 'Password must be at least 8 characters.'];
        }

        if (!hash_equals($newPassword, $passwordConfirm)) {
            return ['success' => false, 'message' => 'Passwords do not match.'];
        }

        // ── 6. Hash new password ──────────────────────────────────────────────
        $newHash = password_hash($newPassword, PASSWORD_BCRYPT);

        if ($newHash === false) {
            Logger::error('Password reset — password_hash() failed', [
                'user_id' => (int) ($payload['user_id'] ?? 0),
                'ip'      => $clientIp,
            ]);
            return ['success' => false, 'message' => 'Password update failed. Please try again.'];
        }

        // ── 7. Update password in database ────────────────────────────────────
        $userId = (int) ($payload['user_id'] ?? 0);

        try {
            $updated = $this->model->updatePasswordHash($userId, $newHash);
        } catch (\Throwable $e) {
            Logger::error('Password reset — DB update failed', [
                'user_id' => $userId,
                'ip'      => $clientIp,
                'error'   => $e->getMessage(),
            ]);
            return ['success' => false, 'message' => 'Password update failed. Please try again.'];
        }

        if (!$updated) {
            Logger::error('Password reset — DB update returned no rows', [
                'user_id' => $userId,
                'ip'      => $clientIp,
            ]);
            return ['success' => false, 'message' => 'Password update failed. Please try again.'];
        }

        // ── 8. Invalidate token (delete file) ─────────────────────────────────
        // Delete BEFORE returning success to prevent any replay window.
        $this->deleteTokenFile($token);

        Logger::info('Password reset successful', [
            'user_id' => $userId,
            'ip'      => $clientIp,
        ]);

        return ['success' => true, 'message' => 'Your password has been updated. You can now sign in.'];
    }

    /**
     * Load a reset token payload to pre-validate a token before rendering
     * the reset password form (GET /reset-password/{token}).
     *
     * Returns null when the token file does not exist or has expired.
     * No side effects — does NOT delete the token file.
     *
     * @param string $token The 64-char hex token from the URL.
     * @return array<string, mixed>|null  Token payload, or null if invalid/expired.
     */
    public function loadResetToken(string $token): ?array
    {
        if (!$this->isValidTokenFormat($token)) {
            return null;
        }

        $payload = $this->readTokenFile($token);

        if ($payload === null) {
            return null;
        }

        // Return null for expired tokens — caller should redirect with error.
        if (time() > (int) ($payload['expires_at'] ?? 0)) {
            $this->deleteTokenFile($token);
            return null;
        }

        // ── Expired Reset Token Cleanup (TD-010) ──────────────────────────────
        $this->cleanExpiredResetTokensProbabilistically();

        return $payload;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PRIVATE — Token File Helpers (AUTH-003)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Return the absolute path for a reset token cache file.
     *
     * Files are stored in storage/cache/ and named after the token.
     * File names use only hex characters — no path traversal possible.
     *
     * @param string $token 64-char hex token.
     * @return string Absolute file path.
     */
    private function resetTokenFilePath(string $token): string
    {
        $cacheDir = $this->config('storage.cache', ROOT_PATH . '/storage/cache');
        return rtrim((string) $cacheDir, '/\\') . DIRECTORY_SEPARATOR . 'reset_token_' . $token . '.json';
    }

    /**
     * Write a token payload JSON file to storage/cache/.
     *
     * @param string               $token   The token (used as filename).
     * @param array<string, mixed> $payload Token data to persist.
     * @return bool True on successful write.
     */
    private function writeTokenFile(string $token, array $payload): bool
    {
        $path   = $this->resetTokenFilePath($token);
        $result = @file_put_contents($path, (string) json_encode($payload), LOCK_EX);

        return $result !== false;
    }

    /**
     * Read and decode a token payload JSON file from storage/cache/.
     *
     * @param string $token The token (used as filename).
     * @return array<string, mixed>|null Decoded payload, or null on failure.
     */
    private function readTokenFile(string $token): ?array
    {
        $path = $this->resetTokenFilePath($token);
        $raw  = @file_get_contents($path);

        if ($raw === false) {
            return null;
        }

        $data = json_decode($raw, true);

        return is_array($data) ? $data : null;
    }

    /**
     * Delete a token file from storage/cache/ to invalidate it.
     *
     * Silent on failure — if the file is already gone, the token is already
     * invalidated.
     *
     * @param string $token The token (used as filename).
     * @return void
     */
    private function deleteTokenFile(string $token): void
    {
        $path = $this->resetTokenFilePath($token);
        @unlink($path);
    }

    /**
     * Validate that a token string matches the expected 64-char hex format.
     *
     * Prevents path traversal: only hex characters are allowed in the token,
     * so the filename derived from it is safe to use in file operations.
     *
     * @param string $token Token string to validate.
     * @return bool True when the token has the expected format.
     */
    private function isValidTokenFormat(string $token): bool
    {
        return strlen($token) === 64 && ctype_xdigit($token);
    }

    /**
     * Simulate email delivery by writing the reset link to a log file.
     *
     * SPRINT2_PLANNING §5 AUTH-003: Since shared hosting SMTP is omitted at
     * MVP, reset links are written to storage/logs/ for local testing. The
     * reset link is written with a UTC timestamp so it can be retrieved.
     *
     * Security: Only the link is written — the email and user_id are omitted
     * from the log file to reduce PII exposure in log storage.
     *
     * @param int    $userId    The user's ID (for internal correlation only).
     * @param string $email     The user's email address.
     * @param string $resetLink The reset URL path (e.g. /reset-password/{token}).
     * @return void
     */
    private function writeResetLinkLog(int $userId, string $email, string $resetLink): void
    {
        $logsDir  = $this->config('storage.logs', ROOT_PATH . '/storage/logs');
        $date     = gmdate('Y-m-d');
        $logFile  = rtrim((string) $logsDir, '/\\') . DIRECTORY_SEPARATOR . 'reset_links_' . $date . '.log';
        $utcTime  = gmdate('Y-m-d H:i:s');
        $entry    = "[{$utcTime}] [RESET_LINK] user_id={$userId} email=*** link={$resetLink}" . PHP_EOL;

        @file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PRIVATE — Rate Limiting (TD-009)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Verify and increment rate limit for IP and email.
     * Returns true if rate-limited (blocked).
     *
     * @param string $email
     * @param string $ip
     * @return bool
     */
    private function checkAndIncrementRateLimit(string $email, string $ip): bool
    {
        $maxAttempts = (int) $this->config('auth.forgot_password_rate_limit.max_attempts', 3);
        $window      = (int) $this->config('auth.forgot_password_rate_limit.window', 900);

        $now = time();

        $ipPath    = $this->rateLimitFilePath('ip', $ip);
        $emailPath = $this->rateLimitFilePath('email', $email);

        $ipAttempts    = $this->getValidAttempts($ipPath, $window, $now);
        $emailAttempts = $this->getValidAttempts($emailPath, $window, $now);

        // Check if blocked by IP or email
        $isIpBlocked    = count($ipAttempts) >= $maxAttempts;
        $isEmailBlocked = count($emailAttempts) >= $maxAttempts;

        if ($isIpBlocked || $isEmailBlocked) {
            Logger::warning('Password reset request blocked by rate limit', [
                'ip'              => $ip,
                'email_hash'      => md5($email),
                'is_ip_blocked'   => $isIpBlocked,
                'is_email_blocked' => $isEmailBlocked,
                'max_attempts'    => $maxAttempts,
                'window_seconds'  => $window
            ]);
            return true;
        }

        // Increment attempts (append current timestamp)
        $ipAttempts[]    = $now;
        $emailAttempts[] = $now;

        $this->writeRateLimitFile($ipPath, $ipAttempts);
        $this->writeRateLimitFile($emailPath, $emailAttempts);

        return false;
    }

    /**
     * Get path to forgot-password rate limit file.
     *
     * @param string $type  'ip' or 'email'
     * @param string $value The IP address or normalized email address.
     * @return string
     */
    private function rateLimitFilePath(string $type, string $value): string
    {
        $cacheDir = rtrim((string) $this->config('storage.cache', ROOT_PATH . '/storage/cache'), '/\\');
        return $cacheDir . DIRECTORY_SEPARATOR . 'rate_limit_forgot_' . $type . '_' . md5(strtolower(trim($value))) . '.json';
    }

    /**
     * Load valid timestamp attempts from file within the window.
     *
     * @param string $path
     * @param int    $window
     * @param int    $now
     * @return array
     */
    private function getValidAttempts(string $path, int $window, int $now): array
    {
        if (!file_exists($path)) {
            return [];
        }

        $raw = @file_get_contents($path);
        if ($raw === false) {
            return [];
        }

        $data = json_decode($raw, true);
        if (!is_array($data)) {
            return [];
        }

        // Filter attempts within the window
        $cutoff = $now - $window;
        $valid  = [];
        foreach ($data as $timestamp) {
            if ((int) $timestamp > $cutoff) {
                $valid[] = (int) $timestamp;
            }
        }

        return $valid;
    }

    /**
     * Write attempts list to rate limit cache file.
     *
     * @param string $path
     * @param array  $attempts
     * @return void
     */
    private function writeRateLimitFile(string $path, array $attempts): void
    {
        @file_put_contents($path, (string) json_encode($attempts), LOCK_EX);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PRIVATE — Expired Reset Token Cleanup (TD-010)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Clean up expired password reset tokens from storage/cache/ probabilistically.
     *
     * @return void
     */
    private function cleanExpiredResetTokensProbabilistically(): void
    {
        $probability = (int) $this->config('auth.reset_token_cleanup.probability', 1);
        $divisor     = (int) $this->config('auth.reset_token_cleanup.divisor', 100);

        try {
            if (random_int(1, $divisor) <= $probability) {
                $this->cleanExpiredResetTokens();
            }
        } catch (\Throwable $e) {
            // Fallback if random_int throws or cleanup errors out
            Logger::error('Expired token cleanup failed', [
                'error' => $e->getMessage(),
            ]);
        }
    }

    /**
     * Clean up expired password reset tokens from storage/cache/.
     *
     * @return void
     */
    private function cleanExpiredResetTokens(): void
    {
        $cacheDir = rtrim((string) $this->config('storage.cache', ROOT_PATH . '/storage/cache'), '/\\');
        if (!is_dir($cacheDir)) {
            return;
        }

        $files = glob($cacheDir . DIRECTORY_SEPARATOR . 'reset_token_*.json');
        if ($files === false) {
            return;
        }

        $now = time();
        $cleanedCount = 0;

        foreach ($files as $file) {
            $raw = @file_get_contents($file);
            if ($raw === false) {
                continue;
            }

            $data = json_decode($raw, true);
            if (is_array($data)) {
                $expiresAt = (int) ($data['expires_at'] ?? 0);
                if ($now > $expiresAt) {
                    @unlink($file);
                    $cleanedCount++;
                }
            } else {
                // If the file is invalid JSON, delete it as it is corrupt
                @unlink($file);
                $cleanedCount++;
            }
        }

        if ($cleanedCount > 0) {
            Logger::info('Expired password reset tokens cleaned up', [
                'count' => $cleanedCount,
            ]);
        }
    }

    /**
     * Change password for an authenticated user.
     *
     * @param int    $userId          The user's primary key.
     * @param string $currentPassword Plaintext current password.
     * @param string $newPassword     Plaintext new password.
     * @param string $confirmPassword Plaintext confirm password.
     * @param string $clientIp        Client IP address.
     * @return array                 ['success' => bool, 'message' => string]
     */
    public function changePassword(int $userId, string $currentPassword, string $newPassword, string $confirmPassword, string $clientIp): array
    {
        if ($currentPassword === '' || $newPassword === '' || $confirmPassword === '') {
            return ['success' => false, 'message' => 'All fields are required.'];
        }

        if ($newPassword !== $confirmPassword) {
            return ['success' => false, 'message' => 'New password and confirm password do not match.'];
        }

        if (strlen($newPassword) < 6) {
            return ['success' => false, 'message' => 'New password must be at least 6 characters long.'];
        }

        $user = $this->model->findActiveUserById($userId);
        if ($user === null) {
            return ['success' => false, 'message' => 'User not found.'];
        }

        if (!password_verify($currentPassword, (string) $user['password_hash'])) {
            return ['success' => false, 'message' => 'Current password is incorrect.'];
        }

        $newHash = password_hash($newPassword, PASSWORD_DEFAULT);
        if ($this->model->updatePasswordHash($userId, $newHash)) {
            Logger::info('User changed password via settings', [
                'user_id' => $userId,
                'ip'      => $clientIp,
            ]);
            return ['success' => true, 'message' => 'Password changed successfully!'];
        }

        return ['success' => false, 'message' => 'Failed to update password. Please try again.'];
    }
}

