import {
  createQueryParams,
  runPopup,
  parseAuthenticationResult,
  encode,
  createRandomString,
  runIframe,
  sha256,
  bufferToBase64UrlEncoded,
  validateCrypto,
  openPopup,
  getDomain,
  getTokenIssuer,
  parseNumber,
  stripAuth0Client
} from './utils';

import { getLockManager, type ILockManager } from './lock';

import { oauthToken, revokeToken } from './api';

import { injectDefaultScopes, scopesToRequest } from './scope';

import {
  InMemoryCache,
  ICache,
  CacheKey,
  CacheManager,
  CacheEntry,
  IdTokenEntry,
  CACHE_KEY_ID_TOKEN_SUFFIX,
  DecodedToken
} from './cache';

import { ConnectAccountTransaction, LoginTransaction, TransactionManager } from './transaction-manager';
import { verify as verifyIdToken } from './jwt';
import {
  AuthenticationError,
  ConnectError,
  GenericError,
  MfaRequiredError,
  MissingRefreshTokenError,
  MissingScopesError,
  PopupOpenError,
  TimeoutError
} from './errors';

import {
  ClientStorage,
  CookieStorage,
  CookieStorageWithLegacySameSite,
  SessionStorage
} from './storage';

import {
  CACHE_LOCATION_MEMORY,
  DEFAULT_POPUP_CONFIG_OPTIONS,
  DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS,
  MISSING_REFRESH_TOKEN_ERROR_MESSAGE,
  MFA_STEP_UP_ERROR_DESCRIPTION,
  DEFAULT_SCOPE,
  DEFAULT_SESSION_CHECK_EXPIRY_DAYS,
  DEFAULT_AUTH0_CLIENT,
  INVALID_REFRESH_TOKEN_ERROR_MESSAGE,
  USER_BLOCKED_ERROR_MESSAGE,
  DEFAULT_NOW_PROVIDER,
  DEFAULT_FETCH_TIMEOUT_MS,
  DEFAULT_AUDIENCE
} from './constants';

import {
  Auth0ClientOptions,
  AuthorizationParams,
  AuthorizeOptions,
  RedirectLoginOptions,
  PopupLoginOptions,
  PopupConfigOptions,
  RedirectLoginResult,
  GetTokenSilentlyOptions,
  GetTokenWithPopupOptions,
  LogoutOptions,
  CacheLocation,
  LogoutUrlOptions,
  User,
  IdToken,
  GetTokenSilentlyVerboseResponse,
  TokenEndpointResponse,
  AuthenticationResult,
  ConnectAccountRedirectResult,
  RedirectConnectAccountOptions,
  ResponseType,
  ClientAuthorizationParams,
  ClientConfiguration,
  RevokeRefreshTokenOptions
} from './global';

// @ts-ignore
import TokenWorker from './worker/token.worker.ts';
import { singlePromise, retryPromise } from './promise-utils';
import { CacheKeyManifest } from './cache/key-manifest';
import {
  buildIsAuthenticatedCookieName,
  buildOrganizationHintCookieName,
  cacheFactory,
  getAuthorizeParams,
  buildGetTokenSilentlyLockKey,
  buildIframeLockKey,
  OLD_IS_AUTHENTICATED_COOKIE_NAME,
  patchOpenUrlWithOnRedirect,
  getScopeToRequest,
  allScopesAreIncluded,
  isRefreshWithMrrt,
  getMissingScopes
} from './Auth0Client.utils';
import { CustomTokenExchangeOptions } from './TokenExchange';
import { Dpop } from './dpop/dpop';
import {
  Fetcher,
  type FetcherConfig,
  type CustomFetchMinimalOutput
} from './fetcher';
import { MyAccountApiClient } from './MyAccountApiClient';
import { MfaApiClient } from './mfa';
import { AuthClient as Auth0AuthJsClient } from '@auth0/auth0-auth-js';

/**
 * @ignore
 */
type GetTokenSilentlyResult = TokenEndpointResponse & {
  decodedToken: ReturnType<typeof verifyIdToken>;
  scope: string;
  oauthTokenScope?: string;
  audience: string;
};

/**
 * Auth0 SDK for Single Page Applications using [Authorization Code Grant Flow with PKCE](https://auth0.com/docs/api-auth/tutorials/authorization-code-grant-pkce).
 */
export class Auth0Client {
  private readonly transactionManager: TransactionManager;
  private readonly cacheManager: CacheManager;
  private readonly lockManager: ILockManager;
  private readonly domainUrl: string;
  private readonly tokenIssuer: string;
  private readonly scope: Record<string, string>;
  private readonly cookieStorage: ClientStorage;
  private readonly dpop: Dpop | undefined;
  private readonly sessionCheckExpiryDays: number;
  private readonly orgHintCookieName: string;
  private readonly isAuthenticatedCookieName: string;
  private readonly nowProvider: () => number | Promise<number>;
  private readonly httpTimeoutMs: number;
  private readonly options: Auth0ClientOptions & {
    authorizationParams: ClientAuthorizationParams,
  };
  private readonly userCache: ICache = new InMemoryCache().enclosedCache;
  private readonly myAccountApi: MyAccountApiClient;

  /**
   * MFA API client for multi-factor authentication operations.
   *
   * Provides methods for:
   * - Listing enrolled authenticators
   * - Enrolling new authenticators (OTP, SMS, Voice, Push, Email)
   * - Initiating MFA challenges
   * - Verifying MFA challenges
   */
  public readonly mfa: MfaApiClient;

  private worker?: Worker;
  private readonly authJsClient: Auth0AuthJsClient;

  private readonly defaultOptions: Partial<Auth0ClientOptions> = {
    authorizationParams: {
      scope: DEFAULT_SCOPE
    },
    useRefreshTokensFallback: false,
    useFormData: true
  };

