import { set, get, del, keys, clear, createStore } from "idb-keyval";

import {
  createSlip21,
  utf8ToBytes,
  bytesToUtf8,
  base64UrlToUint8Array,
  uint8ArrayToBase64Url,
  EncryptionKey,
  Base64Url,
} from "@evolu/common";

import type {
  AuthResult,
  Entropy32,
  SensitiveInfoItem,
  SecureStorage,
  RandomBytesDep,
  SymmetricCryptoDep,
} from "@evolu/common";
import type { UseStore } from "idb-keyval";

/** @experimental */
export const createWebAuthnStore = (
  deps: RandomBytesDep & SymmetricCryptoDep,
): SecureStorage => ({
  setItem: async (key, value, options) => {
    if (options?.accessControl === "none") {
      const metadata = createMetadata(false);
      await set(key, { value, metadata }, getStore(options.service));
      return { metadata };
    }
    const seed = generateSeed(deps)();
    const authResult = JSON.parse(value) as AuthResult;
    const credential = await createCredential(deps)(
      options?.webAuthnUsername ?? "Evolu User",
      seed,
      options?.relyingPartyID,
      options?.relyingPartyName,
      options?.webAuthnUserVerification,
      options?.webAuthnAuthenticatorAttachment,
    );
    const encryptionKey = deriveEncryptionKey(seed);
    const encryptedData = encryptAuthResult(deps)(authResult, encryptionKey);
    const credentialId = uint8ArrayToBase64Url(
      new Uint8Array(credential.rawId),
    );
    const metadata = createMetadata();
    await set(
      key,
      { credentialId, ...encryptedData, metadata },
      getStore(options?.service),
    );
    return { metadata };
  },

  getItem: async (key, options) => {
    if (options?.accessControl === "none") {
      const data = await get<{
        readonly value: string;
        readonly metadata: SensitiveInfoItem["metadata"];
      }>(key, getStore(options.service));
      return data
        ? {
            key,
            value: data.value,
            service: options.service ?? "default",
            metadata: data.metadata,
          }
        : null;
    }
    const data = await get<{
      readonly nonce: Base64Url;
      readonly ciphertext: Base64Url;
      readonly credentialId: string;
      readonly metadata: SensitiveInfoItem["metadata"];
    }>(key, getStore(options?.service));
    if (!data) {
      return null;
    }
    try {
      const credential = await getCredential(deps)(
        data.credentialId,
        options?.relyingPartyID,
        options?.webAuthnUserVerification,
      );
      const credentialSeed = extractSeedFromCredential(credential);
      const encryptionKey = deriveEncryptionKey(credentialSeed);
      const authResultVal = decryptAuthResult(deps)(data, encryptionKey);
      if (!authResultVal) {
        return null;
      }
      return {
        key,
        service: options?.service ?? "default",
        value: authResultVal,
        metadata: data.metadata,
      };
    } catch (_error) {
      return null;
    }
  },

  deleteItem: async (key, options) => {
    await del(key, getStore(options?.service));
    return true;
  },

  getAllItems: async (options) => {
    const service = options?.service ?? "default";
    const itemKeys = await keys<string>(getStore(service));
    const items = await Promise.all(
      itemKeys.map(async (key) => {
        const data = await get<{
          readonly metadata?: SensitiveInfoItem["metadata"];
          readonly value?: string;
        }>(key, getStore(service));
        return {
          key,
          service,
          metadata: data?.metadata ?? createMetadata(),
          ...(options?.includeValues && data?.value
            ? { value: data.value }
            : {}),
        };
      }),
    );
    return items;
  },

  clearService: async (options) => {
    await clear(getStore(options?.service));
  },
});

/**
 * Create default metadata for backwards compatibility with items that don't
 * have stored metadata.
 */
const createMetadata = (isSecure = true): SensitiveInfoItem["metadata"] => {
  return {
    backend: "keychain",
    accessControl: isSecure ? "biometryCurrentSet" : "none",
    securityLevel: isSecure ? "biometry" : "software",
    timestamp: Date.now(),
  };
};

/** Get storage key for owner ID. (supports namespaces via prefix) */
const getStore = (prefix = "default"): UseStore => {
  return createStore(prefix, "evolu-auth");
};

const createCredential =
  (deps: RandomBytesDep) =>
  async (
    username: string,
    seed: Uint8Array,
    relyingPartyID?: string,
    relyingPartyName?: string,
    userVerification?: UserVerificationRequirement,
    authenticatorAttachment?: AuthenticatorAttachment,
  ): Promise<PublicKeyCredential> => {
    const options = createCredentialCreationOptions(deps)(
      username,
      seed,
      relyingPartyID,
      relyingPartyName,
      userVerification,
      authenticatorAttachment,
    );
    const credential = (await navigator.credentials.create(
      options,
    )) as PublicKeyCredential | null;
    if (!credential) {
      throw new Error("Failed to create WebAuthn credential");
    }
    return credential;
  };

