<?php

namespace App\Services;

use App\Models\User;
use App\Models\WebauthnCredential;
use App\Support\Webauthn\ZeroTolerantCounterChecker;
use Throwable;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
use Webauthn\Denormalizer\WebauthnSerializerFactory;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity;

/**
 * The single seam onto web-auth/webauthn-lib (v5). Wraps the registration + assertion
 * ceremonies for a security-key SECOND factor. Gated on a domain + HTTPS — the rpId is
 * the active domain (an IP literal is never a valid rpId).
 *
 * NOTE: the cryptographic ceremony (verifyRegistration/verifyAssertion) can only be
 * exercised by a real authenticator over HTTPS on the rp domain; it is browser-verified
 * on a domain host. The option builders + storage are unit-tested here.
 */
class WebauthnService
{
    private const RP_NAME = 'Clusev';

    public function __construct(private DeploymentService $deployment) {}

    /**
     * WebAuthn needs a secure context AND a domain rpId — never a bare IP. Available when the
     * panel is reached via the ACTIVE DOMAIN, which is always served over HTTPS through the front
     * door (Caddy's own TLS or an external TLS proxy). Deliberately NOT keyed on
     * request()->isSecure(): behind an external TLS proxy the forwarded scheme may not be trusted
     * here, so the request looks like plain HTTP even though the browser context is secure — that
     * would wrongly lock out hardware keys. The bare-IP recovery host (http, no secure context)
     * is excluded by the host check.
     */
    public function available(): bool
    {
        $domain = $this->deployment->domain();
        $request = request();

        return $domain !== null
            && $request !== null
            && strtolower($request->getHost()) === $domain;
    }

    /** The Relying-Party ID = the active domain (asserted present by available()). */
    public function rpId(): string
    {
        return (string) $this->deployment->domain();
    }

    /** @return array<string, mixed> JSON-ready creation options; challenge stored in session. */
    public function registrationOptions(User $user): array
    {
        $exclude = $user->webauthnCredentials
            ->map(fn (WebauthnCredential $c) => PublicKeyCredentialDescriptor::create('public-key', $this->fromB64u($c->credential_id)))
            ->all();

        // Target a roaming HARDWARE security key (YubiKey) used as a SECOND factor with a single
        // touch — NOT a platform/password-manager passkey:
        //  - cross-platform  → the browser asks for an external/roaming key (steers away from the
        //                       OS/Bitwarden "save passkey" flow).
        //  - residentKey discouraged → a non-discoverable second-factor credential (a resident /
        //                       discoverable credential is what triggers the passkey-manager UI).
        //  - userVerification discouraged → user PRESENCE only (one tap), no PIN/biometric.
        $authenticatorSelection = AuthenticatorSelectionCriteria::create(
            AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM,
            AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_DISCOURAGED,
            AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_DISCOURAGED,
        );

        $options = PublicKeyCredentialCreationOptions::create(
            PublicKeyCredentialRpEntity::create(self::RP_NAME, $this->rpId()),
            PublicKeyCredentialUserEntity::create($user->email, (string) $user->id, $user->name),
            random_bytes(32),
            pubKeyCredParams: [
                PublicKeyCredentialParameters::createPk(-7),    // ES256
                PublicKeyCredentialParameters::createPk(-257),  // RS256
            ],
            authenticatorSelection: $authenticatorSelection,
            attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
            excludeCredentials: $exclude,
        );

        $json = $this->serializer()->serialize($options, 'json');
        session(['webauthn.register' => $json]);

        // WebAuthn L3 `hints`: tell the client to prefer a roaming HARDWARE security key. Modern
        // browsers + passkey managers (Bitwarden, 1Password, …) that honor hints then step ASIDE
        // and let the YubiKey prompt through instead of hijacking the ceremony to "save a passkey".
        // Injected into the client payload only (not the session copy used for server validation).
        $result = json_decode($json, true);
        $result['hints'] = ['security-key'];

        return $result;
    }