  constructor(options: Auth0ClientOptions) {
    this.options = {
      ...this.defaultOptions,
      ...options,
      authorizationParams: {
        ...this.defaultOptions.authorizationParams,
        ...options.authorizationParams
      }
    };

    typeof window !== 'undefined' && validateCrypto();

    this.lockManager = getLockManager();

    if (options.cache && options.cacheLocation) {
      console.warn(
        'Both `cache` and `cacheLocation` options have been specified in the Auth0Client configuration; ignoring `cacheLocation` and using `cache`.'
      );
    }

    let cacheLocation: CacheLocation | undefined;
    let cache: ICache;

    if (options.cache) {
      cache = options.cache;
    } else {
      cacheLocation = options.cacheLocation || CACHE_LOCATION_MEMORY;

      if (!cacheFactory(cacheLocation)) {
        throw new Error(`Invalid cache location "${cacheLocation}"`);
      }

      cache = cacheFactory(cacheLocation)();
    }

    this.httpTimeoutMs = options.httpTimeoutInSeconds
      ? options.httpTimeoutInSeconds * 1000
      : DEFAULT_FETCH_TIMEOUT_MS;

    this.cookieStorage =
      options.legacySameSiteCookie === false
        ? CookieStorage
        : CookieStorageWithLegacySameSite;

    this.orgHintCookieName = buildOrganizationHintCookieName(
      this.options.clientId
    );

    this.isAuthenticatedCookieName = buildIsAuthenticatedCookieName(
      this.options.clientId
    );

    this.sessionCheckExpiryDays =
      options.sessionCheckExpiryDays || DEFAULT_SESSION_CHECK_EXPIRY_DAYS;

    const transactionStorage = options.useCookiesForTransactions
      ? this.cookieStorage
      : SessionStorage;

    // Construct the scopes based on the following:
    // 1. Always include `openid`
    // 2. Include the scopes provided in `authorizationParams. This defaults to `profile email`
    // 3. Add `offline_access` if `useRefreshTokens` is enabled
    this.scope = injectDefaultScopes(
      this.options.authorizationParams.scope,
      'openid',
      this.options.useRefreshTokens ? 'offline_access' : ''
    );

    this.transactionManager = new TransactionManager(
      transactionStorage,
      this.options.clientId,
      this.options.cookieDomain
    );

    this.nowProvider = this.options.nowProvider || DEFAULT_NOW_PROVIDER;

    this.cacheManager = new CacheManager(
      cache,
      !cache.allKeys
        ? new CacheKeyManifest(cache, this.options.clientId)
        : undefined,
      this.nowProvider
    );

    this.dpop = this.options.useDpop
      ? new Dpop(this.options.clientId)
      : undefined;

    this.domainUrl = getDomain(this.options.domain);
    this.tokenIssuer = getTokenIssuer(this.options.issuer, this.domainUrl);

    const myAccountApiIdentifier = `${this.domainUrl}/me/`;
    const myAccountFetcher = this.createFetcher({
      ...(this.options.useDpop && { dpopNonceId: '__auth0_my_account_api__' }),
      getAccessToken: () =>
        this.getTokenSilently({
          authorizationParams: {
            scope: 'create:me:connected_accounts',
            audience: myAccountApiIdentifier
          },
          detailedResponse: true
        })
    });
    this.myAccountApi = new MyAccountApiClient(
      myAccountFetcher,
      myAccountApiIdentifier
    );

    // Initialize auth-js client foundational Oauth feature support
    this.authJsClient = new Auth0AuthJsClient({
      domain: this.options.domain,
      clientId: this.options.clientId,
    });
    this.mfa = new MfaApiClient(this.authJsClient.mfa, this);


    // Don't use web workers unless using refresh tokens in memory
    if (
      typeof window !== 'undefined' &&
      window.Worker &&
      this.options.useRefreshTokens &&
      cacheLocation === CACHE_LOCATION_MEMORY
    ) {
      if (this.options.workerUrl) {
        this.worker = new Worker(this.options.workerUrl);
      } else {
        this.worker = new TokenWorker();
      }

      this.worker!.postMessage({
        type: 'init',
        allowedBaseUrl: this.domainUrl
      });
    }
  }

  /**
   * Returns a readonly copy of the initialization configuration.
   *
   * @returns An object containing domain and clientId
   *
   * @example
   * ```typescript
   * const auth0 = new Auth0Client({
   *   domain: 'tenant.auth0.com',
   *   clientId: 'abc123'
   * });
   *
   * const config = auth0.getConfiguration();
   * // { domain: 'tenant.auth0.com', clientId: 'abc123' }
   * ```
   */
  public getConfiguration(): Readonly<ClientConfiguration> {
    return Object.freeze({
      domain: this.options.domain,
      clientId: this.options.clientId
    });
  }

  private _url(path: string) {
    const auth0ClientObj = this.options.auth0Client || DEFAULT_AUTH0_CLIENT;
    // Strip env from auth0Client for /authorize to prevent query param truncation
    const strippedAuth0Client = stripAuth0Client(auth0ClientObj, true);
    const auth0Client = encodeURIComponent(
      btoa(JSON.stringify(strippedAuth0Client))
    );
    return `${this.domainUrl}${path}&auth0Client=${auth0Client}`;
  }

  private _authorizeUrl(authorizeOptions: AuthorizeOptions) {
    return this._url(`/authorize?${createQueryParams(authorizeOptions)}`);
  }

  private async _verifyIdToken(
    id_token: string,
    nonce?: string,
    organization?: string
  ) {
    const now = await this.nowProvider();

    return verifyIdToken({
      iss: this.tokenIssuer,
      aud: this.options.clientId,
      id_token,
      nonce,
      organization,
      leeway: this.options.leeway,
      max_age: parseNumber(this.options.authorizationParams.max_age),
      now
    });
  }

  private _processOrgHint(organization?: string) {
    if (organization) {
      this.cookieStorage.save(this.orgHintCookieName, organization, {
        daysUntilExpire: this.sessionCheckExpiryDays,
        cookieDomain: this.options.cookieDomain
      });
    } else {
      this.cookieStorage.remove(this.orgHintCookieName, {
        cookieDomain: this.options.cookieDomain
      });
    }
  }

  /**
   * Extracts the session transfer token from the current URL query parameters
   * for Native to Web SSO flows.
   *
   * @param paramName The query parameter name to extract from the URL
   * @returns The session transfer token if present, undefined otherwise
   */
  private _extractSessionTransferToken(paramName: string): string | undefined {
    const params = new URLSearchParams(window.location.search);
    return params.get(paramName) || undefined;
  }

  /**
   * Clears the session transfer token from the current URL using the History API.
   * This prevents the token from being re-sent on subsequent authentication requests,
   * which is important since session transfer tokens are typically single-use.
   *
   * @param paramName The query parameter name to remove from the URL
   */
  private _clearSessionTransferTokenFromUrl(paramName: string): void {
    try {
      const url = new URL(window.location.href);
      if (url.searchParams.has(paramName)) {
        url.searchParams.delete(paramName);
        window.history.replaceState({}, '', url.toString());
      }
    } catch {
      // Silently fail if URL manipulation isn't possible
    }
  }

  /**
   * Applies the session transfer token from the URL to the authorization parameters
   * if configured and not already provided.
   *
   * @param authorizationParams The authorization parameters to enhance
   * @returns The authorization parameters with session_transfer_token added if applicable
   */
  private _applySessionTransferToken(
    authorizationParams: AuthorizationParams
  ): AuthorizationParams {
    const paramName = this.options.sessionTransferTokenQueryParamName;
    if (!paramName || authorizationParams.session_transfer_token) {
      return authorizationParams;
    }
    const token = this._extractSessionTransferToken(paramName);
    if (!token) return authorizationParams;
    this._clearSessionTransferTokenFromUrl(paramName);
    return { ...authorizationParams, session_transfer_token: token };
  }

  private async _prepareAuthorizeUrl(
    authorizationParams: AuthorizationParams,
    authorizeOptions?: Partial<AuthorizeOptions>,
    fallbackRedirectUri?: string
  ): Promise<{
    scope: string;
    audience: string;
    redirect_uri?: string;
    nonce: string;
    code_verifier: string;
    state: string;
    url: string;
  }> {
    const state = encode(createRandomString());
    const nonce = encode(createRandomString());
    const code_verifier = createRandomString();
    const code_challengeBuffer = await sha256(code_verifier);
    const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer);
    const thumbprint = await this.dpop?.calculateThumbprint();

    const params = getAuthorizeParams(
      this.options,
      this.scope,
      authorizationParams,
      state,
      nonce,
      code_challenge,
      authorizationParams.redirect_uri ||
      this.options.authorizationParams.redirect_uri ||
      fallbackRedirectUri,
      authorizeOptions?.response_mode,
      thumbprint
    );

    const url = this._authorizeUrl(params);

