<?php

namespace Functional\Users\Http\Controllers;

use Functional\Streaks\Models\Streak;
use Functional\Users\Exceptions\OidcAuthenticationFailedException;
use Functional\Users\Exceptions\OidcStateMismatchException;
use Functional\Users\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

/**
 * Login OIDC (Authorization Code + PKCE S256) contre pocket-id, piloté 100% back : le front ne
 * fait que suivre la redirection vers /auth/oidc/redirect et atterrir sur APP_FRONT_URL au retour.
 */
class OidcController
{
    /**
     * Redirige vers l'écran d'autorisation pocket-id. State + code_verifier PKCE en session.
     */
    public function redirect(Request $request): RedirectResponse
    {
        $discovery = $this->discoverProvider();

        $state = Str::random(40);
        $codeVerifier = $this->generateCodeVerifier();

        $request->session()->put('oidc_state', $state);
        $request->session()->put('oidc_code_verifier', $codeVerifier);

        $query = http_build_query([
            'response_type' => 'code',
            'client_id' => config('services.oidc.client_id'),
            'redirect_uri' => config('services.oidc.redirect_uri'),
            'scope' => 'openid profile email',
            'state' => $state,
            'code_challenge' => $this->codeChallenge($codeVerifier),
            'code_challenge_method' => 'S256',
        ]);

        return redirect()->away("{$discovery['authorization_endpoint']}?{$query}");
    }

    /**
     * Échange le code contre un token, récupère les claims userinfo, lie/crée le user, ouvre la
     * session, puis renvoie vers le front — aucune logique d'auth n'est jamais exposée à celui-ci.
     */
    public function callback(Request $request): RedirectResponse
    {
        $state = $request->query('state');
        $expectedState = $request->session()->pull('oidc_state');
        $codeVerifier = $request->session()->pull('oidc_code_verifier');

        if (! is_string($state) || ! is_string($expectedState) || ! hash_equals($expectedState, $state)) {
            throw new OidcStateMismatchException();
        }

        if (! is_string($codeVerifier)) {
            throw new OidcStateMismatchException();
        }

        $code = $request->query('code');

        if (! is_string($code)) {
            throw new OidcAuthenticationFailedException();
        }

        $claims = $this->fetchUserinfoClaims($code, $codeVerifier);
        $user = $this->upsertUserFromClaims($claims);

        Auth::login($user);
        $request->session()->regenerate();

        return redirect()->away((string) config('app.front_url'));
    }

    /**
     * Échange le code d'autorisation contre un access token puis récupère les claims userinfo.
     *
     * @return array<string, mixed>
     */
    private function fetchUserinfoClaims(string $code, string $codeVerifier): array
    {
        $discovery = $this->discoverProvider();

        $tokenResponse = Http::asForm()->post($discovery['token_endpoint'], [
            'grant_type' => 'authorization_code',
            'code' => $code,
            'redirect_uri' => config('services.oidc.redirect_uri'),
            'client_id' => config('services.oidc.client_id'),
            'client_secret' => config('services.oidc.client_secret'),
            'code_verifier' => $codeVerifier,
        ]);

        if ($tokenResponse->failed()) {
            throw new OidcAuthenticationFailedException();
        }

        $accessToken = $tokenResponse->json('access_token');

        if (! is_string($accessToken)) {
            throw new OidcAuthenticationFailedException();
        }

        $userinfoResponse = Http::withToken($accessToken)->get($discovery['userinfo_endpoint']);

        if ($userinfoResponse->failed()) {
            throw new OidcAuthenticationFailedException();
        }

        return $userinfoResponse->json();
    }

    /**
     * Lie le compte par `oidc_sub`, sinon par `email` ; sinon crée un nouveau user. Ne renseigne
     * oidc_sub/name/email/locale que quand l'attribut est encore absent, pour ne jamais écraser un
     * profil déjà personnalisé par l'utilisateur.
     *
     * @param  array<string, mixed>  $claims
     */
    private function upsertUserFromClaims(array $claims): User
    {
        $sub = $claims['sub'] ?? null;
        $email = $claims['email'] ?? null;

        if (! is_string($sub) || ! is_string($email)) {
            throw new OidcAuthenticationFailedException();
        }

        $user = User::query()->where('oidc_sub', $sub)->first()
            ?? User::query()->where('email', $email)->first()
            ?? new User();

        if (blank($user->oidc_sub)) {
            $user->oidc_sub = $sub;
        }

        if (blank($user->email)) {
            $user->email = $email;
        }

        if (blank($user->name)) {
            $user->name = $claims['name'] ?? $email;
        }

        if (blank($user->locale)) {
            $user->locale = $claims['locale'] ?? 'fr';
        }

        if (! $user->exists) {
            // Compte OIDC uniquement : la colonne password est NOT NULL mais jamais utilisée pour se connecter.
            $user->password = Str::password(40);
        }

        $user->save();

        Streak::firstOrCreate(['user_id' => $user->id]);

        return $user;
    }

    /**
     * Récupère (et met en cache une heure) le document de discovery OIDC de pocket-id.
     *
     * @return array{authorization_endpoint: string, token_endpoint: string, userinfo_endpoint: string}
     */
    private function discoverProvider(): array
    {
        return Cache::remember('oidc.discovery', now()->addHour(), function (): array {
            $response = Http::get(rtrim((string) config('services.oidc.issuer'), '/') . '/.well-known/openid-configuration');

            if ($response->failed()) {
                throw new OidcAuthenticationFailedException();
            }

            $authorizationEndpoint = $response->json('authorization_endpoint');
            $tokenEndpoint = $response->json('token_endpoint');
            $userinfoEndpoint = $response->json('userinfo_endpoint');

            if (! is_string($authorizationEndpoint) || ! is_string($tokenEndpoint) || ! is_string($userinfoEndpoint)) {
                throw new OidcAuthenticationFailedException();
            }

            return [
                'authorization_endpoint' => $authorizationEndpoint,
                'token_endpoint' => $tokenEndpoint,
                'userinfo_endpoint' => $userinfoEndpoint,
            ];
        });
    }

    /**
     * Génère un code_verifier PKCE aléatoire (RFC 7636 : 43 à 128 caractères base64url).
     */
    private function generateCodeVerifier(): string
    {
        return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
    }

    /**
     * Dérive le code_challenge S256 du code_verifier PKCE.
     */
    private function codeChallenge(string $codeVerifier): string
    {
        return rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
    }
}