    public function verifyRegistration(User $user, array $response, string $name): WebauthnCredential
    {
        $serializer = $this->serializer();
        $options = $serializer->deserialize((string) session('webauthn.register'), PublicKeyCredentialCreationOptions::class, 'json');

        $credential = $serializer->deserialize(json_encode($response), PublicKeyCredential::class, 'json');
        $attestation = $credential->response;
        abort_unless($attestation instanceof AuthenticatorAttestationResponse, 422);

        $validator = AuthenticatorAttestationResponseValidator::create($this->ceremony()->creationCeremony());
        $record = $validator->check($attestation, $options, $this->rpId());
        $source = PublicKeyCredentialSource::fromCredentialRecord($record);

        $cred = $user->webauthnCredentials()->create([
            'name' => $name,
            'credential_id' => $this->toB64u($source->publicKeyCredentialId),
            'public_key' => $serializer->serialize($source, 'json'),
            'aaguid' => $record->aaguid->__toString(),
            'transports' => $record->transports,
            'sign_count' => $record->counter,
        ]);

        session()->forget('webauthn.register');

        return $cred;
    }

    /** @return array<string, mixed> JSON-ready request options; challenge stored in session. */
    public function assertionOptions(User $user): array
    {
        $allow = $user->webauthnCredentials
            ->map(fn (WebauthnCredential $c) => PublicKeyCredentialDescriptor::create('public-key', $this->fromB64u($c->credential_id)))
            ->all();

        $options = PublicKeyCredentialRequestOptions::create(
            random_bytes(32),
            rpId: $this->rpId(),
            allowCredentials: $allow,
            // Single touch (user presence) — no PIN/biometric, matching the registration's
            // discouraged userVerification, so login is: plug in → one tap → done.
            userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_DISCOURAGED,
        );

        $json = $this->serializer()->serialize($options, 'json');
        session(['webauthn.login' => $json]);

        // Same security-key hint on login, so the passkey manager doesn't intercept the assertion.
        $result = json_decode($json, true);
        $result['hints'] = ['security-key'];

        return $result;
    }

    /** Validate an assertion against the user's stored credential. Never throws into the login path. */
    public function verifyAssertion(User $user, array $response): bool
    {
        try {
            $serializer = $this->serializer();
            $credential = $serializer->deserialize(json_encode($response), PublicKeyCredential::class, 'json');
            $assertion = $credential->response;
            if (! $assertion instanceof AuthenticatorAssertionResponse) {
                return false;
            }

            $model = $user->webauthnCredentials()
                ->where('credential_id_hash', hash('sha256', $this->toB64u($credential->rawId)))
                ->first();
            if (! $model) {
                return false;
            }

            $source = $serializer->deserialize($model->public_key, PublicKeyCredentialSource::class, 'json');
            $options = $serializer->deserialize((string) session('webauthn.login'), PublicKeyCredentialRequestOptions::class, 'json');

            $validator = AuthenticatorAssertionResponseValidator::create($this->ceremony()->requestCeremony());
            $updated = $validator->check($source, $assertion, $options, $this->rpId(), (string) $user->id);

            // Re-serialize the UPDATED record so the new sign counter is also embedded in
            // public_key — otherwise the next assertion would validate against the stale
            // registration-time counter and clone/rollback detection would never fire.
            $updatedSource = $updated instanceof PublicKeyCredentialSource
                ? $updated
                : PublicKeyCredentialSource::fromCredentialRecord($updated);

            $model->update([
                'public_key' => $serializer->serialize($updatedSource, 'json'),
                'sign_count' => $updated->counter,
                'last_used_at' => now(),
            ]);
            session()->forget('webauthn.login');

            return true;
        } catch (Throwable) {
            return false;
        }
    }

    private function serializer()
    {
        return (new WebauthnSerializerFactory(
            AttestationStatementSupportManager::create([new NoneAttestationStatementSupport]),
        ))->create();
    }

    private function ceremony(): CeremonyStepManagerFactory
    {
        // Defaults register ES256+RS256 + NoneAttestation; constrain the origin to
        // https://<rpId> and swap the strict counter checker for a zero-tolerant one
        // (platform authenticators / passkeys always report counter 0).
        $factory = new CeremonyStepManagerFactory;
        $factory->setAllowedOrigins(['https://'.$this->rpId()]);
        $factory->setCounterChecker(new ZeroTolerantCounterChecker);

        return $factory;
    }

    private function toB64u(string $bin): string
    {
        return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
    }

    private function fromB64u(string $s): string
    {
        return (string) base64_decode(strtr($s, '-_', '+/'));
    }
}
