import { LogService } from "../../../platform/abstractions/log.service";
import { Utils } from "../../../platform/misc/utils";
import { CipherService } from "../../abstractions/cipher.service";
import {
  Fido2AlgorithmIdentifier,
  Fido2AuthenticatorError,
  Fido2AuthenticatorErrorCode,
  Fido2AuthenticatorGetAssertionParams,
  Fido2AuthenticatorGetAssertionResult,
  Fido2AuthenticatorMakeCredentialResult,
  Fido2AuthenticatorMakeCredentialsParams,
  Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction,
  PublicKeyCredentialDescriptor,
} from "../../abstractions/fido2/fido2-authenticator.service.abstraction";
import { Fido2UserInterfaceService } from "../../abstractions/fido2/fido2-user-interface.service.abstraction";
import { SyncService } from "../../abstractions/sync/sync.service.abstraction";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherType } from "../../enums/cipher-type";
import { CipherView } from "../../models/view/cipher.view";
import { Fido2CredentialView } from "../../models/view/fido2-credential.view";

import { CBOR } from "./cbor";
import { p1363ToDer } from "./ecdsa-utils";
import { Fido2Utils } from "./fido2-utils";
import { guidToRawFormat, guidToStandardFormat } from "./guid-utils";

// AAGUID: d548826e-79b4-db40-a3d8-11116f7e8349
export const AAGUID = new Uint8Array([
  0xd5, 0x48, 0x82, 0x6e, 0x79, 0xb4, 0xdb, 0x40, 0xa3, 0xd8, 0x11, 0x11, 0x6f, 0x7e, 0x83, 0x49,
]);

const KeyUsages: KeyUsage[] = ["sign"];

/**
 * Bitwarden implementation of the WebAuthn Authenticator Model as described by W3C
 * https://www.w3.org/TR/webauthn-3/#sctn-authenticator-model
 *
 * It is highly recommended that the W3C specification is used a reference when reading this code.
 */
