<?php

declare(strict_types=1);

namespace App\Auth;

use App\ApiClient\ApiException;
use App\ApiClient\AuthClient;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Slim\Views\Twig;

/**
 * Local-admin sign-in.
 *
 * Flow on POST:
 *   1. Throttle gate — `LoginThrottle` keyed by `(username, source_ip)`
 *      (SPEC §M14.2). If locked, refuse with a flash and redirect.
 *   2. Username must equal `LOCAL_ADMIN_USERNAME`.
 *   3. `password_verify` against the Argon2id hash from the env.
 *   4. On success: clear the throttle, call `AuthClient::upsertLocal()` to
 *      ensure the api has a `users` row with `is_local=1, role=admin`,
 *      regenerate session id, set the session user, redirect to
 *      `next` (or `/app/dashboard`).
 *   5. On failure: record on the throttle (per-process in-memory store),
 *      flash an error, redirect back to `/login`.
 */
final class LocalLoginController
{
    /**
     * Precomputed Argon2id hash used as a constant-time `password_verify`
     * target on the failure path (SEC_REVIEW F7). Built once per worker in
     * the constructor; never on the request hot path.
     */
    private readonly string $dummyPasswordHash;

    public function __construct(
        private readonly Twig $twig,
        private readonly SessionManager $sessions,
        private readonly AuthClient $auth,
        private readonly LoginThrottle $throttle,
        private readonly ResponseFactoryInterface $responseFactory,
        private readonly LoggerInterface $logger,
        private readonly bool $localAdminEnabled,
        private readonly bool $oidcEnabled,
        private readonly string $localUsername,
        private readonly string $localPasswordHash,
    ) {
        $this->dummyPasswordHash = password_hash(
            bin2hex(random_bytes(16)),
            PASSWORD_ARGON2ID,
        );
    }

    public function showLogin(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        if ($this->sessions->getUser() !== null) {
            return $response->withStatus(302)->withHeader('Location', '/app/me');
        }

        return $this->twig->render($response, 'pages/login.twig', [
            'oidc_enabled' => $this->oidcEnabled,
            'local_admin_enabled' => $this->localAdminEnabled,
        ]);
    }

    public function postLocal(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $sourceIp = self::extractSourceIp($request);

        // SEC_REVIEW F38: rate-limit hits to /login/local even when the
        // path is disabled, so an attacker can't burn worker threads
        // hammering this URL. Normalising to an empty username folds all
        // hits from one source IP into one per-IP bucket regardless of
        // what username field the attacker happens to submit, so the
        // existing 5/10/15 ladder applies to a rotating-username spray.
        if (!$this->localAdminEnabled) {
            if (!$this->throttle->isLocked('', $sourceIp)) {
                $this->throttle->recordFailure('', $sourceIp);
            }

            return $this->responseFactory->createResponse(404);
        }

        $body = $request->getParsedBody();
        $username = is_array($body) && isset($body['username']) && is_string($body['username']) ? $body['username'] : '';
        $password = is_array($body) && isset($body['password']) && is_string($body['password']) ? $body['password'] : '';

        // Throttle gate is keyed by (username, source_ip). An empty
        // username still gets a bucket so blank-form spamming is rate-limited.
        if ($this->throttle->isLocked($username, $sourceIp)) {
            $this->sessions->flash('error', 'Too many failed attempts. Try again in a moment.');

            return $response->withStatus(303)->withHeader('Location', '/login');
        }

        $usernameOk = hash_equals($this->localUsername, $username);

        // SEC_REVIEW F7: always run `password_verify` against an Argon2id
        // hash so the request takes the same time on a username miss as on
        // a hit — and even when the local-admin password is unconfigured.
        // We compare against the configured admin hash whenever it is set
        // (so the timing target matches whatever cost params the operator
        // chose); when it is unset we fall back to a precomputed dummy
        // hash. The verify result is then ANDed with the username check
        // and the "configured" check, so a username miss still fails
        // closed.
        $hashToCompare = $this->localPasswordHash !== ''
            ? $this->localPasswordHash
            : $this->dummyPasswordHash;
        $verifyOk = password_verify($password, $hashToCompare);

        $passwordOk = $usernameOk
            && $this->localPasswordHash !== ''
            && $verifyOk;

        if (!$usernameOk || !$passwordOk) {
            $this->throttle->recordFailure($username, $sourceIp);
            $this->sessions->flash('error', 'Invalid username or password.');

            return $response->withStatus(303)->withHeader('Location', '/login');
        }

        try {
            $user = $this->auth->upsertLocal($this->localUsername);
        } catch (ApiException $e) {
            // SEC_REVIEW F11: a disabled local-admin row blocks the
            // upsert with 403 user_disabled. Don't speak truth about
            // "disabled" on the public login page (probe-friendly);
            // surface a generic credential error instead. The audit log
            // captures the 403 server-side.
            if ($e->statusCode === 403 && $e->apiError === 'user_disabled') {
                $this->logger->warning('local login denied: account disabled');
                $this->throttle->recordFailure($username, $sourceIp);
                $this->sessions->flash('error', 'Invalid username or password.');

                return $response->withStatus(303)->withHeader('Location', '/login');
            }
            $this->logger->error('local login: upsertLocal failed', ['error' => $e->getMessage()]);
            $this->sessions->flash('error', 'API unreachable; please retry.');

            return $response->withStatus(303)->withHeader('Location', '/login');
        }

        $this->throttle->clear($username, $sourceIp);
        $this->sessions->regenerateId();
        $this->sessions->setUser(new UserContext(
            userId: $user->userId,
            displayName: $user->displayName !== '' ? $user->displayName : 'Local Admin',
            role: $user->role,
            email: $user->email,
            source: UserContext::SOURCE_LOCAL,
        ));

        $next = $this->sessions->consumeNext() ?? '/app/dashboard';

        return $response->withStatus(303)->withHeader('Location', $next);
    }

    /**
     * Source IP for the throttle bucket. We deliberately do **not** read
     * `X-Forwarded-For` here: an attacker hitting Caddy directly can spoof
     * the header and rotate it per request to mint a fresh per-IP bucket on
     * every attempt, defeating the lockout (SEC_REVIEW F1). Caddy is the
     * trust boundary: its `trusted_proxies static private_ranges` directive
     * already rewrites `REMOTE_ADDR` to the real client when an upstream
     * proxy is in RFC1918, and leaves the public peer IP otherwise. PHP
     * just consumes whatever Caddy decided.
     */
    private static function extractSourceIp(ServerRequestInterface $request): string
    {
        $server = $request->getServerParams();
        $remote = $server['REMOTE_ADDR'] ?? '';

        return is_string($remote) ? $remote : '';
    }
}