    return {
      nonce,
      code_verifier,
      scope: params.scope,
      audience: params.audience || DEFAULT_AUDIENCE,
      redirect_uri: params.redirect_uri,
      state,
      url
    };
  }

  /**
   * ```js
   * try {
   *  await auth0.loginWithPopup(options);
   * } catch(e) {
   *  if (e instanceof PopupCancelledError) {
   *    // Popup was closed before login completed
   *  }
   * }
   * ```
   *
   * Opens a popup with the `/authorize` URL using the parameters
   * provided as arguments. Random and secure `state` and `nonce`
   * parameters will be auto-generated. If the response is successful,
   * results will be valid according to their expiration times.
   *
   * IMPORTANT: This method has to be called from an event handler
   * that was started by the user like a button click, for example,
   * otherwise the popup will be blocked in most browsers.
   *
   * @param options
   * @param config
   */
  public async loginWithPopup(
    options?: PopupLoginOptions,
    config?: PopupConfigOptions
  ) {
    options = options || {};
    config = config || {};

    if (!config.popup) {
      config.popup = openPopup('');

      if (!config.popup) {
        throw new PopupOpenError();
      }
    }

    const authorizationParams = this._applySessionTransferToken(options.authorizationParams || {});

    const params = await this._prepareAuthorizeUrl(
      authorizationParams,
      { response_mode: 'web_message' },
      window.location.origin
    );

    config.popup.location.href = params.url;

    const codeResult = await runPopup(
      {
        ...config,
        timeoutInSeconds:
          config.timeoutInSeconds ||
          this.options.authorizeTimeoutInSeconds ||
          DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS
      },
      new URL(params.url).origin
    );

    if (params.state !== codeResult.state) {
      throw new GenericError('state_mismatch', 'Invalid state');
    }

    const organization =
      options.authorizationParams?.organization ||
      this.options.authorizationParams.organization;

    await this._requestToken(
      {
        audience: params.audience,
        scope: params.scope,
        code_verifier: params.code_verifier,
        grant_type: 'authorization_code',
        code: codeResult.code as string,
        redirect_uri: params.redirect_uri
      },
      {
        nonceIn: params.nonce,
        organization
      }
    );
  }

  /**
   * ```js
   * const user = await auth0.getUser();
   * ```
   *
   * Returns the user information if available (decoded
   * from the `id_token`).
   *
   * @typeparam TUser The type to return, has to extend {@link User}.
   */
  public async getUser<TUser extends User>(): Promise<TUser | undefined> {
    const cache = await this._getIdTokenFromCache();

    return cache?.decodedToken?.user as TUser;
  }

  /**
   * ```js
   * const claims = await auth0.getIdTokenClaims();
   * ```
   *
   * Returns all claims from the id_token if available.
   */
  public async getIdTokenClaims(): Promise<IdToken | undefined> {
    const cache = await this._getIdTokenFromCache();

    return cache?.decodedToken?.claims;
  }

  /**
   * ```js
   * await auth0.loginWithRedirect(options);
   * ```
   *
   * Performs a redirect to `/authorize` using the parameters
   * provided as arguments. Random and secure `state` and `nonce`
   * parameters will be auto-generated.
   *
   * @param options
   */
  public async loginWithRedirect<TAppState = any>(
    options: RedirectLoginOptions<TAppState> = {}
  ) {
    const { openUrl, fragment, appState, ...urlOptions } =
      patchOpenUrlWithOnRedirect(options);

    const organization =
      urlOptions.authorizationParams?.organization ||
      this.options.authorizationParams.organization;

    const authorizationParams = this._applySessionTransferToken(urlOptions.authorizationParams || {});

    const { url, ...transaction } = await this._prepareAuthorizeUrl(
      authorizationParams
    );

    this.transactionManager.create<LoginTransaction>({
      ...transaction,
      appState,
      response_type: ResponseType.Code,
      ...(organization && { organization })
    });

    const urlWithFragment = fragment ? `${url}#${fragment}` : url;

    if (openUrl) {
      await openUrl(urlWithFragment);
    } else {
      window.location.assign(urlWithFragment);
    }
  }

  /**
   * After the browser redirects back to the callback page,
   * call `handleRedirectCallback` to handle success and error
   * responses from Auth0. If the response is successful, results
   * will be valid according to their expiration times.
   */
  public async handleRedirectCallback<TAppState = any>(
    url: string = window.location.href
  ): Promise<
    RedirectLoginResult<TAppState> | ConnectAccountRedirectResult<TAppState>
  > {
    const queryStringFragments = url.split('?').slice(1);

    if (queryStringFragments.length === 0) {
      throw new Error('There are no query params available for parsing.');
    }

    const transaction = this.transactionManager.get<
      LoginTransaction | ConnectAccountTransaction
    >();

    if (!transaction) {
      throw new GenericError('missing_transaction', 'Invalid state');
    }

    this.transactionManager.remove();

    const authenticationResult = parseAuthenticationResult(
      queryStringFragments.join('')
    );

    if (transaction.response_type === ResponseType.ConnectCode) {
      return this._handleConnectAccountRedirectCallback<TAppState>(
        authenticationResult,
        transaction
      );
    }
    return this._handleLoginRedirectCallback<TAppState>(
      authenticationResult,
      transaction
    );
  }

  /**
   * Handles the redirect callback from the login flow.
   *
   * @template AppState - The application state persisted from the /authorize redirect.
   * @param {string} authenticationResult - The parsed authentication result from the URL.
   * @param {string} transaction - The login transaction.
   *
   * @returns {RedirectLoginResult} Resolves with the persisted app state.
   * @throws {GenericError | Error} If the transaction is missing, invalid, or the code exchange fails.
   */
  private async _handleLoginRedirectCallback<TAppState>(
    authenticationResult: AuthenticationResult,
    transaction: LoginTransaction
  ): Promise<RedirectLoginResult<TAppState>> {
    const { code, state, error, error_description } = authenticationResult;

    if (error) {
      throw new AuthenticationError(
        error,
        error_description || error,
        state,
        transaction.appState
      );
    }

    // Transaction should have a `code_verifier` to do PKCE for CSRF protection
    if (
      !transaction.code_verifier ||
      (transaction.state && transaction.state !== state)
    ) {
      throw new GenericError('state_mismatch', 'Invalid state');
    }

    const organization = transaction.organization;
    const nonceIn = transaction.nonce;
    const redirect_uri = transaction.redirect_uri;

    await this._requestToken(
      {
        audience: transaction.audience,
        scope: transaction.scope,
        code_verifier: transaction.code_verifier,
        grant_type: 'authorization_code',
        code: code as string,
        ...(redirect_uri ? { redirect_uri } : {})
      },
      { nonceIn, organization }
    );

    return {
      appState: transaction.appState,
      response_type: ResponseType.Code
    };
  }

  /**
   * Handles the redirect callback from the connect account flow.
   * This works the same as the redirect from the login flow expect it verifies the `connect_code`
   * with the My Account API rather than the `code` with the Authorization Server.
   *
   * @template AppState - The application state persisted from the connect redirect.
   * @param {string} connectResult - The parsed connect accounts result from the URL.
   * @param {string} transaction - The login transaction.
   * @returns {Promise<ConnectAccountRedirectResult>} The result of the My Account API, including any persisted app state.
   * @throws {GenericError | MyAccountApiError} If the transaction is missing, invalid, or an error is returned from the My Account API.
   */
  private async _handleConnectAccountRedirectCallback<TAppState>(
    connectResult: AuthenticationResult,
    transaction: ConnectAccountTransaction
  ): Promise<ConnectAccountRedirectResult<TAppState>> {
    const { connect_code, state, error, error_description } = connectResult;

    if (error) {
      throw new ConnectError(
        error,
        error_description || error,
        transaction.connection,
        state,
        transaction.appState
      );
    }

    if (!connect_code) {
      throw new GenericError('missing_connect_code', 'Missing connect code');
    }

    if (
      !transaction.code_verifier ||
      !transaction.state ||
      !transaction.auth_session ||
      !transaction.redirect_uri ||
      transaction.state !== state
    ) {
      throw new GenericError('state_mismatch', 'Invalid state');
    }

    const data = await this.myAccountApi.completeAccount({
      auth_session: transaction.auth_session,
      connect_code,
      redirect_uri: transaction.redirect_uri,
      code_verifier: transaction.code_verifier
    });

    return {
      ...data,
      appState: transaction.appState,
      response_type: ResponseType.ConnectCode,
    };
  }

  /**
   * ```js
   * await auth0.checkSession();
   * ```
   *
   * Check if the user is logged in using `getTokenSilently`. The difference
   * with `getTokenSilently` is that this doesn't return a token, but it will
   * pre-fill the token cache.
   *
   * This method also heeds the `auth0.{clientId}.is.authenticated` cookie, as an optimization
   *  to prevent calling Auth0 unnecessarily. If the cookie is not present because
   * there was no previous login (or it has expired) then tokens will not be refreshed.
   *
   * It should be used for silently logging in the user when you instantiate the
   * `Auth0Client` constructor. You should not need this if you are using the
   * `createAuth0Client` factory.
   *
   * **Note:** the cookie **may not** be present if running an app using a private tab, as some
   * browsers clear JS cookie data and local storage when the tab or page is closed, or on page reload. This effectively
   * means that `checkSession` could silently return without authenticating the user on page refresh when
   * using a private tab, despite having previously logged in. As a workaround, use `getTokenSilently` instead
   * and handle the possible `login_required` error [as shown in the readme](https://github.com/auth0/auth0-spa-js#creating-the-client).
   *
   * @param options
   */
  public async checkSession(options?: GetTokenSilentlyOptions) {
    if (!this.cookieStorage.get(this.isAuthenticatedCookieName)) {
      if (!this.cookieStorage.get(OLD_IS_AUTHENTICATED_COOKIE_NAME)) {
        return;
      } else {
        // Migrate the existing cookie to the new name scoped by client ID
        this.cookieStorage.save(this.isAuthenticatedCookieName, true, {
          daysUntilExpire: this.sessionCheckExpiryDays,
          cookieDomain: this.options.cookieDomain
        });

        this.cookieStorage.remove(OLD_IS_AUTHENTICATED_COOKIE_NAME);
      }
    }

    try {
      await this.getTokenSilently(options);
    } catch (_) { }
  }

  /**
   * Fetches a new access token and returns the response from the /oauth/token endpoint, omitting the refresh token.
   *
   * @param options
   */
  public async getTokenSilently(
    options: GetTokenSilentlyOptions & { detailedResponse: true }
  ): Promise<GetTokenSilentlyVerboseResponse>;

  /**
   * Fetches a new access token and returns it.
   *
   * @param options
   */
  public async getTokenSilently(
    options?: GetTokenSilentlyOptions
  ): Promise<string>;

  /**
   * Fetches a new access token, and either returns just the access token (the default) or the response from the /oauth/token endpoint, depending on the `detailedResponse` option.
   *
   * ```js
   * const token = await auth0.getTokenSilently(options);
   * ```
   *
   * If there's a valid token stored and it has more than 60 seconds
   * remaining before expiration, return the token. Otherwise, attempt
   * to obtain a new token.
   *
   * A new token will be obtained either by opening an iframe or a
   * refresh token (if `useRefreshTokens` is `true`).

   * If iframes are used, opens an iframe with the `/authorize` URL
   * using the parameters provided as arguments. Random and secure `state`
   * and `nonce` parameters will be auto-generated. If the response is successful,
   * results will be validated according to their expiration times.
   *
   * If refresh tokens are used, the token endpoint is called directly with the
   * 'refresh_token' grant. If no refresh token is available to make this call,
   * the SDK will only fall back to using an iframe to the '/authorize' URL if
   * the `useRefreshTokensFallback` setting has been set to `true`. By default this
   * setting is `false`.
   *
   * This method may use a web worker to perform the token call if the in-memory
   * cache is used.
   *
   * If an `audience` value is given to this function, the SDK always falls
   * back to using an iframe to make the token exchange.
   *
   * Note that in all cases, falling back to an iframe requires access to
   * the `auth0` cookie.
   *
   * @param options
   */
  public async getTokenSilently(
    options: GetTokenSilentlyOptions = {}
  ): Promise<undefined | string | GetTokenSilentlyVerboseResponse> {
    const localOptions: GetTokenSilentlyOptions & {
      authorizationParams: AuthorizationParams & { scope: string };
    } = {
      cacheMode: 'on',
      ...options,
      authorizationParams: {
        ...this.options.authorizationParams,
        ...options.authorizationParams,
        scope: scopesToRequest(
          this.scope,
          options.authorizationParams?.scope,
          options.authorizationParams?.audience || this.options.authorizationParams.audience,
        )
      }
    };

    const result = await singlePromise(
      () => this._getTokenSilently(localOptions),
      `${this.options.clientId}::${localOptions.authorizationParams.audience}::${localOptions.authorizationParams.scope}`
    );

    return options.detailedResponse ? result : result?.access_token;
  }

  private async _getTokenSilently(
    options: GetTokenSilentlyOptions & {
      authorizationParams: AuthorizationParams & { scope: string };
    }
  ): Promise<undefined | GetTokenSilentlyVerboseResponse> {
    const { cacheMode, ...getTokenOptions } = options;

    // Check the cache before acquiring the lock to avoid the latency of
    // `lock.acquireLock` when the cache is populated.
    if (cacheMode !== 'off') {
      const entry = await this._getEntryFromCache({
        scope: getTokenOptions.authorizationParams.scope,
        audience: getTokenOptions.authorizationParams.audience || DEFAULT_AUDIENCE,
        clientId: this.options.clientId,
        cacheMode,
      });

      if (entry) {
        return entry;
      }
    }

    if (cacheMode === 'cache-only') {
      return;
    }

    // Generate lock key based on client ID and audience for better isolation
    const lockKey = buildGetTokenSilentlyLockKey(
      this.options.clientId,
      getTokenOptions.authorizationParams.audience || 'default'
    );

    try {
      return await this.lockManager.runWithLock(lockKey, 5000, async () => {
        // Check the cache a second time, because it may have been populated
        // by a previous call while this call was waiting to acquire the lock.
        if (cacheMode !== 'off') {
          const entry = await this._getEntryFromCache({
            scope: getTokenOptions.authorizationParams.scope,
            audience:
              getTokenOptions.authorizationParams.audience || DEFAULT_AUDIENCE,
            clientId: this.options.clientId
          });

          if (entry) {
            return entry;
          }
        }

        const authResult = this.options.useRefreshTokens
          ? await this._getTokenUsingRefreshToken(getTokenOptions)
          : await this._getTokenFromIFrame(getTokenOptions);

        const { id_token, token_type, access_token, oauthTokenScope, expires_in } =
          authResult;

        return {
          id_token,
          token_type,
          access_token,
          ...(oauthTokenScope ? { scope: oauthTokenScope } : null),
          expires_in
        };
      });
    } catch (error) {
      // Lock is already released - safe to open popup
      if (this._isInteractiveError(error) && this.options.interactiveErrorHandler === 'popup') {
        return await this._handleInteractiveErrorWithPopup(getTokenOptions);
      }
      throw error;
    }
  }

  /**
   * Checks if an error should be handled by the interactive error handler.
   * Matches:
   * - MfaRequiredError (refresh token path, error='mfa_required')
   * - GenericError from iframe path (error='login_required',
   *   error_description='Multifactor authentication required')
   * Extensible for future interactive error types.
   */
  private _isInteractiveError(
    error: unknown
  ): error is MfaRequiredError | GenericError {
    return error instanceof MfaRequiredError || (error instanceof GenericError && this._isIframeMfaError(error));
  }

  /**
   * Checks if a login_required error from the iframe flow is actually
   * an MFA step-up requirement. The /authorize endpoint returns
   * error='login_required' with error_description='Multifactor authentication required'
   * when MFA is needed but prompt=none prevents interaction.
   */
  private _isIframeMfaError(error: GenericError): boolean {
    return (
      error.error === 'login_required' &&
      error.error_description === MFA_STEP_UP_ERROR_DESCRIPTION
    );
  }

  /**
   * Handles MFA errors by opening a popup to complete authentication,
   * then reads the resulting token from cache.
   */
  private async _handleInteractiveErrorWithPopup(
    options: GetTokenSilentlyOptions & {
      authorizationParams: AuthorizationParams & { scope: string };
    }
  ): Promise<GetTokenSilentlyVerboseResponse> {
    try {
      await this.loginWithPopup({
        authorizationParams: options.authorizationParams
      });

      const entry = await this._getEntryFromCache({
        scope: options.authorizationParams.scope,
        audience:
          options.authorizationParams.audience || DEFAULT_AUDIENCE,
        clientId: this.options.clientId
      });

      if (!entry) {
        throw new GenericError(
          'interactive_handler_cache_miss',
          'Token not found in cache after interactive authentication'
        );
      }

      return entry;
    } catch (error) {
      // Expected errors (all GenericError subclasses):
      // - PopupCancelledError: user closed the popup before completing login
      // - PopupTimeoutError: popup did not complete within the allowed time
      // - PopupOpenError: popup could not be opened (e.g. blocked by browser)
      // - GenericError: authentication or cache miss errors
      throw error;
    }
  }

  /**
   * ```js
   * const token = await auth0.getTokenWithPopup(options);
   * ```
   * Opens a popup with the `/authorize` URL using the parameters
   * provided as arguments. Random and secure `state` and `nonce`
   * parameters will be auto-generated. If the response is successful,
   * results will be valid according to their expiration times.
   *
   * @param options
   * @param config
   */
  public async getTokenWithPopup(
    options: GetTokenWithPopupOptions = {},
    config: PopupConfigOptions = {}
  ) {
    const localOptions = {
      ...options,
      authorizationParams: {
        ...this.options.authorizationParams,
        ...options.authorizationParams,
        scope: scopesToRequest(
          this.scope,
          options.authorizationParams?.scope,
          options.authorizationParams?.audience || this.options.authorizationParams.audience
        )
      }
    };

    config = {
      ...DEFAULT_POPUP_CONFIG_OPTIONS,
      ...config
    };

    await this.loginWithPopup(localOptions, config);

    const cache = await this.cacheManager.get(
      new CacheKey({
        scope: localOptions.authorizationParams.scope,
        audience: localOptions.authorizationParams.audience || DEFAULT_AUDIENCE,
        clientId: this.options.clientId
      }),
      undefined,
      this.options.useMrrt
    );

    return cache!.access_token;
  }

  /**
   * ```js
   * const isAuthenticated = await auth0.isAuthenticated();
   * ```
   *
   * Returns `true` if there's valid information stored,
   * otherwise returns `false`.
   *
   */
  public async isAuthenticated() {
    const user = await this.getUser();
    return !!user;
  }

  /**
   * ```js
   * await auth0.buildLogoutUrl(options);
   * ```
   *
   * Builds a URL to the logout endpoint using the parameters provided as arguments.
   * @param options
   */
  private _buildLogoutUrl(options: LogoutUrlOptions): string {
    if (options.clientId !== null) {
      options.clientId = options.clientId || this.options.clientId;
    } else {
      delete options.clientId;
    }

    const { federated, ...logoutOptions } = options.logoutParams || {};
    const federatedQuery = federated ? `&federated` : '';
    const url = this._url(
      `/v2/logout?${createQueryParams({
        clientId: options.clientId,
        ...logoutOptions
      })}`
    );

    return url + federatedQuery;
  }

  /**
   * ```js
   * await auth0.revokeRefreshToken();
   * ```
   *
   * Revokes the refresh token using the `/oauth/revoke` endpoint.
   * This invalidates the refresh token so it can no longer be used to obtain new access tokens.
   *
   * The method works with both memory and localStorage cache modes:
   * - For memory storage with worker: The refresh token never leaves the worker thread,
   *   maintaining security isolation
   * - For localStorage: The token is retrieved from cache and revoked
   *
   * If `useRefreshTokens` is disabled, this method does nothing.
   *
   * **Important:** This method revokes the refresh token for a single audience. If your
   * application requests tokens for multiple audiences, each audience may have its own
   * refresh token. To fully revoke all refresh tokens, call this method once per audience.
   * If you want to terminate the user's session entirely, use `logout()` instead.
   *
   * When using Multi-Resource Refresh Tokens (MRRT), a single refresh token may cover
   * multiple audiences. In that case, revoking it will affect all cache entries that
   * share the same token.
   *
   * @param options - Optional parameters to identify which refresh token to revoke.
   *   Defaults to the audience configured in `authorizationParams`.
   *
   * @example
   * // Revoke the default refresh token
   * await auth0.revokeRefreshToken();
   *
   * @example
   * // Revoke refresh tokens for each audience individually
   * await auth0.revokeRefreshToken({ audience: 'https://api.example.com' });
   * await auth0.revokeRefreshToken({ audience: 'https://api2.example.com' });
   */
  public async revokeRefreshToken(options: RevokeRefreshTokenOptions = {}): Promise<void> {
    if (!this.options.useRefreshTokens) {
      return;
    }

    const audience =
      options.audience || this.options.authorizationParams.audience;

    const resolvedAudience = audience || DEFAULT_AUDIENCE;

    // For the non-worker path the main-thread cache holds the refresh tokens.
    // For the worker path the worker holds its own RT store — the cache returns
    // [] and revokeToken sends a single message; the worker loops internally.
    const refreshTokens = await this.cacheManager.getRefreshTokensByAudience(
      resolvedAudience,
      this.options.clientId
    );

    await revokeToken(
      {
        baseUrl: this.domainUrl,
        timeout: this.httpTimeoutMs,
        auth0Client: this.options.auth0Client,
        useFormData: this.options.useFormData,
        client_id: this.options.clientId,
        refreshTokens,
        audience: resolvedAudience,
        onRefreshTokenRevoked: refreshToken =>
          this.cacheManager.stripRefreshToken(refreshToken)
      },
      this.worker
    );
  }

  /**
   * ```js
   * await auth0.logout(options);
   * ```
   *
   * Clears the application session and performs a redirect to `/v2/logout`, using
   * the parameters provided as arguments, to clear the Auth0 session.
   *
   * If the `federated` option is specified it also clears the Identity Provider session.
   * [Read more about how Logout works at Auth0](https://auth0.com/docs/logout).
   *
   * @param options
   */
  public async logout(options: LogoutOptions = {}): Promise<void> {
    const { openUrl, ...logoutOptions } = patchOpenUrlWithOnRedirect(options);

    if (options.clientId === null) {
      await this.cacheManager.clear();
    } else {
      await this.cacheManager.clear(options.clientId || this.options.clientId);
    }

    this.cookieStorage.remove(this.orgHintCookieName, {
      cookieDomain: this.options.cookieDomain
    });
    this.cookieStorage.remove(this.isAuthenticatedCookieName, {
      cookieDomain: this.options.cookieDomain
    });
    this.userCache.remove(CACHE_KEY_ID_TOKEN_SUFFIX);

    await this.dpop?.clear();

    const url = this._buildLogoutUrl(logoutOptions);

    if (openUrl) {
      await openUrl(url);
    } else if (openUrl !== false) {
      window.location.assign(url);
    }
  }

  private async _getTokenFromIFrame(
    options: GetTokenSilentlyOptions & {
      authorizationParams: AuthorizationParams & { scope: string };
    }
  ): Promise<GetTokenSilentlyResult> {
    const iframeLockKey = buildIframeLockKey(this.options.clientId);

    // Acquire global iframe lock to serialize iframe authorization flows.
    // This is necessary because the SDK does not support multiple simultaneous transactions.
    // Since https://github.com/auth0/auth0-spa-js/pull/1408, when calling
    // `getTokenSilently()`, the global locking will lock per `audience` instead of locking
    // only per `client_id`.
    // This means that calls for different audiences would happen in parallel, which does
    // not work when using silent authentication (prompt=none) from within the SDK, as that
    // relies on the same transaction context as a top-level `loginWithRedirect`.
    // To resolve that, we add a second-level locking that locks only the iframe calls in
    // the same way as was done before https://github.com/auth0/auth0-spa-js/pull/1408.
    try {
      return await this.lockManager.runWithLock(
        iframeLockKey,
        5000,
        async () => {
          const params: AuthorizationParams & { scope: string } = {
            ...options.authorizationParams,
            prompt: 'none'
          };

          const orgHint = this.cookieStorage.get<string>(
            this.orgHintCookieName
          );

          if (orgHint && !params.organization) {
            params.organization = orgHint;
          }

          const {
            url,
            state: stateIn,
            nonce: nonceIn,
            code_verifier,
            redirect_uri,
            scope,
            audience
          } = await this._prepareAuthorizeUrl(
            params,
            { response_mode: 'web_message' },
            window.location.origin
          );

          // When a browser is running in a Cross-Origin Isolated context, using iframes is not possible.
          // It doesn't throw an error but times out instead, so we should exit early and inform the user about the reason.
          // https://developer.mozilla.org/en-US/docs/Web/API/crossOriginIsolated
          if ((window as any).crossOriginIsolated) {
            throw new GenericError(
              'login_required',
              'The application is running in a Cross-Origin Isolated context, silently retrieving a token without refresh token is not possible.'
            );
          }

          const authorizeTimeout =
            options.timeoutInSeconds || this.options.authorizeTimeoutInSeconds;

          // Extract origin from domainUrl, fallback to domainUrl if URL parsing fails
          let eventOrigin: string;
          try {
            eventOrigin = new URL(this.domainUrl).origin;
          } catch {
            eventOrigin = this.domainUrl;
          }

          const codeResult = await runIframe(
            url,
            eventOrigin,
            authorizeTimeout
          );

          if (stateIn !== codeResult.state) {
            throw new GenericError('state_mismatch', 'Invalid state');
          }

          const tokenResult = await this._requestToken(
            {
              ...options.authorizationParams,
              code_verifier,
              code: codeResult.code as string,
              grant_type: 'authorization_code',
              redirect_uri,
              timeout: options.authorizationParams.timeout || this.httpTimeoutMs
            },
            {
              nonceIn,
              organization: params.organization
            }
          );

          return {
            ...tokenResult,
            scope: scope,
            oauthTokenScope: tokenResult.scope,
            audience: audience
          };
        }
      );
    } catch (e) {
      if (e.error === 'login_required') {
        // When the login_required error is actually an MFA step-up requirement
        // and the interactive error handler is configured, skip logout so the
        // session is preserved for the popup flow.
        const shouldSkipLogoutForMfaStepUp =
          e instanceof GenericError &&
          this._isIframeMfaError(e) &&
          this.options.interactiveErrorHandler === 'popup';

        if (!shouldSkipLogoutForMfaStepUp) {
          this.logout({
            openUrl: false
          });
        }
      }
      throw e;
    }
  }

  private async _getTokenUsingRefreshToken(
    options: GetTokenSilentlyOptions & {
      authorizationParams: AuthorizationParams & { scope: string };
    }
  ): Promise<GetTokenSilentlyResult> {
    const cache = await this.cacheManager.get(
      new CacheKey({
        scope: options.authorizationParams.scope,
        audience: options.authorizationParams.audience || DEFAULT_AUDIENCE,
        clientId: this.options.clientId
      }),
      undefined,
      this.options.useMrrt
    );

    // If you don't have a refresh token in memory
    // and you don't have a refresh token in web worker memory
    // and useRefreshTokensFallback was explicitly enabled
    // fallback to an iframe
    if ((!cache || !cache.refresh_token) && !this.worker) {
      if (this.options.useRefreshTokensFallback) {
        return await this._getTokenFromIFrame(options);
      }

      throw new MissingRefreshTokenError(
        options.authorizationParams.audience || DEFAULT_AUDIENCE,
        options.authorizationParams.scope
      );
    }

    const redirect_uri =
      options.authorizationParams.redirect_uri ||
      this.options.authorizationParams.redirect_uri ||
      window.location.origin;

    const timeout =
      typeof options.timeoutInSeconds === 'number'
        ? options.timeoutInSeconds * 1000
        : null;

    const scopesToRequest = getScopeToRequest(
      this.options.useMrrt,
      options.authorizationParams,
      cache?.audience,
      cache?.scope,
    );

    try {
      const tokenResult = await this._requestToken({
        ...options.authorizationParams,
        grant_type: 'refresh_token',
        refresh_token: cache && cache.refresh_token,
        redirect_uri,
        ...(timeout && { timeout })
      },
        {
          scopesToRequest,
        }
      );

      // If is refreshed with MRRT, we update all entries that have the old
      // refresh_token with the new one if the server responded with one
      if (tokenResult.refresh_token && cache?.refresh_token) {
        await this.cacheManager.updateEntry(
          cache.refresh_token,
          tokenResult.refresh_token
        );
      }

      // Some scopes requested to the server might not be inside the refresh policies
      // In order to return a token with all requested scopes when using MRRT we should
      // check if all scopes are returned. If not, we will try to use an iframe to request
      // a token.
      if (this.options.useMrrt) {
        const isRefreshMrrt = isRefreshWithMrrt(
          cache?.audience,
          cache?.scope,
          options.authorizationParams.audience,
          options.authorizationParams.scope,
        );

        if (isRefreshMrrt) {
          const tokenHasAllScopes = allScopesAreIncluded(
            scopesToRequest,
            tokenResult.scope,
          );

          if (!tokenHasAllScopes) {
            if (this.options.useRefreshTokensFallback) {
              return await this._getTokenFromIFrame(options);
            }

            // Before throwing MissingScopesError, we have to remove the previously created entry
            // to avoid storing wrong data
            await this.cacheManager.remove(
              this.options.clientId,
              options.authorizationParams.audience,
              options.authorizationParams.scope,
            );

            const missingScopes = getMissingScopes(
              scopesToRequest,
              tokenResult.scope,
            );

            throw new MissingScopesError(
              options.authorizationParams.audience || 'default',
              missingScopes,
            );
          }
        }
      }

      return {
        ...tokenResult,
        scope: options.authorizationParams.scope,
        oauthTokenScope: tokenResult.scope,
        audience: options.authorizationParams.audience || DEFAULT_AUDIENCE
      };
    } catch (e) {
      if (e.message) {
        // Blocked users should be logged out immediately. No point attempting
        // iframe fallback as the authorization server will reject the request.
        if (e.message.includes(USER_BLOCKED_ERROR_MESSAGE)) {
          await this.logout({ openUrl: false });
          throw e;
        }

        // For missing or invalid refresh tokens, attempt iframe fallback if configured.
        // The iframe may succeed if the user still has a valid session.
        if (
          (e.message.includes(MISSING_REFRESH_TOKEN_ERROR_MESSAGE) ||
            e.message.includes(INVALID_REFRESH_TOKEN_ERROR_MESSAGE)) &&
          this.options.useRefreshTokensFallback
        ) {
          return await this._getTokenFromIFrame(options);
        }
      }

      if (e instanceof MfaRequiredError) {
        this.mfa.setMFAAuthDetails(
          e.mfa_token,
          options.authorizationParams?.scope,
          options.authorizationParams?.audience,
          e.mfa_requirements
        );
      }

      throw e;
    }
  }

  private async _saveEntryInCache(
    entry: CacheEntry & { id_token: string; decodedToken: DecodedToken }
  ) {
    const { id_token, decodedToken, ...entryWithoutIdToken } = entry;

    this.userCache.set(CACHE_KEY_ID_TOKEN_SUFFIX, {
      id_token,
      decodedToken
    });

    await this.cacheManager.setIdToken(
      this.options.clientId,
      entry.id_token,
      entry.decodedToken
    );

    await this.cacheManager.set(entryWithoutIdToken);
  }

  private async _getIdTokenFromCache() {
    const audience = this.options.authorizationParams.audience || DEFAULT_AUDIENCE;
    const scope = this.scope[audience];

    const cache = await this.cacheManager.getIdToken(
      new CacheKey({
        clientId: this.options.clientId,
        audience,
        scope,
      })
    );

    const currentCache = this.userCache.get<IdTokenEntry>(
      CACHE_KEY_ID_TOKEN_SUFFIX
    ) as IdTokenEntry;

    // If the id_token in the cache matches the value we previously cached in memory return the in-memory
    // value so that object comparison will work
    if (cache && cache.id_token === currentCache?.id_token) {
      return currentCache;
    }

    this.userCache.set(CACHE_KEY_ID_TOKEN_SUFFIX, cache);
    return cache;
  }

  private async _getEntryFromCache({
    scope,
    audience,
    clientId,
    cacheMode,
  }: {
    scope: string;
    audience: string;
    clientId: string;
    cacheMode?: string;
  }): Promise<undefined | GetTokenSilentlyVerboseResponse> {
    const entry = await this.cacheManager.get(
      new CacheKey({
        scope,
        audience,
        clientId
      }),
      60, // get a new token if within 60 seconds of expiring
      this.options.useMrrt,
      cacheMode,
    );

    if (entry && entry.access_token) {
      const { token_type, access_token, oauthTokenScope, expires_in } =
        entry as CacheEntry;
      const cache = await this._getIdTokenFromCache();
      return (
        cache && {
          id_token: cache.id_token,
          token_type: token_type ? token_type : 'Bearer',
          access_token,
          ...(oauthTokenScope ? { scope: oauthTokenScope } : null),
          expires_in
        }
      );
    }
  }

  private async _requestToken(
    options:
      | PKCERequestTokenOptions
      | RefreshTokenRequestTokenOptions
      | TokenExchangeRequestOptions,
    additionalParameters?: RequestTokenAdditionalParameters
  ) {
    const { nonceIn, organization, scopesToRequest } = additionalParameters || {};
    const authResult = await oauthToken(
      {
        baseUrl: this.domainUrl,
        client_id: this.options.clientId,
        auth0Client: this.options.auth0Client,
        useFormData: this.options.useFormData,
        timeout: this.httpTimeoutMs,
        useMrrt: this.options.useMrrt,
        dpop: this.dpop,
        ...options,
        scope: scopesToRequest || options.scope,
      },
      this.worker
    );

    const decodedToken = await this._verifyIdToken(
      authResult.id_token,
      nonceIn,
      organization
    );

    // When logging in with authorization_code, check if a different user is authenticating
    // If so, clear the cache to prevent tokens from multiple users coexisting
    if (options.grant_type === 'authorization_code') {
      const existingIdToken = await this._getIdTokenFromCache();

      if (existingIdToken?.decodedToken?.claims?.sub &&
        existingIdToken.decodedToken.claims.sub !== decodedToken.claims.sub) {
        // Different user detected - clear cached tokens
        await this.cacheManager.clear(this.options.clientId);
        this.userCache.remove(CACHE_KEY_ID_TOKEN_SUFFIX);
      }
    }

    await this._saveEntryInCache({
      ...authResult,
      decodedToken,
      scope: options.scope,
      audience: options.audience || DEFAULT_AUDIENCE,
      ...(authResult.scope ? { oauthTokenScope: authResult.scope } : null),
      client_id: this.options.clientId
    });

    this.cookieStorage.save(this.isAuthenticatedCookieName, true, {
      daysUntilExpire: this.sessionCheckExpiryDays,
      cookieDomain: this.options.cookieDomain
    });

    this._processOrgHint(organization || decodedToken.claims.org_id);

    return { ...authResult, decodedToken };
  }

  /*
  Custom Token Exchange
  * **Implementation Notes:**
  * - Ensure that the `subject_token` provided has been securely obtained and is valid according
  *   to your external identity provider's policies before invoking this function.
  * - The function leverages internal helper methods:
  *   - `validateTokenType` confirms that the `subject_token_type` is supported.
  *   - `getUniqueScopes` merges and de-duplicates scopes between the provided options and
  *     the instance's default scopes.
  *   - `_requestToken` performs the actual HTTP request to the token endpoint.
  */

  /**
   * ```js
   * await auth0.loginWithCustomTokenExchange(options);
   * ```
   *
   * Exchanges an external subject token for Auth0 tokens and logs the user in.
   * This method implements the Custom Token Exchange grant as specified in RFC 8693.
   *
   * The exchanged tokens are automatically cached, establishing an authenticated session.
   * After calling this method, you can use `getUser()`, `getIdTokenClaims()`, and
   * `getTokenSilently()` to access the user's information and tokens.
   *
   * @param {CustomTokenExchangeOptions} options - The options required to perform the token exchange.
   *
   * @returns {Promise<TokenEndpointResponse>} A promise that resolves to the token endpoint response,
   * which contains the issued Auth0 tokens (access_token, id_token, etc.).
   *
   * The request includes the following parameters:
   * - `grant_type`: "urn:ietf:params:oauth:grant-type:token-exchange"
   * - `subject_token`: The external token to exchange
   * - `subject_token_type`: The type identifier of the external token
   * - `scope`: Merged scopes from the request and SDK defaults
   * - `audience`: Target audience (defaults to SDK configuration)
   * - `organization`: Optional organization ID/name for org-scoped authentication
   *
   * **Example Usage:**
   *
   * ```js
   * const options = {
   *   subject_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6Ikp...',
   *   subject_token_type: 'urn:acme:legacy-system-token',
   *   scope: 'openid profile email',
   *   audience: 'https://api.example.com',
   *   organization: 'org_12345'
   * };
   *
   * try {
   *   const tokenResponse = await auth0.loginWithCustomTokenExchange(options);
   *   console.log('Access token:', tokenResponse.access_token);
   *
   *   // User is now logged in - access user info
   *   const user = await auth0.getUser();
   *   console.log('Logged in user:', user);
   * } catch (error) {
   *   console.error('Token exchange failed:', error);
   * }
   * ```
   */
  async loginWithCustomTokenExchange(
    options: CustomTokenExchangeOptions
  ): Promise<TokenEndpointResponse> {
    return this._requestToken({
      ...options,
      grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
      subject_token: options.subject_token,
      subject_token_type: options.subject_token_type,
      scope: scopesToRequest(
        this.scope,
        options.scope,
        options.audience || this.options.authorizationParams.audience
      ),
      audience: options.audience || this.options.authorizationParams.audience,
      organization: options.organization || this.options.authorizationParams.organization
    });
  }

  /**
   * @deprecated Use `loginWithCustomTokenExchange()` instead. This method will be removed in the next major version.
   *
   * Exchanges an external subject token for Auth0 tokens.
   *
   * @param {CustomTokenExchangeOptions} options - The options required to perform the token exchange.
   * @returns {Promise<TokenEndpointResponse>} A promise that resolves to the token endpoint response.
   *
   * **Example:**
   * ```js
   * // Instead of:
   * const tokens = await auth0.exchangeToken(options);
   *
   * // Use:
   * const tokens = await auth0.loginWithCustomTokenExchange(options);
   * ```
   */
  async exchangeToken(
    options: CustomTokenExchangeOptions
  ): Promise<TokenEndpointResponse> {
    return this.loginWithCustomTokenExchange(options);
  }

  protected _assertDpop(dpop: Dpop | undefined): asserts dpop is Dpop {
    if (!dpop) {
      throw new Error('`useDpop` option must be enabled before using DPoP.');
    }
  }

  /**
   * Returns the current DPoP nonce used for making requests to Auth0.
   *
   * It can return `undefined` because when starting fresh it will not
   * be populated until after the first response from the server.
   *
   * It requires enabling the {@link Auth0ClientOptions.useDpop} option.
   *
   * @param nonce The nonce value.
   * @param id    The identifier of a nonce: if absent, it will get the nonce
   *              used for requests to Auth0. Otherwise, it will be used to
   *              select a specific non-Auth0 nonce.
   */
  public getDpopNonce(id?: string): Promise<string | undefined> {
    this._assertDpop(this.dpop);

    return this.dpop.getNonce(id);
  }

  /**
   * Sets the current DPoP nonce used for making requests to Auth0.
   *
   * It requires enabling the {@link Auth0ClientOptions.useDpop} option.
   *
   * @param nonce The nonce value.
   * @param id    The identifier of a nonce: if absent, it will set the nonce
   *              used for requests to Auth0. Otherwise, it will be used to
   *              select a specific non-Auth0 nonce.
   */
  public setDpopNonce(nonce: string, id?: string): Promise<void> {
    this._assertDpop(this.dpop);

    return this.dpop.setNonce(nonce, id);
  }

  /**
   * Returns a string to be used to demonstrate possession of the private
   * key used to cryptographically bind access tokens with DPoP.
   *
   * It requires enabling the {@link Auth0ClientOptions.useDpop} option.
   */
  public generateDpopProof(params: {
    url: string;
    method: string;
    nonce?: string;
    accessToken: string;
  }): Promise<string> {
    this._assertDpop(this.dpop);

    return this.dpop.generateProof(params);
  }

  /**
   * Returns a new `Fetcher` class that will contain a `fetchWithAuth()` method.
   * This is a drop-in replacement for the Fetch API's `fetch()` method, but will
   * handle certain authentication logic for you, like building the proper auth
   * headers or managing DPoP nonces and retries automatically.
   *
   * Check the `EXAMPLES.md` file for a deeper look into this method.
   */
  public createFetcher<TOutput extends CustomFetchMinimalOutput = Response>(
    config: FetcherConfig<TOutput> = {}
  ): Fetcher<TOutput> {
    return new Fetcher(config, {
      isDpopEnabled: () => !!this.options.useDpop,
      getAccessToken: authParams =>
        this.getTokenSilently({
          authorizationParams: {
            scope: authParams?.scope?.join(' '),
            audience: authParams?.audience
          },
          detailedResponse: true
        }),
      getDpopNonce: () => this.getDpopNonce(config.dpopNonceId),
      setDpopNonce: nonce => this.setDpopNonce(nonce, config.dpopNonceId),
      generateDpopProof: params => this.generateDpopProof(params)
    });
  }


  /**
   * Initiates a redirect to connect the user's account with a specified connection.
   * This method generates PKCE parameters, creates a transaction, and redirects to the /connect endpoint.
   * 
   * You must enable `Offline Access` from the Connection Permissions settings to be able to use the connection with Connected Accounts.
   *
   * @template TAppState - The application state to persist through the transaction.
   * @param {RedirectConnectAccountOptions<TAppState>} options - Options for the connect account redirect flow.
   * @param   {string} options.connection - The name of the connection to link (e.g. 'google-oauth2').
   * @param   {string[]} [options.scopes] - Array of scopes to request from the Identity Provider during the connect account flow.
   * @param   {AuthorizationParams} [options.authorization_params] - Additional authorization parameters for the request to the upstream IdP.
   * @param   {string} [options.redirectUri] - The URI to redirect back to after connecting the account.
   * @param   {TAppState} [options.appState] - Application state to persist through the transaction.
   * @param   {(url: string) => Promise<void>} [options.openUrl] - Custom function to open the URL.
   *
   * @returns {Promise<void>} Resolves when the redirect is initiated.
   * @throws {MyAccountApiError} If the connect request to the My Account API fails.
   */
  public async connectAccountWithRedirect<TAppState = any>(
    options: RedirectConnectAccountOptions<TAppState>
  ) {
    const {
      openUrl,
      appState,
      connection,
      scopes,
      authorization_params,
      redirectUri = this.options.authorizationParams.redirect_uri ||
      window.location.origin
    } = options;

    if (!connection) {
      throw new Error('connection is required');
    }

    const state = encode(createRandomString());
    const code_verifier = createRandomString();
    const code_challengeBuffer = await sha256(code_verifier);
    const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer);

    const { connect_uri, connect_params, auth_session } =
      await this.myAccountApi.connectAccount({
        connection,
        scopes,
        redirect_uri: redirectUri,
        state,
        code_challenge,
        code_challenge_method: 'S256',
        authorization_params
      });

    this.transactionManager.create<ConnectAccountTransaction>({
      state,
      code_verifier,
      auth_session,
      redirect_uri: redirectUri,
      appState,
      connection,
      response_type: ResponseType.ConnectCode
    });

    const url = new URL(connect_uri);
    url.searchParams.set('ticket', connect_params.ticket);
    if (openUrl) {
      await openUrl(url.toString());
    } else {
      window.location.assign(url);
    }
  }

  /**
   * @internal
   * Internal method used by MfaApiClient to exchange MFA tokens for access tokens.
   * This method should not be called directly by applications.
   */
  async _requestTokenForMfa(
    options: {
      grant_type: string;
      mfaToken: string;
      scope?: string;
      audience?: string;
      otp?: string;
      binding_code?: string;
      oob_code?: string;
      recovery_code?: string;
    },
    additionalParameters?: RequestTokenAdditionalParameters
  ): Promise<TokenEndpointResponse> {
    // Need to add better typing here
    const { mfaToken, ...restOptions } = options;
    return this._requestToken({ ...restOptions, mfa_token: mfaToken } as any, additionalParameters);
  }
}

interface BaseRequestTokenOptions {
  audience?: string;
  scope: string;
  timeout?: number;
  redirect_uri?: string;
}

interface PKCERequestTokenOptions extends BaseRequestTokenOptions {
  code: string;
  grant_type: 'authorization_code';
  code_verifier: string;
}

interface RefreshTokenRequestTokenOptions extends BaseRequestTokenOptions {
  grant_type: 'refresh_token';
  refresh_token?: string;
}

interface TokenExchangeRequestOptions extends BaseRequestTokenOptions {
  grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange';
  subject_token: string;
  subject_token_type: string;
  actor_token?: string;
  actor_token_type?: string;
  organization?: string;
}

interface RequestTokenAdditionalParameters {
  nonceIn?: string;
  organization?: string;
  scopesToRequest?: string;
}
