import type { Auth0Client } from '../Auth0Client';
import type { TokenEndpointResponse } from '../global';
import type {
  PasskeySignupOptions,
  PasskeyLoginOptions,
  PasskeyCredentialResponse,
  PasskeyCreationOptions,
  PasskeyRequestOptions,
  PasskeySignupChallengeOptions,
  PasskeyLoginChallengeOptions,
  PasskeySignupChallenge,
  PasskeyLoginChallenge,
  PasskeyGetTokenOptions
} from './types';
import type { PasskeyClient } from '@auth0/auth0-auth-js';
import { PasskeyError, PasskeyGetTokenError } from './errors';
import { PasskeyVerificationRequiredError } from '../errors';

/**
 * Client for Auth0 Passkey operations.
 *
 * Provides 2 public methods:
 * - `signup` — Register a new user with a passkey (full flow: challenge → WebAuthn → token exchange)
 * - `login` — Sign in with a passkey (full flow: challenge → WebAuthn → token exchange)
 *
 * @example
 * ```typescript
 * // Signup — single call handles everything
 * const tokens = await auth0.passkey.signup({ email: 'user@example.com' });
 *
 * // Login — single call handles everything
 * const tokens = await auth0.passkey.login();
 * ```
 *
 * @category Clients
 */
export class PasskeyApiClient {
  #passkeyClient: PasskeyClient;
  #auth0Client: Auth0Client;
  #pendingSignup: {
    authSession: string;
    publicKey: PublicKeyCredentialCreationOptions;
    verificationRequired: Array<'email' | 'phone'>;
    /** Set after the first attempt, so a retry reuses it without a second biometric prompt. */
    credential?: PasskeyCredentialResponse;
    realm?: string;
    organization?: string;
    scope?: string;
    audience?: string;
  } | null = null;

  /**
   * @internal
   * Do not instantiate directly. Use Auth0Client.passkey instead.
   */
  constructor(passkeyClient: PasskeyClient, auth0Client: Auth0Client) {
    this.#passkeyClient = passkeyClient;
    this.#auth0Client = auth0Client;
  }

  /**
   * Register a new user with a passkey.
   *
   * Handles the full flow: requests a signup challenge, triggers the browser
   * WebAuthn credential creation ceremony, serializes the result, and exchanges
   * it for tokens.
   *
   * If the server requires identifier verification, it stops before creating the
   * passkey and throws `PasskeyVerificationRequiredError`. Collect the OTP codes
   * and call `continueSignup()` to finish.
   *
   * @param options - Passkey signup options (user identifier, optional scope/audience)
   * @returns A promise that resolves to the token endpoint response containing access/ID tokens
   * @throws {PasskeyError} If WebAuthn is not supported in the browser
   * @throws {PasskeyRegisterError} If the challenge request fails
   * @throws {PasskeyVerificationRequiredError} If OTP codes are needed before the passkey is created
   * @throws {GenericError} If the token exchange fails
   * @throws {PasskeyError} If the user cancels the WebAuthn prompt
   */
  async signup(options: PasskeySignupOptions): Promise<TokenEndpointResponse> {
    if (!window.PublicKeyCredential) {
      throw new PasskeyError('passkey_not_supported', 'WebAuthn is not supported in this browser.');
    }

    this.#pendingSignup = null;

    const { scope, audience, ...challengeOptions } = options;

    const challenge = await this.#passkeyClient.register(challengeOptions);

    if (challenge.verificationRequired && challenge.verificationRequired.length > 0) {
      this.#pendingSignup = {
        authSession: challenge.authSession,
        publicKey: prepareCreationOptions(challenge.authnParamsPublicKey),
        verificationRequired: challenge.verificationRequired,
        realm: challengeOptions.realm,
        organization: challengeOptions.organization,
        scope,
        audience,
      };
      throw new PasskeyVerificationRequiredError(challenge.verificationRequired);
    }

    const publicKeyOptions = prepareCreationOptions(
      challenge.authnParamsPublicKey
    );
    const credential = await navigator.credentials.create({
      publicKey: publicKeyOptions
    });

    if (!credential) {
      throw new PasskeyError(
        'passkey_cancelled',
        'Passkey creation was cancelled or no credential was returned.'
      );
    }

    const serialized = serializeCreationCredential(
      credential as PublicKeyCredential
    );

    return this.#auth0Client._requestTokenForPasskey({
      authSession: challenge.authSession,
      credential: serialized,
      realm: challengeOptions.realm,
      organization: challengeOptions.organization,
      scope,
      audience
    });
  }

  /**
   * Sign in with an existing passkey.
   *
   * Handles the full flow: requests a login challenge, triggers the browser
   * WebAuthn assertion ceremony, serializes the result, and exchanges it
   * for tokens.
   *
   * @param options - Optional passkey login options (optional scope/audience/realm/organization)
   * @returns A promise that resolves to the token endpoint response containing access/ID tokens
   * @throws {PasskeyError} If WebAuthn is not supported in the browser
   * @throws {PasskeyChallengeError} If the challenge request fails
   * @throws {GenericError} If the token exchange fails
   * @throws {PasskeyError} If the user cancels the WebAuthn prompt
   */
  async login(options?: PasskeyLoginOptions): Promise<TokenEndpointResponse> {
    if (!window.PublicKeyCredential) {
      throw new PasskeyError('passkey_not_supported', 'WebAuthn is not supported in this browser.');
    }

    const { scope, audience, ...challengeOptions } = options || {};

    const challenge = await this.#passkeyClient.challenge(
      Object.keys(challengeOptions).length > 0 ? challengeOptions : undefined
    );

    const publicKeyOptions = prepareRequestOptions(
      challenge.authnParamsPublicKey
    );
    const credential = await navigator.credentials.get({
      publicKey: publicKeyOptions
    });

    if (!credential) {
      throw new PasskeyError(
        'passkey_cancelled',
        'Passkey authentication was cancelled or no credential was returned.'
      );
    }

    const serialized = serializeAssertionCredential(
      credential as PublicKeyCredential
    );

    return this.#auth0Client._requestTokenForPasskey({
      authSession: challenge.authSession,
      credential: serialized,
      realm: challengeOptions.realm,
      organization: challengeOptions.organization,
      scope,
      audience
    });
  }

  /**
   * Request a passkey signup challenge.
   *
   * Step 1 of the granular signup flow. Returns the auth session and a
   * decoded `PublicKeyCredentialCreationOptions`. Run the WebAuthn credential
   * creation ceremony with `publicKey`, then hand the resulting credential and `authSession` to
   * `getTokenWithPasskey()`.
   *
   * @param options - Signup challenge options (user identifier, optional realm/organization/metadata)
   * @returns A promise resolving to `{ authSession, publicKey }`
   * @throws {PasskeyError} If WebAuthn is not supported in the browser
   * @throws {PasskeyRegisterError} If the challenge request fails
   */
  async getSignupChallenge(
    options: PasskeySignupChallengeOptions
  ): Promise<PasskeySignupChallenge> {
    if (!window.PublicKeyCredential) {
      throw new PasskeyError('passkey_not_supported', 'WebAuthn is not supported in this browser.');
    }

    const challenge = await this.#passkeyClient.register(options);
    return {
      authSession: challenge.authSession,
      publicKey: prepareCreationOptions(challenge.authnParamsPublicKey),
      ...(challenge.verificationRequired && { verificationRequired: challenge.verificationRequired }),
    };
  }

  /**
   * Request a passkey login challenge.
   *
   * Step 1 of the granular login flow. Returns the auth session and a
   * decoded `PublicKeyCredentialRequestOptions`. Run the WebAuthn assertion
   * ceremony with `publicKey`, then hand the resulting credential and `authSession` to
   * `getTokenWithPasskey()`.
   *
   * @param options - Optional login challenge options (realm/organization)
   * @returns A promise resolving to `{ authSession, publicKey }`
   * @throws {PasskeyError} If WebAuthn is not supported in the browser
   * @throws {PasskeyChallengeError} If the challenge request fails
   */
  async getLoginChallenge(
    options?: PasskeyLoginChallengeOptions
  ): Promise<PasskeyLoginChallenge> {
    if (!window.PublicKeyCredential) {
      throw new PasskeyError('passkey_not_supported', 'WebAuthn is not supported in this browser.');
    }

    const challenge = await this.#passkeyClient.challenge(options);

    return {
      authSession: challenge.authSession,
      publicKey: prepareRequestOptions(challenge.authnParamsPublicKey)
    };
  }

  /**
   * Exchange a signed passkey credential for tokens.
   *
   * Step 2 of the granular flow. Serializes the raw `PublicKeyCredential`
   * produced by the WebAuthn ceremony — either a creation (signup) or an
   * assertion (login) credential — and exchanges it for tokens. The credential
   * type (attestation vs assertion) is detected automatically.
   *
   * @param options - The auth session, raw credential, and optional realm/organization/scope/audience
   * @returns A promise resolving to the token endpoint response
   * @throws {PasskeyError} If WebAuthn is not supported in the browser
   * @throws {PasskeyError} If the credential is not a valid attestation or assertion response
   * @throws {PasskeyError} If a code in `verification` is empty. Nothing is sent, so the same session can be retried
   * @throws {PasskeyGetTokenError} If an OTP code is wrong and the session is still active (`isRetryable: true`)
   * @throws {GenericError} If the token exchange fails
   */
  async getTokenWithPasskey(
    options: PasskeyGetTokenOptions
  ): Promise<TokenEndpointResponse> {
    if (!window.PublicKeyCredential) {
      throw new PasskeyError('passkey_not_supported', 'WebAuthn is not supported in this browser.');
    }

    const { authSession, credential, realm, organization, scope, audience, verification } = options;

    const codes: Record<string, string | undefined> = verification ?? {};
    if (Object.keys(codes).some(id => codes[id] === '')) {
      throw new PasskeyError('missing_verification_code', 'OTP codes in verification must not be empty.');
    }

    const response = credential.response;

    let serialized: PasskeyCredentialResponse;

    if (response instanceof AuthenticatorAttestationResponse) {
      serialized = serializeCreationCredential(credential);
    } else if (response instanceof AuthenticatorAssertionResponse) {
      serialized = serializeAssertionCredential(credential);
    } else {
      throw new PasskeyError(
        'passkey_invalid_credential',
        'The provided credential is not a valid attestation or assertion response.'
      );
    }

    return this.#auth0Client._requestTokenForPasskey({
      authSession,
      credential: serialized,
      realm,
      organization,
      scope,
      audience,
      verification,
    });
  }

  /**
   * Continue a signup that was interrupted by identifier verification.
   *
   * Call this after catching `PasskeyVerificationRequiredError` from `signup()`,
   * collecting OTP codes from the user, and passing them here. The SDK
   * internally retains the auth session — you do not need to pass it.
   *
   * If the codes are wrong but the session is still alive, this throws a
   * `PasskeyGetTokenError` with `isRetryable: true`. Collect new codes for
   * `error.verificationRequired` and call `continueSignup()` again; the same
   * passkey is reused, so the user is not prompted again.
   *
   * @param verification - Map of identifier → OTP code (e.g. `{ phone: "123456" }`)
   * @returns A promise resolving to the token endpoint response
   * @throws {PasskeyError} If there is no pending signup (signup() was not called first)
   * @throws {PasskeyError} If a code is missing for a required identifier. The pending signup is kept
   * @throws {PasskeyGetTokenError} If an OTP code is wrong and the session is still active (`isRetryable: true`)
   * @throws {GenericError} If the token exchange fails
   */
  async continueSignup(verification: { email?: string; phone?: string }): Promise<TokenEndpointResponse> {
    if (!this.#pendingSignup) {
      throw new PasskeyError(
        'no_pending_signup',
        'No pending signup found. Call signup() first and catch PasskeyVerificationRequiredError.'
      );
    }

    // Check before the passkey prompt, so a missing code doesn't leave an unused passkey.
    const missing = this.#pendingSignup.verificationRequired.filter(id => !verification?.[id]);
    if (missing.length > 0) {
      throw new PasskeyError(
        'missing_verification_code',
        `Missing OTP code for: ${missing.join(', ')}. Pass a code for each identifier in verificationRequired.`
      );
    }

    // Clear before any await so a concurrent call can't reuse the same session.
    const pending = this.#pendingSignup;
    this.#pendingSignup = null;
    const { authSession, publicKey, realm, organization, scope, audience } = pending;

    let serialized = pending.credential;
    if (!serialized) {
      let credential: Credential | null;
      try {
        credential = await navigator.credentials.create({ publicKey });
      } catch (e) {
        this.#pendingSignup = pending;
        throw e;
      }

      if (!credential) {
        this.#pendingSignup = pending;
        throw new PasskeyError(
          'passkey_cancelled',
          'Passkey creation was cancelled or no credential was returned.'
        );
      }

      serialized = serializeCreationCredential(credential as PublicKeyCredential);
    }

    try {
      return await this.#auth0Client._requestTokenForPasskey({
        authSession,
        credential: serialized,
        realm,
        organization,
        scope,
        audience,
        verification,
      });
    } catch (e) {
      if (e instanceof PasskeyGetTokenError && e.isRetryable) {
        this.#pendingSignup = {
          ...pending,
          authSession: e.authSession!,
          verificationRequired: e.verificationRequired!,
          credential: serialized
        };
      }
      throw e;
    }
  }

}

