<?php

declare(strict_types=1);

namespace App\Auth;

use Jumbojett\OpenIDConnectClient;
use Jumbojett\OpenIDConnectClientException;

/**
 * Concrete OIDC authenticator backed by jumbojett/openid-connect-php.
 *
 * The library handles state/nonce/PKCE and stashes them in the PHP
 * session itself (`$_SESSION['openid_connect_*']`), so we don't manage
 * those — but the controller regenerates the session id BEFORE
 * `authenticate()` runs on `/login/oidc` (SEC_REVIEW F9, defeats
 * pre-handshake fixation) and AGAIN after a successful callback
 * (defeats anything carrying over).
 *
 * SPEC §M08.4 scopes: `openid profile email`. Entra emits the `groups`
 * claim once configured in the app manifest (see `doc/oidc.md`); we
 * don't request a group scope explicitly because the claim is
 * configuration-side, not scope-side.
 */
final class JumbojettOidcAuthenticator implements OidcAuthenticator
{
    public function __construct(
        private readonly string $issuer,
        private readonly string $clientId,
        private readonly string $clientSecret,
        private readonly string $redirectUri,
    ) {
    }

    public function authenticate(): OidcClaims
    {
        $client = $this->buildClient();

        try {
            $authenticated = $client->authenticate();
        } catch (OpenIDConnectClientException $e) {
            throw new OidcException('OIDC authentication failed: ' . $e->getMessage(), 0, $e);
        }

        if (!$authenticated) {
            // authenticate() either redirects (and exits) or returns true.
            // Reaching here with `false` means the library has aborted in
            // a non-throwing way — treat as a hard failure.
            throw new OidcException('OIDC authentication did not complete');
        }

        $sub = (string) ($client->getVerifiedClaims('sub') ?? '');
        if ($sub === '') {
            throw new OidcException('ID token missing sub claim');
        }
        $emailClaim = $client->getVerifiedClaims('email');
        $email = is_string($emailClaim) && $emailClaim !== '' ? $emailClaim : null;
        if ($email === null) {
            $alt = $client->getVerifiedClaims('preferred_username');
            if (is_string($alt) && $alt !== '') {
                $email = $alt;
            }
        }
        $nameClaim = $client->getVerifiedClaims('name');
        $name = is_string($nameClaim) && $nameClaim !== '' ? $nameClaim : ($email ?? $sub);

        $groups = [];
        $groupsClaim = $client->getVerifiedClaims('groups');
        if (is_array($groupsClaim)) {
            foreach ($groupsClaim as $g) {
                if (is_string($g) && $g !== '') {
                    $groups[] = $g;
                }
            }
        }

        return new OidcClaims(
            subject: $sub,
            email: $email,
            displayName: $name,
            groups: $groups,
        );
    }

    private function buildClient(): OpenIDConnectClient
    {
        $client = new OpenIDConnectClient($this->issuer, $this->clientId, $this->clientSecret);
        $client->setRedirectURL($this->redirectUri);
        $client->addScope(['openid', 'profile', 'email']);
        // Entra-specific: the v2.0 endpoints require `response_mode=query`
        // for the auth-code flow, which is the library's default. PKCE is
        // enabled by setting a code-challenge method.
        $client->setCodeChallengeMethod('S256');

        return $client;
    }
}
