import { Observable, combineLatest, firstValueFrom, map } from "rxjs";
import { Opaque } from "type-fest";

import { LogoutReason, decodeJwtTokenToJson } from "@bitwarden/auth/common";

import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
import { LogService } from "../../platform/abstractions/log.service";
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
import { StorageLocation } from "../../platform/enums";
import { Utils } from "../../platform/misc/utils";
import { EncString, EncryptedString } from "../../platform/models/domain/enc-string";
import { StorageOptions } from "../../platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import {
  GlobalState,
  GlobalStateProvider,
  SingleUserStateProvider,
  UserKeyDefinition,
} from "../../platform/state";
import { UserId } from "../../types/guid";
import { VaultTimeout, VaultTimeoutStringType } from "../../types/vault-timeout.type";
import { TokenService as TokenServiceAbstraction } from "../abstractions/token.service";
import { SetTokensResult } from "../models/domain/set-tokens-result";

import { ACCOUNT_ACTIVE_ACCOUNT_ID } from "./account.service";
import {
  ACCESS_TOKEN_DISK,
  ACCESS_TOKEN_MEMORY,
  API_KEY_CLIENT_ID_DISK,
  API_KEY_CLIENT_ID_MEMORY,
  API_KEY_CLIENT_SECRET_DISK,
  API_KEY_CLIENT_SECRET_MEMORY,
  EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
  REFRESH_TOKEN_DISK,
  REFRESH_TOKEN_MEMORY,
  SECURITY_STAMP_MEMORY,
} from "./token.state";

export enum TokenStorageLocation {
  Disk = "disk",
  SecureStorage = "secureStorage",
  Memory = "memory",
}

/**
 * Type representing the structure of a standard Bitwarden decoded access token.
 * src: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1
 * Note: all claims are technically optional so we must verify their existence before using them.
 * Note 2: NumericDate is a number representing a date in seconds since the Unix epoch.
 */
export type DecodedAccessToken = {
  /** Issuer  - the issuer of the token, typically the URL of the authentication server */
  iss?: string;

  /** Not Before - a timestamp defining when the token starts being valid */
  nbf?: number;

  /** Issued At - a timestamp of when the token was issued */
  iat?: number;

  /** Expiration Time - a NumericDate timestamp of when the token will expire */
  exp?: number;

  /** Scope - the scope of the access request, such as the permissions the token grants */
  scope?: string[];

  /** Authentication Method Reference - the methods used in the authentication */
  amr?: string[];

  /** Client ID - the identifier for the client that requested the token */
  client_id?: string;

  /** Subject - the unique identifier for the user */
  sub?: string;

  /** Authentication Time - a timestamp of when the user authentication occurred */
  auth_time?: number;

  /** Identity Provider - the system or service that authenticated the user */
  idp?: string;

  /** Premium - a boolean flag indicating whether the account is premium */
  premium?: boolean;

  /** Email - the user's email address */
  email?: string;

  /** Email Verified - a boolean flag indicating whether the user's email address has been verified */
  email_verified?: boolean;

  /**
   * Security Stamp - a unique identifier which invalidates the access token if it changes in the db
   * (typically after critical account changes like a password change)
   */
  sstamp?: string;

  /** Name - the name of the user */
  name?: string;

  /** Organization Owners - a list of organization owner identifiers */
  orgowner?: string[];

  /** Device - the identifier of the device used */
  device?: string;

  /** JWT ID - a unique identifier for the JWT */
  jti?: string;
};

/**
 * A symmetric key for encrypting the access token before the token is stored on disk.
 * This key should be stored in secure storage.
 * */
export type AccessTokenKey = Opaque<SymmetricCryptoKey, "AccessTokenKey">;

export class TokenService implements TokenServiceAbstraction {
  private readonly accessTokenKeySecureStorageKey: string = "_accessTokenKey";

  private readonly refreshTokenSecureStorageKey: string = "_refreshToken";

  private emailTwoFactorTokenRecordGlobalState: GlobalState<Record<string, string>>;

  private activeUserIdGlobalState: GlobalState<UserId>;