export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstraction {
  constructor(
    private cipherService: CipherService,
    private userInterface: Fido2UserInterfaceService,
    private syncService: SyncService,
    private logService?: LogService,
  ) {}

  async makeCredential(
    params: Fido2AuthenticatorMakeCredentialsParams,
    tab: chrome.tabs.Tab,
    abortController?: AbortController,
  ): Promise<Fido2AuthenticatorMakeCredentialResult> {
    const userInterfaceSession = await this.userInterface.newSession(
      params.fallbackSupported,
      tab,
      abortController,
    );

    try {
      if (params.credTypesAndPubKeyAlgs.every((p) => p.alg !== Fido2AlgorithmIdentifier.ES256)) {
        const requestedAlgorithms = params.credTypesAndPubKeyAlgs.map((p) => p.alg).join(", ");
        this.logService?.warning(
          `[Fido2Authenticator] No compatible algorithms found, RP requested: ${requestedAlgorithms}`,
        );
        throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotSupported);
      }

      if (
        params.requireResidentKey != undefined &&
        typeof params.requireResidentKey !== "boolean"
      ) {
        this.logService?.error(
          `[Fido2Authenticator] Invalid 'requireResidentKey' value: ${String(
            params.requireResidentKey,
          )}`,
        );
        throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown);
      }

      if (
        params.requireUserVerification != undefined &&
        typeof params.requireUserVerification !== "boolean"
      ) {
        this.logService?.error(
          `[Fido2Authenticator] Invalid 'requireUserVerification' value: ${String(
            params.requireUserVerification,
          )}`,
        );
        throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown);
      }

      await userInterfaceSession.ensureUnlockedVault();
      await this.syncService.fullSync(false);

      const existingCipherIds = await this.findExcludedCredentials(
        params.excludeCredentialDescriptorList,
      );
      if (existingCipherIds.length > 0) {
        this.logService?.info(
          `[Fido2Authenticator] Aborting due to excluded credential found in vault.`,
        );
        await userInterfaceSession.informExcludedCredential(existingCipherIds);
        throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed);
      }

      let cipher: CipherView;
      let fido2Credential: Fido2CredentialView;
      let keyPair: CryptoKeyPair;
      let userVerified = false;
      let credentialId: string;
      let pubKeyDer: ArrayBuffer;
      const response = await userInterfaceSession.confirmNewCredential({
        credentialName: params.rpEntity.name,
        userName: params.userEntity.displayName,
        userVerification: params.requireUserVerification,
        rpId: params.rpEntity.id,
      });
      const cipherId = response.cipherId;
      userVerified = response.userVerified;

      if (cipherId === undefined) {
        this.logService?.warning(
          `[Fido2Authenticator] Aborting because user confirmation was not recieved.`,
        );
        throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed);
      }

      try {
        keyPair = await createKeyPair();
        pubKeyDer = await crypto.subtle.exportKey("spki", keyPair.publicKey);
        const encrypted = await this.cipherService.get(cipherId);
        cipher = await encrypted.decrypt(
          await this.cipherService.getKeyForCipherKeyDecryption(encrypted),
        );

        if (
          !userVerified &&
          (params.requireUserVerification || cipher.reprompt !== CipherRepromptType.None)
        ) {
          this.logService?.warning(
            `[Fido2Authenticator] Aborting because user verification was unsuccessful.`,
          );
          throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed);
        }

        fido2Credential = await createKeyView(params, keyPair.privateKey);
        cipher.login.fido2Credentials = [fido2Credential];
        const reencrypted = await this.cipherService.encrypt(cipher);
        await this.cipherService.updateWithServer(reencrypted);
        credentialId = fido2Credential.credentialId;
      } catch (error) {
        this.logService?.error(
          `[Fido2Authenticator] Aborting because of unknown error when creating credential: ${error}`,
        );
        throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown);
      }

      const authData = await generateAuthData({
        rpId: params.rpEntity.id,
        credentialId: guidToRawFormat(credentialId),
        counter: fido2Credential.counter,
        userPresence: true,
        userVerification: userVerified,
        keyPair,
      });
      const attestationObject = new Uint8Array(
        CBOR.encode({
          fmt: "none",
          attStmt: {},
          authData,
        }),
      );

      return {
        credentialId: guidToRawFormat(credentialId),
        attestationObject,
        authData,
        publicKey: pubKeyDer,
        publicKeyAlgorithm: -7,
      };
    } finally {
      userInterfaceSession.close();
    }
  }

  async getAssertion(
    params: Fido2AuthenticatorGetAssertionParams,
    tab: chrome.tabs.Tab,
    abortController?: AbortController,
  ): Promise<Fido2AuthenticatorGetAssertionResult> {
    const userInterfaceSession = await this.userInterface.newSession(
      params.fallbackSupported,
      tab,
      abortController,
    );
    try {
      if (
        params.requireUserVerification != undefined &&
        typeof params.requireUserVerification !== "boolean"
      ) {
        this.logService?.error(
          `[Fido2Authenticator] Invalid 'requireUserVerification' value: ${String(
            params.requireUserVerification,
          )}`,
        );
        throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown);
      }

      let cipherOptions: CipherView[];

      await userInterfaceSession.ensureUnlockedVault();
      await this.syncService.fullSync(false);

      if (params.allowCredentialDescriptorList?.length > 0) {
        cipherOptions = await this.findCredentialsById(
          params.allowCredentialDescriptorList,
          params.rpId,
        );
      } else {
        cipherOptions = await this.findCredentialsByRp(params.rpId);
      }

      if (cipherOptions.length === 0) {
        this.logService?.info(
          `[Fido2Authenticator] Aborting because no matching credentials were found in the vault.`,
        );

        await userInterfaceSession.informCredentialNotFound();
        throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed);
      }

      const response = await userInterfaceSession.pickCredential({
        cipherIds: cipherOptions.map((cipher) => cipher.id),
        userVerification: params.requireUserVerification,
      });
      const selectedCipherId = response.cipherId;
      const userVerified = response.userVerified;
      const selectedCipher = cipherOptions.find((c) => c.id === selectedCipherId);

      if (selectedCipher === undefined) {
        this.logService?.error(
          `[Fido2Authenticator] Aborting because the selected credential could not be found.`,
        );
        throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed);
      }

      if (
        !userVerified &&
        (params.requireUserVerification || selectedCipher.reprompt !== CipherRepromptType.None)
      ) {
        this.logService?.warning(
          `[Fido2Authenticator] Aborting because user verification was unsuccessful.`,
        );
        throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed);
      }

      try {
        const selectedFido2Credential = selectedCipher.login.fido2Credentials[0];
        const selectedCredentialId = selectedFido2Credential.credentialId;

        if (selectedFido2Credential.counter > 0) {
          ++selectedFido2Credential.counter;
        }

        selectedCipher.localData = {
          ...selectedCipher.localData,
          lastUsedDate: new Date().getTime(),
        };

        if (selectedFido2Credential.counter > 0) {
          const encrypted = await this.cipherService.encrypt(selectedCipher);
          await this.cipherService.updateWithServer(encrypted);
        }

        const authenticatorData = await generateAuthData({
          rpId: selectedFido2Credential.rpId,
          credentialId: guidToRawFormat(selectedCredentialId),
          counter: selectedFido2Credential.counter,
          userPresence: true,
          userVerification: userVerified,
        });

        const signature = await generateSignature({
          authData: authenticatorData,
          clientDataHash: params.hash,
          privateKey: await getPrivateKeyFromFido2Credential(selectedFido2Credential),
        });

        return {
          authenticatorData,
          selectedCredential: {
            id: guidToRawFormat(selectedCredentialId),
            userHandle: Fido2Utils.stringToBuffer(selectedFido2Credential.userHandle),
          },
          signature,
        };
      } catch (error) {
        this.logService?.error(
          `[Fido2Authenticator] Aborting because of unknown error when asserting credential: ${error}`,
        );
        throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown);
      }
    } finally {
      userInterfaceSession.close();
    }
  }

  /** Finds existing crendetials and returns the `cipherId` for each one */
  private async findExcludedCredentials(
    credentials: PublicKeyCredentialDescriptor[],
  ): Promise<string[]> {
    const ids: string[] = [];

    for (const credential of credentials) {
      try {
        ids.push(guidToStandardFormat(credential.id));
        // eslint-disable-next-line no-empty
      } catch {}
    }

    if (ids.length === 0) {
      return [];
    }

    const ciphers = await this.cipherService.getAllDecrypted();
    return ciphers
      .filter(
        (cipher) =>
          !cipher.isDeleted &&
          cipher.organizationId == undefined &&
          cipher.type === CipherType.Login &&
          cipher.login.hasFido2Credentials &&
          ids.includes(cipher.login.fido2Credentials[0].credentialId),
      )
      .map((cipher) => cipher.id);
  }

  private async findCredentialsById(
    credentials: PublicKeyCredentialDescriptor[],
    rpId: string,
  ): Promise<CipherView[]> {
    const ids: string[] = [];

    for (const credential of credentials) {
      try {
        ids.push(guidToStandardFormat(credential.id));
        // eslint-disable-next-line no-empty
      } catch {}
    }

    if (ids.length === 0) {
      return [];
    }

    const ciphers = await this.cipherService.getAllDecrypted();
    return ciphers.filter(
      (cipher) =>
        !cipher.isDeleted &&
        cipher.type === CipherType.Login &&
        cipher.login.hasFido2Credentials &&
        cipher.login.fido2Credentials[0].rpId === rpId &&
        ids.includes(cipher.login.fido2Credentials[0].credentialId),
    );
  }

  private async findCredentialsByRp(rpId: string): Promise<CipherView[]> {
    const ciphers = await this.cipherService.getAllDecrypted();
    return ciphers.filter(
      (cipher) =>
        !cipher.isDeleted &&
        cipher.type === CipherType.Login &&
        cipher.login.hasFido2Credentials &&
        cipher.login.fido2Credentials[0].rpId === rpId &&
        cipher.login.fido2Credentials[0].discoverable,
    );
  }
}