const getCredential =
  (deps: RandomBytesDep) =>
  async (
    credentialId: string,
    relyingPartyID?: string,
    userVerification?: UserVerificationRequirement,
  ): Promise<PublicKeyCredential> => {
    const options = createCredentialRequestOptions(deps)(
      credentialId,
      relyingPartyID,
      userVerification,
    );
    const credential = (await navigator.credentials.get(
      options,
    )) as PublicKeyCredential | null;
    if (!credential?.response) {
      throw new Error("Failed to get WebAuthn credential");
    }
    return credential;
  };

const extractSeedFromCredential = (
  credential: PublicKeyCredential,
): Uint8Array => {
  const response = credential.response as AuthenticatorAssertionResponse;
  if (!response.userHandle) {
    throw new Error("No userHandle in credential response");
  }
  return new Uint8Array(response.userHandle);
};

const createCredentialCreationOptions =
  (deps: RandomBytesDep) =>
  (
    username: string,
    seed: Uint8Array,
    relyingPartyID?: string,
    relyingPartyName?: string,
    userVerification?: UserVerificationRequirement,
    authenticatorAttachment?: AuthenticatorAttachment,
  ): CredentialCreationOptions => {
    return {
      publicKey: {
        challenge: generateSeed(deps)() as BufferSource,
        rp: {
          id: relyingPartyID ?? document.location.hostname,
          name: relyingPartyName ?? "Evolu",
        },
        user: {
          id: seed as BufferSource,
          name: username,
          displayName: username,
        },
        pubKeyCredParams: [
          { type: "public-key", alg: -8 }, // Ed25519
          { type: "public-key", alg: -7 }, // ES256
          { type: "public-key", alg: -257 }, // RS256
        ],
        attestation: "none",
        authenticatorSelection: {
          // - "platform": Uses the platform's built-in authenticator.
          // - "cross-platform": Uses a device specific authenticator (yubikey, fido2, etc.)
          authenticatorAttachment: authenticatorAttachment ?? "platform",
          // - "discouraged": Only User Presence is needed.
          // - "preferred": User Verification is preferred but not required. Falls back to User Presence.
          // - "required": User Verification MUST occur (biometrics/PIN). Clients may silently downgrade to User Presence only.
          userVerification: userVerification ?? "required",
          // - "discouraged": Server-side credential is preferable, but will accept client-side discoverable credential.
          // - "preferred": Relying Party strongly prefers client-side discoverable credential but will accept server-side credential.
          // - "required": Client-side discoverable credential MUST be created, error if it can't be created.
          residentKey: "required",
          // Included for backwards compatibility. Deprecated in favor of residentKey (true = "required")
          requireResidentKey: true,
        },
      },
    };
  };

const createCredentialRequestOptions =
  (deps: RandomBytesDep) =>
  (
    credentialId: string,
    relyingPartyID?: string,
    userVerification?: UserVerificationRequirement,
  ): CredentialRequestOptions => {
    return {
      publicKey: {
        challenge: generateSeed(deps)() as BufferSource,
        rpId: relyingPartyID ?? document.location.hostname,
        userVerification: userVerification ?? "preferred",
        allowCredentials: [
          {
            type: "public-key",
            id: base64UrlToUint8Array(
              Base64Url.orThrow(credentialId),
            ) as BufferSource,
          },
        ],
      },
    };
  };

const deriveEncryptionKey = (seed: Uint8Array): EncryptionKey => {
  const seed32 = seed.length === 32 ? seed : seed.slice(0, 32);
  return EncryptionKey.orThrow(
    createSlip21(seed32 as Entropy32, ["evolu", "auth"]),
  );
};

const encryptAuthResult =
  (deps: SymmetricCryptoDep) =>
  (
    authResult: AuthResult,
    encryptionKey: EncryptionKey,
  ): {
    nonce: Base64Url;
    ciphertext: Base64Url;
  } => {
    const plaintext = utf8ToBytes(JSON.stringify(authResult));
    const { nonce, ciphertext } = deps.symmetricCrypto.encrypt(
      plaintext,
      encryptionKey,
    );
    return {
      nonce: uint8ArrayToBase64Url(nonce),
      ciphertext: uint8ArrayToBase64Url(ciphertext),
    };
  };

const decryptAuthResult =
  (deps: SymmetricCryptoDep) =>
  (
    encryptedData: { nonce: Base64Url; ciphertext: Base64Url },
    encryptionKey: EncryptionKey,
  ): string | null => {
    const nonce = base64UrlToUint8Array(encryptedData.nonce);
    const ciphertext = base64UrlToUint8Array(encryptedData.ciphertext);
    const result = deps.symmetricCrypto.decrypt(
      ciphertext,
      encryptionKey,
      nonce,
    );
    if (!result.ok) return null;
    return bytesToUtf8(result.value);
  };

const generateSeed = (deps: RandomBytesDep) => () => {
  return deps.randomBytes.create(32);
};
