<?php

declare(strict_types=1);

namespace BlackRed\Http\Controllers;

use BlackRed\Auth\ForgotPasswordService;
use BlackRed\Auth\PasswordChangeService;
use BlackRed\Auth\PasswordHasher;
use BlackRed\Auth\RateLimiter;
use BlackRed\Auth\SessionManager;
use BlackRed\Bootstrap\Config;
use BlackRed\Database\Connection;
use BlackRed\Http\Middleware\HttpException;
use BlackRed\Http\Request;
use BlackRed\Http\Response;
use BlackRed\Logging\Logger;
use BlackRed\Validation\Validator;

/**
 * Authentication endpoints (login + logout only).
 *
 * Signup endpoints live in SignupController because they're a multi-step
 * wizard with their own service.
 *
 *   POST /api/auth/login   — phone + password → session
 *   POST /api/auth/logout  — destroys session (auth required)
 */
final class AuthController
{
    public function __construct(
        private readonly Connection $db,
        private readonly Config $config,
        private readonly PasswordHasher $passwords,
        private readonly PasswordChangeService $passwordChange,
        private readonly ForgotPasswordService $forgotPassword,
        private readonly SessionManager $sessions,
        private readonly RateLimiter $limiter,
        private readonly Logger $logger,
    ) {
    }

    /**
     * POST /api/auth/login
     * Body: { phone, password }
     *
     * Constant-time on the failure path: the password verification step
     * always runs even if the player doesn't exist, using a placeholder
     * hash. This prevents timing attacks that could enumerate registered
     * phones.
     */
    public function login(Request $request, array $params): Response
    {
        $v = new Validator($request->json());
        $phone = $v->requirePhone('phone');
        // Looser than signup — let any garbage password through to the verify
        // step so wrong-password and unknown-phone take the same time.
        $password = $v->requireString('password', 1, 100);

        $player = $this->db->fetchOne(
            'SELECT id, msisdn, passwordHash, accountStatus, deletedAt
             FROM player WHERE msisdn = :msisdn LIMIT 1',
            ['msisdn' => $phone]
        );

        // Placeholder hash for the timing-safe path when the player doesn't exist.
        // Argon2id of "x", verifies in roughly the same time as a real password.
        $hashToCheck = $player !== null && $player['passwordHash'] !== null
            ? (string)$player['passwordHash']
            : '$argon2id$v=19$m=65536,t=4,p=1$ZHVtbXlzYWx0ZHVtbXlzYWx0$cBfZ7wPV1tdYgYkb99Y9JBJpYxFxNrxf3JkLkGNEXJI';

        $passwordOk = $this->passwords->verify($password, $hashToCheck);

        if ($player === null
            || $player['deletedAt'] !== null
            || $player['accountStatus'] !== 'active'
            || !$passwordOk
        ) {
            $this->logger->info('login_failed', [
                'request_id' => $request->requestId,
                'msisdn'     => $phone,
                'reason'     => $player === null ? 'no_such_user' :
                                ($player['deletedAt'] !== null ? 'deleted' :
                                ($player['accountStatus'] !== 'active' ? 'not_active' :
                                'bad_password')),
            ]);
            throw HttpException::unauthorized('Invalid phone number or password');
        }

        // Re-hash if cost parameters have been bumped since this hash was created.
        if ($this->passwords->needsRehash((string)$player['passwordHash'])) {
            $newHash = $this->passwords->hash($password);
            $this->db->execute(
                'UPDATE player SET passwordHash = :h WHERE id = :id',
                ['h' => $newHash, 'id' => $player['id']]
            );
        }

        // Reset failed-login counter on successful auth so a legitimate user
        // who fat-fingered a few times stops being rate-limited after success.
        $this->limiter->reset('auth.login:' . $request->clientIp);

        $session = $this->sessions->create(
            (int)$player['id'],
            $phone,
            'web',
            $request->clientIp,
            $request->userAgent,
        );

        $this->logger->info('login_success', [
            'request_id' => $request->requestId,
            'player_id'  => (int)$player['id'],
            'msisdn'     => $phone,
        ]);

        $cookieHeader = $this->sessions->buildCookieHeader($session['token'], $session['expiresAt']);

        return Response::json([
            'ok'        => true,
            'playerId'  => (int)$player['id'],
            'phone'     => $phone,
            'expiresAt' => $session['expiresAt'],
        ])->withHeader('Set-Cookie', $cookieHeader);
    }