async function createKeyPair() {
  return await crypto.subtle.generateKey(
    {
      name: "ECDSA",
      namedCurve: "P-256",
    },
    true,
    KeyUsages,
  );
}

async function createKeyView(
  params: Fido2AuthenticatorMakeCredentialsParams,
  keyValue: CryptoKey,
): Promise<Fido2CredentialView> {
  if (keyValue.algorithm.name !== "ECDSA" && (keyValue.algorithm as any).namedCurve !== "P-256") {
    throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown);
  }

  const pkcs8Key = await crypto.subtle.exportKey("pkcs8", keyValue);
  const fido2Credential = new Fido2CredentialView();
  fido2Credential.credentialId = Utils.newGuid();
  fido2Credential.keyType = "public-key";
  fido2Credential.keyAlgorithm = "ECDSA";
  fido2Credential.keyCurve = "P-256";
  fido2Credential.keyValue = Fido2Utils.bufferToString(pkcs8Key);
  fido2Credential.rpId = params.rpEntity.id;
  fido2Credential.userHandle = Fido2Utils.bufferToString(params.userEntity.id);
  fido2Credential.userName = params.userEntity.name;
  fido2Credential.counter = 0;
  fido2Credential.rpName = params.rpEntity.name;
  fido2Credential.userDisplayName = params.userEntity.displayName;
  fido2Credential.discoverable = params.requireResidentKey;
  fido2Credential.creationDate = new Date();

  return fido2Credential;
}