function bufferToBase64url(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer);
  const binary = Array.from(bytes, b => String.fromCharCode(b)).join('');
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function base64urlToBuffer(base64url: string): ArrayBuffer {
  const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
  const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
  const binary = atob(padded);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes.buffer;
}

function prepareCreationOptions(
  publicKey: PasskeyCreationOptions
): PublicKeyCredentialCreationOptions {
  return {
    ...publicKey,
    challenge: base64urlToBuffer(publicKey.challenge),
    user: {
      ...publicKey.user,
      id: base64urlToBuffer(publicKey.user.id)
    },
    pubKeyCredParams: publicKey.pubKeyCredParams as PublicKeyCredentialParameters[],
    authenticatorSelection: publicKey.authenticatorSelection as AuthenticatorSelectionCriteria | undefined
  };
}

function prepareRequestOptions(
  publicKey: PasskeyRequestOptions
): PublicKeyCredentialRequestOptions {
  return {
    ...publicKey,
    challenge: base64urlToBuffer(publicKey.challenge)
  } as PublicKeyCredentialRequestOptions;
}

function serializeCreationCredential(
  credential: PublicKeyCredential
): PasskeyCredentialResponse {
  const response = credential.response as AuthenticatorAttestationResponse;
  return {
    id: credential.id,
    rawId: bufferToBase64url(credential.rawId),
    type: credential.type,
    authenticatorAttachment: credential.authenticatorAttachment ?? undefined,
    response: {
      clientDataJSON: bufferToBase64url(response.clientDataJSON),
      attestationObject: bufferToBase64url(response.attestationObject)
    },
    clientExtensionResults: credential.getClientExtensionResults() as Record<string, unknown>
  };
}

function serializeAssertionCredential(
  credential: PublicKeyCredential
): PasskeyCredentialResponse {
  const response = credential.response as AuthenticatorAssertionResponse;
  return {
    id: credential.id,
    rawId: bufferToBase64url(credential.rawId),
    type: credential.type,
    authenticatorAttachment: credential.authenticatorAttachment ?? undefined,
    response: {
      clientDataJSON: bufferToBase64url(response.clientDataJSON),
      authenticatorData: bufferToBase64url(response.authenticatorData),
      signature: bufferToBase64url(response.signature),
      userHandle: response.userHandle
        ? bufferToBase64url(response.userHandle)
        : undefined
    },
    clientExtensionResults: credential.getClientExtensionResults() as Record<string, unknown>
  };
}
