<?php
declare(strict_types=1);

/**
 * OIDC Authorization Code Flow with PKCE (S256). Two routes:
 *
 *   /auth/oidc/start?provider=<id>
 *     - Generates state, nonce, PKCE pair
 *     - Redirects to the provider's authorization endpoint
 *
 *   /auth/oidc/callback?code=...&state=...
 *     - Verifies state matches the session
 *     - Exchanges code for tokens, validates the ID token
 *     - JIT-provisions / re-syncs the user, establishes the Plenvo session
 *
 * No CSRF token middleware here because OIDC's own state parameter serves the
 * same purpose. We DO regenerate the session id on successful login (matches
 * Auth::login behaviour) so a fixated session before the redirect cannot
 * survive past authentication.
 */
class OidcAuthController
{
    public static function start(): void
    {
        if (Helper::getSetting('oidc_enabled', '0') !== '1') {
            self::fail('oidc_disabled');
        }
        $providerId = (string)Helper::get('provider', '');
        $provider = OidcConfig::byId($providerId);
        if (!$provider || empty($provider['enabled'])) {
            self::fail('unknown_provider');
        }

        $disc = OidcDiscovery::get($provider['id'], $provider['discovery_url']);
        if ($disc === null) {
            SecurityLog::log('login_oidc_error', SecurityLog::SEV_WARN, ['provider' => $provider['id'], 'reason' => 'discovery_failed']);
            self::fail('discovery_failed');
        }
        $authUrl = (string)($disc['authorization_endpoint'] ?? '');
        if ($authUrl === '') self::fail('discovery_incomplete');

        // Stateful per-attempt secrets. Bound to the current PHP session so a
        // browser without cookies cannot complete the flow.
        $state    = bin2hex(random_bytes(16));
        $nonce    = bin2hex(random_bytes(16));
        $verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '=');
        $challenge = OidcJwt::base64UrlEncode(hash('sha256', $verifier, true));

        $_SESSION['oidc_pending'] = [
            'state'        => $state,
            'nonce'        => $nonce,
            'verifier'     => $verifier,
            'provider_id'  => $provider['id'],
            'created_at'   => time(),
        ];

        $params = [
            'response_type'         => 'code',
            'client_id'             => (string)$provider['client_id'],
            'redirect_uri'          => OidcConfig::redirectUri(),
            'scope'                 => (string)($provider['scopes'] ?? 'openid email profile'),
            'state'                 => $state,
            'nonce'                 => $nonce,
            'code_challenge'        => $challenge,
            'code_challenge_method' => 'S256',
        ];
        header('Location: ' . $authUrl . (str_contains($authUrl, '?') ? '&' : '?') . http_build_query($params));
        exit;
    }

    public static function callback(): void
    {
        $pending = $_SESSION['oidc_pending'] ?? null;
        unset($_SESSION['oidc_pending']);

        if (!is_array($pending) || empty($pending['state'])) {
            self::fail('no_pending_state');
        }
        if ((time() - (int)($pending['created_at'] ?? 0)) > 600) {
            self::fail('state_expired');
        }
        $stateGet = (string)Helper::get('state', '');
        if (!hash_equals((string)$pending['state'], $stateGet)) {
            SecurityLog::log('login_oidc_error', SecurityLog::SEV_WARN, ['reason' => 'state_mismatch']);
            self::fail('state_mismatch');
        }
        $code = (string)Helper::get('code', '');
        if ($code === '') {
            // Provider returned an error (e.g. user cancelled) — surface it.
            $err = (string)Helper::get('error', 'auth_cancelled');
            SecurityLog::log('login_oidc_failure', SecurityLog::SEV_WARN, ['reason' => $err, 'provider' => $pending['provider_id'] ?? '']);
            self::fail($err);
        }

        $provider = OidcConfig::byId((string)$pending['provider_id']);
        if (!$provider || empty($provider['enabled'])) self::fail('unknown_provider');

        $claims = OidcAuthAdapter::exchangeAndVerify(
            $provider,
            $code,
            (string)$pending['verifier'],
            (string)$pending['nonce']
        );
        if ($claims === null) {
            SecurityLog::log('login_oidc_failure', SecurityLog::SEV_WARN, [
                'provider' => $provider['id'],
                'reason'   => 'token_or_signature_failed',
            ]);
            self::fail('token_invalid');
        }

        $user = OidcProvisioner::syncUser($provider, $claims);
        if ($user === null) {
            SecurityLog::log('login_oidc_denied', SecurityLog::SEV_WARN, [
                'provider'   => $provider['id'],
                'email_hint' => Helper::maskEmail((string)($claims['email'] ?? '')),
                'reason'     => 'provisioning_blocked',
            ]);
            self::fail('account_locked');
        }
        if (empty($user['is_active'])) {
            SecurityLog::log('login_oidc_denied', SecurityLog::SEV_WARN, [
                'provider'   => $provider['id'],
                'email_hint' => Helper::maskEmail((string)$user['email']),
                'reason'     => 'inactive',
            ], (int)$user['id']);
            self::fail('account_locked');
        }

        // Establish Plenvo session — same dance as the local-password success path.
        session_regenerate_id(true);
        $_SESSION['user_id'] = (int)$user['id'];
        $_SESSION['lang']    = $user['language'] ?? 'de';
        $_SESSION['theme']   = $user['theme']    ?? 'light';
        Auth::stampSessionLogin();

        SecurityLog::log('login_oidc_success', SecurityLog::SEV_INFO, [
            'provider'   => $provider['id'],
            'email_hint' => Helper::maskEmail((string)$user['email']),
        ], (int)$user['id']);

        Lang::load($user['language'] ?? 'de');
        Helper::redirect('/dashboard');
    }

    /**
     * Send the user back to the login page with a flash error. Reasons map to
     * generic lang strings so we don't leak internals (e.g. "issuer mismatch"
     * stays in the security log, not in the UI).
     */
    private static function fail(string $reason): never
    {
        $key = match ($reason) {
            'oidc_disabled', 'unknown_provider' => 'oidc_err_unavailable',
            'no_pending_state', 'state_expired' => 'oidc_err_session',
            'state_mismatch'                    => 'oidc_err_state',
            'discovery_failed', 'discovery_incomplete' => 'oidc_err_discovery',
            'token_invalid'                     => 'oidc_err_token',
            'account_locked'                    => 'oidc_err_locked',
            default                             => 'oidc_err_generic',
        };
        Helper::redirect('/login', ['error' => Lang::get($key)]);
    }
}