async function getPrivateKeyFromFido2Credential(
  fido2Credential: Fido2CredentialView,
): Promise<CryptoKey> {
  const keyBuffer = Fido2Utils.stringToBuffer(fido2Credential.keyValue);
  return await crypto.subtle.importKey(
    "pkcs8",
    keyBuffer,
    {
      name: fido2Credential.keyAlgorithm,
      namedCurve: fido2Credential.keyCurve,
    } as EcKeyImportParams,
    true,
    KeyUsages,
  );
}

interface AuthDataParams {
  rpId: string;
  credentialId: BufferSource;
  userPresence: boolean;
  userVerification: boolean;
  counter: number;
  keyPair?: CryptoKeyPair;
}

async function generateAuthData(params: AuthDataParams) {
  const authData: Array<number> = [];

  const rpIdHash = new Uint8Array(
    await crypto.subtle.digest({ name: "SHA-256" }, Utils.fromByteStringToArray(params.rpId)),
  );
  authData.push(...rpIdHash);

  const flags = authDataFlags({
    extensionData: false,
    attestationData: params.keyPair != undefined,
    backupEligibility: true,
    backupState: true, // Credentials are always synced
    userVerification: params.userVerification,
    userPresence: params.userPresence,
  });
  authData.push(flags);

  // add 4 bytes of counter - we use time in epoch seconds as monotonic counter
  // TODO: Consider changing this to a cryptographically safe random number
  const counter = params.counter;
  authData.push(
    ((counter & 0xff000000) >> 24) & 0xff,
    ((counter & 0x00ff0000) >> 16) & 0xff,
    ((counter & 0x0000ff00) >> 8) & 0xff,
    counter & 0x000000ff,
  );

  if (params.keyPair) {
    // attestedCredentialData
    const attestedCredentialData: Array<number> = [];

    attestedCredentialData.push(...AAGUID);

    // credentialIdLength (2 bytes) and credential Id
    const rawId = Fido2Utils.bufferSourceToUint8Array(params.credentialId);
    const credentialIdLength = [(rawId.length - (rawId.length & 0xff)) / 256, rawId.length & 0xff];
    attestedCredentialData.push(...credentialIdLength);
    attestedCredentialData.push(...rawId);

    const publicKeyJwk = await crypto.subtle.exportKey("jwk", params.keyPair.publicKey);
    // COSE format of the EC256 key
    const keyX = Utils.fromUrlB64ToArray(publicKeyJwk.x);
    const keyY = Utils.fromUrlB64ToArray(publicKeyJwk.y);

    // Can't get `cbor-redux` to encode in CTAP2 canonical CBOR. So we do it manually:
    const coseBytes = new Uint8Array(77);
    coseBytes.set([0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20], 0);
    coseBytes.set(keyX, 10);
    coseBytes.set([0x22, 0x58, 0x20], 10 + 32);
    coseBytes.set(keyY, 10 + 32 + 3);

    // credential public key - convert to array from CBOR encoded COSE key
    attestedCredentialData.push(...coseBytes);

    authData.push(...attestedCredentialData);
  }

  return new Uint8Array(authData);
}

interface SignatureParams {
  authData: Uint8Array;
  clientDataHash: BufferSource;
  privateKey: CryptoKey;
}

async function generateSignature(params: SignatureParams) {
  const sigBase = new Uint8Array([
    ...params.authData,
    ...Fido2Utils.bufferSourceToUint8Array(params.clientDataHash),
  ]);
  const p1363_signature = new Uint8Array(
    await crypto.subtle.sign(
      {
        name: "ECDSA",
        hash: { name: "SHA-256" },
      },
      params.privateKey,
      sigBase,
    ),
  );

  const asn1Der_signature = p1363ToDer(p1363_signature);

  return asn1Der_signature;
}

interface Flags {
  extensionData: boolean;
  attestationData: boolean;
  backupEligibility: boolean;
  backupState: boolean;
  userVerification: boolean;
  userPresence: boolean;
}

function authDataFlags(options: Flags): number {
  let flags = 0;

  if (options.extensionData) {
    flags |= 0b1000000;
  }

  if (options.attestationData) {
    flags |= 0b01000000;
  }

  if (options.backupEligibility) {
    flags |= 0b00001000;
  }

  if (options.backupState) {
    flags |= 0b00010000;
  }

  if (options.userVerification) {
    flags |= 0b00000100;
  }

  if (options.userPresence) {
    flags |= 0b00000001;
  }

  return flags;
}
