<?php

declare(strict_types=1);

namespace App\Auth;

use App\ApiClient\ApiException;
use App\ApiClient\AuthClient;
use App\Logging\LogIdentifier;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;

/**
 * OIDC bring-up.
 *
 *  - `GET /login/oidc` — calls `OidcAuthenticator::authenticate()`. With
 *    no `code` query, the underlying library redirects to the IdP and
 *    `exit`s, so this method does not return in that case.
 *  - `GET /oidc/callback` — same call; this time the library sees the
 *    `code` and `state`, exchanges the code, validates the ID token,
 *    and we hydrate the session.
 *
 * If the resolved user has an "empty" role (the api's
 * `OIDC_DEFAULT_ROLE=none` case surfaces as `role = "none"` in the
 * upsert response), redirect to `/no-access` — they're authenticated
 * but unauthorized.
 *
 * SEC_REVIEW F9: the session id is rotated at the top of `initiate()`,
 * BEFORE the jumbojett library stashes `state`, `nonce`, and the PKCE
 * `code_verifier` in `$_SESSION`. An attacker who pre-fixated the
 * victim's `irdb_session` cookie loses contact at this moment — the
 * OIDC handshake state is bound only to the freshly rotated id, and
 * (per F8) the rotation itself fail-closes if headers are already
 * sent. A second rotation runs after the callback succeeds (line
 * `regenerateId()` below) to defeat anything carrying over from the
 * pre-callback request.
 */
final class OidcController
{
    public function __construct(
        private readonly OidcAuthenticator $authenticator,
        private readonly AuthClient $auth,
        private readonly SessionManager $sessions,
        private readonly LoggerInterface $logger,
        private readonly bool $enabled,
    ) {
    }

    public function initiate(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        if (!$this->enabled) {
            return $response->withStatus(404);
        }
        // SEC_REVIEW F9: rotate the session id BEFORE the underlying
        // library stashes OIDC state/nonce/code_verifier in $_SESSION. Any
        // pre-fixated cookie is invalidated at this point.
        $this->sessions->regenerateId();
        // authenticate() will redirect-and-exit on the initiate path; only
        // on the callback path does it return normally. We delegate.
        return $this->finishOrFail($request, $response);
    }

    public function callback(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        if (!$this->enabled) {
            return $response->withStatus(404);
        }

        return $this->finishOrFail($request, $response);
    }

    private function finishOrFail(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        try {
            $claims = $this->authenticator->authenticate();
        } catch (OidcException $e) {
            $this->logger->error('oidc handshake failed', ['error' => $e->getMessage()]);
            $this->sessions->flash('error', 'Sign-in via Microsoft failed. Please try again.');

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

        try {
            $user = $this->auth->upsertOidc(
                subject: $claims->subject,
                email: $claims->email,
                displayName: $claims->displayName,
                groups: $claims->groups,
            );
        } catch (ApiException $e) {
            // SEC_REVIEW F11: a disabled user surfaces as 403 user_disabled
            // from the api. Route them to /no-access (same dead-end the
            // "no role assigned" branch uses) instead of looping back
            // through /login with an "API unreachable" flash that misleads.
            if ($e->statusCode === 403 && $e->apiError === 'user_disabled') {
                // SEC_REVIEW F34: log a fingerprint, not the raw OIDC subject.
                $this->logger->warning(
                    'oidc login denied: user disabled',
                    ['subject_fp' => LogIdentifier::fingerprint($claims->subject)],
                );

                return $response->withStatus(302)->withHeader('Location', '/no-access');
            }
            $this->logger->error('oidc upsert failed', ['error' => $e->getMessage()]);
            $this->sessions->flash('error', 'API unreachable; please retry.');

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

        if ($user->role === 'none' || $user->role === '') {
            // SEC_REVIEW F34: log a fingerprint, not the raw OIDC subject.
            $this->logger->warning(
                'oidc user has no role assigned',
                ['subject_fp' => LogIdentifier::fingerprint($claims->subject)],
            );

            return $response->withStatus(302)->withHeader('Location', '/no-access');
        }

        $this->sessions->regenerateId();
        $this->sessions->setUser(new UserContext(
            userId: $user->userId,
            displayName: $user->displayName !== '' ? $user->displayName : ($claims->email ?? $claims->subject),
            role: $user->role,
            email: $user->email ?? $claims->email,
            source: UserContext::SOURCE_OIDC,
        ));

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

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