  constructor(
    // Note: we cannot use ActiveStateProvider because if we ever want to inject
    // this service into the AccountService, we will make a circular dependency
    private singleUserStateProvider: SingleUserStateProvider,
    private globalStateProvider: GlobalStateProvider,
    private readonly platformSupportsSecureStorage: boolean,
    private secureStorageService: AbstractStorageService,
    private keyGenerationService: KeyGenerationService,
    private encryptService: EncryptService,
    private logService: LogService,
    private logoutCallback: (logoutReason: LogoutReason, userId?: string) => Promise<void>,
  ) {
    this.initializeState();
  }

  hasAccessToken$(userId: UserId): Observable<boolean> {
    // FIXME Once once vault timeout action is observable, we can use it to determine storage location
    // and avoid the need to check both disk and memory.
    return combineLatest([
      this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).state$,
      this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).state$,
    ]).pipe(map(([disk, memory]) => Boolean(disk || memory)));
  }

  private initializeState(): void {
    this.emailTwoFactorTokenRecordGlobalState = this.globalStateProvider.get(
      EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
    );

    this.activeUserIdGlobalState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID);
  }

  async setTokens(
    accessToken: string,
    vaultTimeoutAction: VaultTimeoutAction,
    vaultTimeout: VaultTimeout,
    refreshToken?: string,
    clientIdClientSecret?: [string, string],
  ): Promise<SetTokensResult> {
    if (!accessToken) {
      throw new Error("Access token is required.");
    }

    // Can't check for falsey b/c 0 is a valid value
    if (vaultTimeout == null) {
      throw new Error("Vault Timeout is required.");
    }

    if (vaultTimeoutAction == null) {
      throw new Error("Vault Timeout Action is required.");
    }

    // get user id the access token
    const userId: UserId = await this.getUserIdFromAccessToken(accessToken);

    if (!userId) {
      throw new Error("User id not found. Cannot set tokens.");
    }

    const newAccessToken = await this._setAccessToken(
      accessToken,
      vaultTimeoutAction,
      vaultTimeout,
      userId,
    );

    const newTokens = new SetTokensResult(newAccessToken);

    if (refreshToken) {
      newTokens.refreshToken = await this.setRefreshToken(
        refreshToken,
        vaultTimeoutAction,
        vaultTimeout,
        userId,
      );
    }

    if (clientIdClientSecret != null) {
      const clientId = await this.setClientId(
        clientIdClientSecret[0],
        vaultTimeoutAction,
        vaultTimeout,
        userId,
      );
      const clientSecret = await this.setClientSecret(
        clientIdClientSecret[1],
        vaultTimeoutAction,
        vaultTimeout,
        userId,
      );
      newTokens.clientIdSecretPair = [clientId, clientSecret];
    }
    return newTokens;
  }

  private async getAccessTokenKey(userId: UserId): Promise<AccessTokenKey | null> {
    const accessTokenKeyB64 = await this.secureStorageService.get<
      ReturnType<SymmetricCryptoKey["toJSON"]>
    >(`${userId}${this.accessTokenKeySecureStorageKey}`, this.getSecureStorageOptions(userId));

    if (!accessTokenKeyB64) {
      return null;
    }

    const accessTokenKey = SymmetricCryptoKey.fromJSON(accessTokenKeyB64) as AccessTokenKey;
    return accessTokenKey;
  }

  private async createAndSaveAccessTokenKey(userId: UserId): Promise<AccessTokenKey> {
    const newAccessTokenKey = (await this.keyGenerationService.createKey(512)) as AccessTokenKey;

    await this.secureStorageService.save<AccessTokenKey>(
      `${userId}${this.accessTokenKeySecureStorageKey}`,
      newAccessTokenKey,
      this.getSecureStorageOptions(userId),
    );

    // We are having intermittent issues with access token keys not saving into secure storage on windows 10/11.
    // So, let's add a check to ensure we can read the value after writing it.
    const accessTokenKey = await this.getAccessTokenKey(userId);

    if (!accessTokenKey) {
      throw new Error("New Access token key unable to be retrieved from secure storage.");
    }

    return newAccessTokenKey;
  }

  private async clearAccessTokenKey(userId: UserId): Promise<void> {
    await this.secureStorageService.remove(
      `${userId}${this.accessTokenKeySecureStorageKey}`,
      this.getSecureStorageOptions(userId),
    );
  }

  private async getOrCreateAccessTokenKey(userId: UserId): Promise<AccessTokenKey> {
    if (!this.platformSupportsSecureStorage) {
      throw new Error("Platform does not support secure storage. Cannot obtain access token key.");
    }

    if (!userId) {
      throw new Error("User id not found. Cannot obtain access token key.");
    }

    // First see if we have an accessTokenKey in secure storage and return it if we do
    // Note: retrieving/saving data from/to secure storage on linux will throw if the
    // distro doesn't have a secure storage provider
    let accessTokenKey: AccessTokenKey = await this.getAccessTokenKey(userId);

    if (!accessTokenKey) {
      // Otherwise, create a new one and save it to secure storage, then return it
      accessTokenKey = await this.createAndSaveAccessTokenKey(userId);
    }

    return accessTokenKey;
  }

  private async encryptAccessToken(accessToken: string, userId: UserId): Promise<EncString> {
    const accessTokenKey = await this.getOrCreateAccessTokenKey(userId);

    return await this.encryptService.encrypt(accessToken, accessTokenKey);
  }

  private async decryptAccessToken(
    accessTokenKey: AccessTokenKey,
    encryptedAccessToken: EncString,
  ): Promise<string | null> {
    if (!accessTokenKey) {
      throw new Error(
        "decryptAccessToken: Access token key required. Cannot decrypt access token.",
      );
    }

    const decryptedAccessToken = await this.encryptService.decryptToUtf8(
      encryptedAccessToken,
      accessTokenKey,
    );

    return decryptedAccessToken;
  }

  /**
   * Internal helper for set access token which always requires user id.
   * This is useful because setTokens always will have a user id from the access token whereas
   * the public setAccessToken method does not.
   */
  private async _setAccessToken(
    accessToken: string,
    vaultTimeoutAction: VaultTimeoutAction,
    vaultTimeout: VaultTimeout,
    userId: UserId,
  ): Promise<string> {
    const storageLocation = await this.determineStorageLocation(
      vaultTimeoutAction,
      vaultTimeout,
      true,
    );

    switch (storageLocation) {
      case TokenStorageLocation.SecureStorage: {
        // Secure storage implementations have variable length limitations (Windows), so we cannot
        // store the access token directly. Instead, we encrypt with accessTokenKey and store that
        // in secure storage.

        let decryptedAccessToken: string = null;

        try {
          const encryptedAccessToken: EncString = await this.encryptAccessToken(
            accessToken,
            userId,
          );

          // Save the encrypted access token to disk
          await this.singleUserStateProvider
            .get(userId, ACCESS_TOKEN_DISK)
            .update((_) => encryptedAccessToken.encryptedString);

          // If we've successfully stored the encrypted access token to disk, we can return the decrypted access token
          // so that the caller can use it immediately.
          decryptedAccessToken = accessToken;

          // TODO: PM-6408
          // 2024-02-20: Remove access token from memory so that we migrate to encrypt the access token over time.
          // Remove this call to remove the access token from memory after 3 months.
          await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null);
        } catch (error) {
          this.logService.error(
            `SetAccessToken: storing encrypted access token in secure storage failed. Falling back to disk storage.`,
            error,
          );

          // Fall back to disk storage for unecrypted access token
          decryptedAccessToken = await this.singleUserStateProvider
            .get(userId, ACCESS_TOKEN_DISK)
            .update((_) => accessToken);
        }

        return decryptedAccessToken;
      }
      case TokenStorageLocation.Disk:
        // Access token stored on disk unencrypted as platform does not support secure storage
        return await this.singleUserStateProvider
          .get(userId, ACCESS_TOKEN_DISK)
          .update((_) => accessToken);
      case TokenStorageLocation.Memory:
        // Access token stored in memory due to vault timeout settings
        return await this.singleUserStateProvider
          .get(userId, ACCESS_TOKEN_MEMORY)
          .update((_) => accessToken);
    }
  }

  async setAccessToken(
    accessToken: string,
    vaultTimeoutAction: VaultTimeoutAction,
    vaultTimeout: VaultTimeout,
  ): Promise<string> {
    if (!accessToken) {
      throw new Error("Access token is required.");
    }
    const userId: UserId = await this.getUserIdFromAccessToken(accessToken);

    // If we don't have a user id, we can't save the value
    if (!userId) {
      throw new Error("User id not found. Cannot save access token.");
    }

    // Can't check for falsey b/c 0 is a valid value
    if (vaultTimeout == null) {
      throw new Error("Vault Timeout is required.");
    }

    if (vaultTimeoutAction == null) {
      throw new Error("Vault Timeout Action is required.");
    }

    return await this._setAccessToken(accessToken, vaultTimeoutAction, vaultTimeout, userId);
  }

  async clearAccessToken(userId?: UserId): Promise<void> {
    userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);

    // If we don't have a user id, we can't clear the value
    if (!userId) {
      throw new Error("User id not found. Cannot clear access token.");
    }

    // TODO: re-eval this implementation once we get shared key definitions for vault timeout and vault timeout action data.
    // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
    // but we can simply clear all locations to avoid the need to require those parameters.

    if (this.platformSupportsSecureStorage) {
      // Always clear the access token key when clearing the access token
      // The next set of the access token will create a new access token key
      await this.clearAccessTokenKey(userId);
    }

    // Platform doesn't support secure storage, so use state provider implementation
    await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null);
    await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null);
  }

  async getAccessToken(userId?: UserId): Promise<string | null> {
    userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);

    if (!userId) {
      return null;
    }

    // Try to get the access token from memory
    const accessTokenMemory = await this.getStateValueByUserIdAndKeyDef(
      userId,
      ACCESS_TOKEN_MEMORY,
    );
    if (accessTokenMemory != null) {
      return accessTokenMemory;
    }

    // If memory is null, read from disk
    const accessTokenDisk = await this.getStateValueByUserIdAndKeyDef(userId, ACCESS_TOKEN_DISK);
    if (!accessTokenDisk) {
      return null;
    }

    if (this.platformSupportsSecureStorage) {
      let accessTokenKey: AccessTokenKey;
      try {
        accessTokenKey = await this.getAccessTokenKey(userId);
      } catch (error) {
        if (EncString.isSerializedEncString(accessTokenDisk)) {
          this.logService.error(
            "Access token key retrieval failed. Unable to decrypt encrypted access token. Logging user out.",
            error,
          );
          await this.logoutCallback("accessTokenUnableToBeDecrypted", userId);
          return null;
        }

        // If the access token key is not found, but the access token is unencrypted then
        // this indicates that this is the pre-migration state where the access token
        // was stored unencrypted on disk. We can return the access token as is.
        // Note: this is likely to only be hit for linux users who don't
        // have a secure storage provider configured.
        return accessTokenDisk;
      }

      if (!accessTokenKey) {
        if (EncString.isSerializedEncString(accessTokenDisk)) {
          // The access token is encrypted but we don't have the key to decrypt it for
          // whatever reason so we have to log the user out.
          this.logService.error(
            "Access token key not found to decrypt encrypted access token. Logging user out.",
          );

          await this.logoutCallback("accessTokenUnableToBeDecrypted", userId);

          return null;
        }

        // We know this is an unencrypted access token
        return accessTokenDisk;
      }

      try {
        const encryptedAccessTokenEncString = new EncString(accessTokenDisk as EncryptedString);

        const decryptedAccessToken = await this.decryptAccessToken(
          accessTokenKey,
          encryptedAccessTokenEncString,
        );
        return decryptedAccessToken;
      } catch (error) {
        // If an error occurs during decryption, logout and then return null.
        // We don't try to recover here since we'd like to know
        // if access token and key are getting out of sync.
        this.logService.error(`Failed to decrypt access token`, error);

        await this.logoutCallback("accessTokenUnableToBeDecrypted", userId);

        return null;
      }
    }
    return accessTokenDisk;
  }

  // Private because we only ever set the refresh token when also setting the access token
  // and we need the user id from the access token to save to secure storage
  private async setRefreshToken(
    refreshToken: string,
    vaultTimeoutAction: VaultTimeoutAction,
    vaultTimeout: VaultTimeout,
    userId: UserId,
  ): Promise<string> {
    // If we don't have a user id, we can't save the value
    if (!userId) {
      throw new Error("User id not found. Cannot save refresh token.");
    }

    // Can't check for falsey b/c 0 is a valid value
    if (vaultTimeout == null) {
      throw new Error("Vault Timeout is required.");
    }

    if (vaultTimeoutAction == null) {
      throw new Error("Vault Timeout Action is required.");
    }

    const storageLocation = await this.determineStorageLocation(
      vaultTimeoutAction,
      vaultTimeout,
      true,
    );

    switch (storageLocation) {
      case TokenStorageLocation.SecureStorage: {
        let decryptedRefreshToken: string = null;

        try {
          await this.saveStringToSecureStorage(
            userId,
            this.refreshTokenSecureStorageKey,
            refreshToken,
          );

          // Check if the refresh token was able to be saved to secure storage by reading it
          // immediately after setting it. This is needed due to intermittent silent failures on Windows 10/11.
          const refreshTokenSecureStorage = await this.getStringFromSecureStorage(
            userId,
            this.refreshTokenSecureStorageKey,
          );

          // Only throw if the refresh token was not saved to secure storage
          // If we only check for a nullish value out of secure storage without considering the input value,
          // then we would end up falling back to disk storage if the input value was null.
          if (refreshToken !== null && !refreshTokenSecureStorage) {
            throw new Error("Refresh token failed to save to secure storage.");
          }

          // If we've successfully stored the encrypted refresh token, we can return the decrypted refresh token
          // so that the caller can use it immediately.
          decryptedRefreshToken = refreshToken;

          // TODO: PM-6408
          // 2024-02-20: Remove refresh token from memory and disk so that we migrate to secure storage over time.
          // Remove these 2 calls to remove the refresh token from memory and disk after 3 months.
          await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
          await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null);
        } catch (error) {
          // This case could be hit for both Linux users who don't have secure storage configured
          // or for Windows users who have intermittent issues with secure storage.
          this.logService.error(
            `SetRefreshToken: storing refresh token in secure storage failed. Falling back to disk storage.`,
            error,
          );

          // Fall back to disk storage for refresh token
          decryptedRefreshToken = await this.singleUserStateProvider
            .get(userId, REFRESH_TOKEN_DISK)
            .update((_) => refreshToken);
        }

        return decryptedRefreshToken;
      }
      case TokenStorageLocation.Disk:
        return await this.singleUserStateProvider
          .get(userId, REFRESH_TOKEN_DISK)
          .update((_) => refreshToken);

      case TokenStorageLocation.Memory:
        return await this.singleUserStateProvider
          .get(userId, REFRESH_TOKEN_MEMORY)
          .update((_) => refreshToken);
    }
  }

  async getRefreshToken(userId?: UserId): Promise<string | null> {
    userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);

    if (!userId) {
      return null;
    }

    // pre-secure storage migration:
    // Always read memory first b/c faster
    const refreshTokenMemory = await this.getStateValueByUserIdAndKeyDef(
      userId,
      REFRESH_TOKEN_MEMORY,
    );

    if (refreshTokenMemory != null) {
      return refreshTokenMemory;
    }

    // if memory is null, read from disk and then secure storage
    const refreshTokenDisk = await this.getStateValueByUserIdAndKeyDef(userId, REFRESH_TOKEN_DISK);

    if (refreshTokenDisk != null) {
      // This handles the scenario pre-secure storage migration where the refresh token was stored on disk.
      return refreshTokenDisk;
    }

    if (this.platformSupportsSecureStorage) {
      try {
        const refreshTokenSecureStorage = await this.getStringFromSecureStorage(
          userId,
          this.refreshTokenSecureStorageKey,
        );

        if (refreshTokenSecureStorage != null) {
          return refreshTokenSecureStorage;
        }

        this.logService.error(
          "Refresh token not found in secure storage. Access token will fail to refresh upon expiration or manual refresh.",
        );
      } catch (error) {
        // This case will be hit for Linux users who don't have secure storage configured.

        this.logService.error(`Failed to retrieve refresh token from secure storage`, error);

        await this.logoutCallback("refreshTokenSecureStorageRetrievalFailure", userId);
      }
    }

    return null;
  }

  private async clearRefreshToken(userId: UserId): Promise<void> {
    // If we don't have a user id, we can't clear the value
    if (!userId) {
      throw new Error("User id not found. Cannot clear refresh token.");
    }

    // TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data.
    // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
    // but we can simply clear all locations to avoid the need to require those parameters

    if (this.platformSupportsSecureStorage) {
      await this.secureStorageService.remove(
        `${userId}${this.refreshTokenSecureStorageKey}`,
        this.getSecureStorageOptions(userId),
      );
    }

    // Platform doesn't support secure storage, so use state provider implementation
    await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null);
    await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null);
  }

  async setClientId(
    clientId: string,
    vaultTimeoutAction: VaultTimeoutAction,
    vaultTimeout: VaultTimeout,
    userId?: UserId,
  ): Promise<string> {
    userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);

    // If we don't have a user id, we can't save the value
    if (!userId) {
      throw new Error("User id not found. Cannot save client id.");
    }

    // Can't check for falsey b/c 0 is a valid value
    if (vaultTimeout == null) {
      throw new Error("Vault Timeout is required.");
    }

    if (vaultTimeoutAction == null) {
      throw new Error("Vault Timeout Action is required.");
    }

    const storageLocation = await this.determineStorageLocation(
      vaultTimeoutAction,
      vaultTimeout,
      false, // don't use secure storage for client id
    );

    if (storageLocation === TokenStorageLocation.Disk) {
      return await this.singleUserStateProvider
        .get(userId, API_KEY_CLIENT_ID_DISK)
        .update((_) => clientId);
    } else if (storageLocation === TokenStorageLocation.Memory) {
      return await this.singleUserStateProvider
        .get(userId, API_KEY_CLIENT_ID_MEMORY)
        .update((_) => clientId);
    }
  }

  async getClientId(userId?: UserId): Promise<string | undefined> {
    userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);

    if (!userId) {
      return undefined;
    }

    // Always read memory first b/c faster
    const apiKeyClientIdMemory = await this.getStateValueByUserIdAndKeyDef(
      userId,
      API_KEY_CLIENT_ID_MEMORY,
    );

    if (apiKeyClientIdMemory != null) {
      return apiKeyClientIdMemory;
    }

    // if memory is null, read from disk
    return await this.getStateValueByUserIdAndKeyDef(userId, API_KEY_CLIENT_ID_DISK);
  }

  private async clearClientId(userId?: UserId): Promise<void> {
    userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);

    // If we don't have a user id, we can't clear the value
    if (!userId) {
      throw new Error("User id not found. Cannot clear client id.");
    }

    // TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data.
    // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
    // but we can simply clear both locations to avoid the need to require those parameters

    // Platform doesn't support secure storage, so use state provider implementation
    await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_ID_MEMORY).update((_) => null);
    await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_ID_DISK).update((_) => null);
  }

  async setClientSecret(
    clientSecret: string,
    vaultTimeoutAction: VaultTimeoutAction,
    vaultTimeout: VaultTimeout,
    userId?: UserId,
  ): Promise<string> {
    userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);

    if (!userId) {
      throw new Error("User id not found. Cannot save client secret.");
    }

    // Can't check for falsey b/c 0 is a valid value
    if (vaultTimeout == null) {
      throw new Error("Vault Timeout is required.");
    }

    if (vaultTimeoutAction == null) {
      throw new Error("Vault Timeout Action is required.");
    }

    const storageLocation = await this.determineStorageLocation(
      vaultTimeoutAction,
      vaultTimeout,
      false, // don't use secure storage for client secret
    );

    if (storageLocation === TokenStorageLocation.Disk) {
      return await this.singleUserStateProvider
        .get(userId, API_KEY_CLIENT_SECRET_DISK)
        .update((_) => clientSecret);
    } else if (storageLocation === TokenStorageLocation.Memory) {
      return await this.singleUserStateProvider
        .get(userId, API_KEY_CLIENT_SECRET_MEMORY)
        .update((_) => clientSecret);
    }
  }

  async getClientSecret(userId?: UserId): Promise<string | undefined> {
    userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);

    if (!userId) {
      return undefined;
    }

    // Always read memory first b/c faster
    const apiKeyClientSecretMemory = await this.getStateValueByUserIdAndKeyDef(
      userId,
      API_KEY_CLIENT_SECRET_MEMORY,
    );

    if (apiKeyClientSecretMemory != null) {
      return apiKeyClientSecretMemory;
    }

    // if memory is null, read from disk
    return await this.getStateValueByUserIdAndKeyDef(userId, API_KEY_CLIENT_SECRET_DISK);
  }

  private async clearClientSecret(userId?: UserId): Promise<void> {
    userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);

    // If we don't have a user id, we can't clear the value
    if (!userId) {
      throw new Error("User id not found. Cannot clear client secret.");
    }

    // TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data.
    // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
    // but we can simply clear both locations to avoid the need to require those parameters

    // Platform doesn't support secure storage, so use state provider implementation
    await this.singleUserStateProvider
      .get(userId, API_KEY_CLIENT_SECRET_MEMORY)
      .update((_) => null);
    await this.singleUserStateProvider.get(userId, API_KEY_CLIENT_SECRET_DISK).update((_) => null);
  }

  async setTwoFactorToken(email: string, twoFactorToken: string): Promise<void> {
    await this.emailTwoFactorTokenRecordGlobalState.update((emailTwoFactorTokenRecord) => {
      emailTwoFactorTokenRecord ??= {};

      emailTwoFactorTokenRecord[email] = twoFactorToken;
      return emailTwoFactorTokenRecord;
    });
  }

  async getTwoFactorToken(email: string): Promise<string | null> {
    const emailTwoFactorTokenRecord: Record<string, string> = await firstValueFrom(
      this.emailTwoFactorTokenRecordGlobalState.state$,
    );

    if (!emailTwoFactorTokenRecord) {
      return null;
    }

    return emailTwoFactorTokenRecord[email];
  }

  async clearTwoFactorToken(email: string): Promise<void> {
    await this.emailTwoFactorTokenRecordGlobalState.update((emailTwoFactorTokenRecord) => {
      emailTwoFactorTokenRecord ??= {};
      delete emailTwoFactorTokenRecord[email];
      return emailTwoFactorTokenRecord;
    });
  }

  // TODO: stop accepting optional userIds
  async clearTokens(userId?: UserId): Promise<void> {
    userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);

    if (!userId) {
      throw new Error("User id not found. Cannot clear tokens.");
    }

    await Promise.all([
      this.clearAccessToken(userId),
      this.clearRefreshToken(userId),
      this.clearClientId(userId),
      this.clearClientSecret(userId),
    ]);
  }

  // jwthelper methods
  // ref https://github.com/auth0/angular-jwt/blob/master/src/angularJwt/services/jwt.js

  async decodeAccessToken(tokenOrUserId?: string | UserId): Promise<DecodedAccessToken> {
    let token = tokenOrUserId as string;
    if (Utils.isGuid(tokenOrUserId)) {
      token = await this.getAccessToken(tokenOrUserId as UserId);
    } else {
      token ??= await this.getAccessToken();
    }

    if (token == null) {
      throw new Error("Access token not found.");
    }

    return decodeJwtTokenToJson(token) as DecodedAccessToken;
  }

  // TODO: PM-6678- tech debt - consider consolidating the return types of all these access
  // token data retrieval methods to return null if something goes wrong instead of throwing an error.

  async getTokenExpirationDate(): Promise<Date | null> {
    let decoded: DecodedAccessToken;
    try {
      decoded = await this.decodeAccessToken();
    } catch (error) {
      throw new Error("Failed to decode access token: " + error.message);
    }

    // per RFC, exp claim is optional but if it exists, it should be a number
    if (!decoded || typeof decoded.exp !== "number") {
      return null;
    }

    // The 0 in Date(0) is the key; it sets the date to the epoch
    const expirationDate = new Date(0);
    expirationDate.setUTCSeconds(decoded.exp);
    return expirationDate;
  }

  async tokenSecondsRemaining(offsetSeconds = 0): Promise<number> {
    const date = await this.getTokenExpirationDate();
    if (date == null) {
      return 0;
    }

    const msRemaining = date.valueOf() - (new Date().valueOf() + offsetSeconds * 1000);
    return Math.round(msRemaining / 1000);
  }

  async tokenNeedsRefresh(minutes = 5): Promise<boolean> {
    const sRemaining = await this.tokenSecondsRemaining();
    return sRemaining < 60 * minutes;
  }

  async getUserId(): Promise<UserId> {
    let decoded: DecodedAccessToken;
    try {
      decoded = await this.decodeAccessToken();
    } catch (error) {
      throw new Error("Failed to decode access token: " + error.message);
    }

    if (!decoded || typeof decoded.sub !== "string") {
      throw new Error("No user id found");
    }

    return decoded.sub as UserId;
  }

  private async getUserIdFromAccessToken(accessToken: string): Promise<UserId> {
    let decoded: DecodedAccessToken;
    try {
      decoded = await this.decodeAccessToken(accessToken);
    } catch (error) {
      throw new Error("Failed to decode access token: " + error.message);
    }

    if (!decoded || typeof decoded.sub !== "string") {
      throw new Error("No user id found");
    }

    return decoded.sub as UserId;
  }

  async getEmail(): Promise<string> {
    let decoded: DecodedAccessToken;
    try {
      decoded = await this.decodeAccessToken();
    } catch (error) {
      throw new Error("Failed to decode access token: " + error.message);
    }

    if (!decoded || typeof decoded.email !== "string") {
      throw new Error("No email found");
    }

    return decoded.email;
  }

  async getEmailVerified(): Promise<boolean> {
    let decoded: DecodedAccessToken;
    try {
      decoded = await this.decodeAccessToken();
    } catch (error) {
      throw new Error("Failed to decode access token: " + error.message);
    }

    if (!decoded || typeof decoded.email_verified !== "boolean") {
      throw new Error("No email verification found");
    }

    return decoded.email_verified;
  }

  async getName(): Promise<string> {
    let decoded: DecodedAccessToken;
    try {
      decoded = await this.decodeAccessToken();
    } catch (error) {
      throw new Error("Failed to decode access token: " + error.message);
    }

    if (!decoded || typeof decoded.name !== "string") {
      return null;
    }

    return decoded.name;
  }

  async getIssuer(): Promise<string> {
    let decoded: DecodedAccessToken;
    try {
      decoded = await this.decodeAccessToken();
    } catch (error) {
      throw new Error("Failed to decode access token: " + error.message);
    }

    if (!decoded || typeof decoded.iss !== "string") {
      throw new Error("No issuer found");
    }

    return decoded.iss;
  }

  async getIsExternal(userId: UserId): Promise<boolean> {
    let decoded: DecodedAccessToken;
    try {
      decoded = await this.decodeAccessToken(userId);
    } catch (error) {
      throw new Error("Failed to decode access token: " + error.message);
    }

    return Array.isArray(decoded.amr) && decoded.amr.includes("external");
  }

  async getSecurityStamp(userId?: UserId): Promise<string | null> {
    userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);

    if (!userId) {
      throw new Error("User id not found. Cannot get security stamp.");
    }

    const securityStamp = await this.getStateValueByUserIdAndKeyDef(userId, SECURITY_STAMP_MEMORY);

    return securityStamp;
  }

  async setSecurityStamp(securityStamp: string, userId?: UserId): Promise<void> {
    userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);

    if (!userId) {
      throw new Error("User id not found. Cannot set security stamp.");
    }

    await this.singleUserStateProvider
      .get(userId, SECURITY_STAMP_MEMORY)
      .update((_) => securityStamp);
  }

  private async getStateValueByUserIdAndKeyDef(
    userId: UserId,
    storageLocation: UserKeyDefinition<string>,
  ): Promise<string | undefined> {
    // read from single user state provider
    return await firstValueFrom(this.singleUserStateProvider.get(userId, storageLocation).state$);
  }

  private async determineStorageLocation(
    vaultTimeoutAction: VaultTimeoutAction,
    vaultTimeout: VaultTimeout,
    useSecureStorage: boolean,
  ): Promise<TokenStorageLocation> {
    if (vaultTimeoutAction == null) {
      throw new Error(
        "TokenService - determineStorageLocation: We expect the vault timeout action to always exist at this point.",
      );
    }

    if (vaultTimeout == null) {
      throw new Error(
        "TokenService - determineStorageLocation: We expect the vault timeout to always exist at this point.",
      );
    }

    if (
      vaultTimeoutAction === VaultTimeoutAction.LogOut &&
      vaultTimeout !== VaultTimeoutStringType.Never
    ) {
      return TokenStorageLocation.Memory;
    } else {
      if (useSecureStorage && this.platformSupportsSecureStorage) {
        return TokenStorageLocation.SecureStorage;
      }

      return TokenStorageLocation.Disk;
    }
  }

  private async saveStringToSecureStorage(
    userId: UserId,
    storageKey: string,
    value: string,
  ): Promise<void> {
    await this.secureStorageService.save<string>(
      `${userId}${storageKey}`,
      value,
      this.getSecureStorageOptions(userId),
    );
  }

  private async getStringFromSecureStorage(
    userId: UserId,
    storageKey: string,
  ): Promise<string | null> {
    // If we have a user ID, read from secure storage.
    return await this.secureStorageService.get<string>(
      `${userId}${storageKey}`,
      this.getSecureStorageOptions(userId),
    );
  }

  private getSecureStorageOptions(userId: UserId): StorageOptions {
    return {
      storageLocation: StorageLocation.Disk,
      useSecureStorage: true,
      userId: userId,
    };
  }
}