    /**
     * POST /api/auth/logout
     * Authenticated. Idempotent.
     */
    public function logout(Request $request, array $params): Response
    {
        $sessionId = $request->getAttribute('session_id');
        if (is_int($sessionId)) {
            $this->sessions->terminate($sessionId);
        }
        $clearCookie = $this->sessions->buildCookieHeader('', '1970-01-01 00:00:00');
        return Response::json(['ok' => true])->withHeader('Set-Cookie', $clearCookie);
    }

    /**
     * POST /api/auth/change-password
     * Authenticated. Body: { currentPassword, newPassword, confirmPassword }
     *
     * On success: all player sessions are terminated (including this one),
     * the cookie is cleared, and the client should redirect to login.
     */
    public function changePassword(Request $request, array $params): Response
    {
        $playerId = $request->getAttribute('player_id');
        if (!is_int($playerId)) {
            throw HttpException::unauthorized('Authentication required');
        }

        $v = new Validator($request->json());
        $current = $v->requireString('currentPassword', 1, 100);
        $new     = $v->requirePassword('newPassword');
        $confirm = $v->requireString('confirmPassword', 1, 100);

        if ($new !== $confirm) {
            throw new HttpException(422, 'password_mismatch', 'New password and confirmation do not match.');
        }

        $result = $this->passwordChange->change(
            $playerId,
            $current,
            $new,
            $request->clientIp,
            $request->userAgent,
        );

        // All sessions just got terminated, including the one that made this
        // request. Clear the cookie so the browser doesn't keep sending the
        // now-invalid token.
        $clearCookie = $this->sessions->buildCookieHeader('', '1970-01-01 00:00:00');

        return Response::json([
            'ok'                 => true,
            'sessionsTerminated' => $result['sessionsTerminated'],
            'message'            => 'Password updated. Please sign in with your new password.',
        ])->withHeader('Set-Cookie', $clearCookie);
    }

    /**
     * POST /api/auth/forgot/lookup
     * Body: { phone }
     *
     * Public (no auth). Always returns the same shape regardless of whether
     * the phone is registered, to prevent account enumeration. Tells the
     * user to dial *920*995# to receive a code on their MoMo SIM.
     */
    public function forgotLookup(Request $request, array $params): Response
    {
        $v = new Validator($request->json());
        $phone = $v->requirePhone('phone');

        $result = $this->forgotPassword->lookup($phone);

        return Response::json([
            'ok'         => true,
            'ussdCode'   => $result['ussdCode'],
            'ttlMinutes' => $result['ttlMinutes'],
            'message'    => 'If your number is registered, dial ' . $result['ussdCode'] . ' to get a 4-character code.',
        ]);
    }

    /**
     * POST /api/auth/forgot/reset
     * Body: { phone, code, newPassword, confirmPassword }
     *
     * Public (no auth). Verifies the USSD code, sets the new password,
     * terminates all existing sessions, and issues a fresh session for
     * THIS device (auto-login).
     */
    public function forgotReset(Request $request, array $params): Response
    {
        $v = new Validator($request->json());
        $phone   = $v->requirePhone('phone');
        $code    = $v->requireOtpCode('code', 4);
        $new     = $v->requirePassword('newPassword');
        $confirm = $v->requireString('confirmPassword', 1, 100);

        if ($new !== $confirm) {
            throw new HttpException(422, 'password_mismatch', 'New password and confirmation do not match.');
        }

        $session = $this->forgotPassword->reset(
            $phone,
            $code,
            $new,
            $request->clientIp,
            $request->userAgent,
        );

        $cookieHeader = $this->sessions->buildCookieHeader($session['token'], $session['expiresAt']);

        return Response::json([
            'ok'        => true,
            'playerId'  => $session['playerId'],
            'phone'     => $phone,
            'expiresAt' => $session['expiresAt'],
            'message'   => 'Password reset. You are signed in.',
        ])->withHeader('Set-Cookie', $cookieHeader);
    }
}