import type { LetterMetadata } from "../api/response";

export interface EncryptedLetter {
  encrypted_content: string;
  encrypted_dek: string;
  sharingKey?: string | null;
}

export interface EncryptedLetterMetadata {
  encrypted_content: string;
  encrypted_dek: string;
  sharingKey?: string | null;
}

export interface EncryptedImageUpload {
  encryptedBlob: Blob;
  encrypted_dek: string;
  filename: string;
}

interface SealedEnvelope {
  encryptedContent: string;
  encrypted_dek: string;
  sharingKey: string;
}

// we use a class here to keep track of instantiations (use 1 and the same DEK per letter content and metadata)
// TODO: try refactoring into a pure function for consistency
export class CryptoUtils {
  private dek!: CryptoKey;
  private static readonly PBKDF2_ITERATIONS =
    Number(import.meta.env.VITE_PBKDF2_ITERATIONS) || 600_000;
  // NOTE: https://www.w3.org/TR/webcrypto/#aes-gcm
  private static readonly AES_ALGO = { name: "AES-GCM", length: 256 };
  private static readonly IV_BYTE_LENGTH = 12;

  // NOTE: this MUST be called once, per letter, for all operations in a session to a fresh Data Encryption Key (DEK)
  async initialize() {
    this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_ALGO, true, [
      "encrypt",
      "decrypt",
    ]);
  }

  private toBase64 = (buffer: Uint8Array): string => {
    // convert buffer to raw string
    let binaryFileString = "";
    for (let i = 0; i < buffer.byteLength; i++) {
      binaryFileString += String.fromCharCode(buffer[i]);
    }
    return btoa(binaryFileString);
  };

  private fromBase64 = (b64String: string): Uint8Array<ArrayBuffer> => {
    const decodedString = atob(b64String);
    const arr = new Uint8Array(decodedString.length);
    for (let i = 0; i < decodedString.length; i++)
      arr[i] = decodedString.charCodeAt(i);
    return arr;
  };

  // Required structure: [12 bytes IV][Cipher text][16 bytes Auth Tag]
  // NOTE: Web Crypto API auto appends the auth tag, so we focus on IV and cipher
  private packWithIv = (iv: Uint8Array, ciphertext: ArrayBuffer): string => {
    // create a buffer large enough to hold both iv and cipher text (12 + x bytes)
    const combinedPayload = new Uint8Array(
      CryptoUtils.IV_BYTE_LENGTH + ciphertext.byteLength,
    );

    // place the iv at the start
    combinedPayload.set(iv, 0);

    // place the ciphertext after the iv
    combinedPayload.set(new Uint8Array(ciphertext), CryptoUtils.IV_BYTE_LENGTH);

    // convert the buffer to Base64 for transit
    return this.toBase64(combinedPayload);
  };

  // For decryption: extracts the IV and the data from the base64 string, easy because we know the size of iv already.
  private unpackWithIv = (
    encodedString: string,
  ): { iv: Uint8Array<ArrayBuffer>; ciphertext: Uint8Array<ArrayBuffer> } => {
    // decode from base64 to array buffer
    const fullBuffer = this.fromBase64(encodedString);

    // extract first 12 bytes for iv
    const iv = fullBuffer.slice(0, CryptoUtils.IV_BYTE_LENGTH);
    // extract rest for cipher text
    const ciphertext = fullBuffer.slice(CryptoUtils.IV_BYTE_LENGTH);

    return { iv: new Uint8Array(iv), ciphertext: new Uint8Array(ciphertext) };
  };

  /**
   * Derive a key bundle (Masterkey + authHash) from email + (plain) password combo
   * WHY?: This is much secure than relying on server to hash and store the password. Also ensures absolute 0 knowledge
   */
  public static async deriveKeyBundle(
    password: string,
    email: string,
  ): Promise<{ masterKey: CryptoKey; authHash: string }> {
    const encoder = new TextEncoder();
    const salt = encoder.encode(email.toLowerCase());

    const baseKey = await crypto.subtle.importKey(
      "raw",
      encoder.encode(password),
      "PBKDF2",
      false,
      ["deriveBits", "deriveKey"],
    );

    const masterSeed = await crypto.subtle.deriveBits(
      {
        name: "PBKDF2",
        salt,
        iterations: CryptoUtils.PBKDF2_ITERATIONS,
        hash: "SHA-256",
      },
      baseKey,
      512,
    );

    // first 256 bits for masterkey, last 256 bits for authHash (password sent in REST)
    const masterKeyBytes = masterSeed.slice(0, 32);
    const authHashBytes = masterSeed.slice(32, 64);

    // Create the masterkey for client-side encryption
    const masterKey = await crypto.subtle.importKey(
      "raw",
      masterKeyBytes,
      CryptoUtils.AES_ALGO,
      false,
      ["encrypt", "decrypt", "wrapKey", "unwrapKey"],
    );

    // convert bytes in to hex string
    let authHash = "";
    const authHashBuffer = new Uint8Array(authHashBytes);

    for (let i = 0; i < authHashBuffer.byteLength; i++) {
      // we force every bytes converted to string to be min 2 chars (otherwise 00 0a will be just a and not "000a")
      authHash += authHashBuffer[i].toString(16).padStart(2, "0");
    }

    return { masterKey, authHash };
  }

  /*
   * Envelope Encryption - Decryption
   * WHY?: for guest access where we don't have to share the masterkey just the dek.
   * This way, raw dek never leaves browser (db stores the encrypted version)
   */

  // encrypt the plaintext with a DEK and then encrypt (wrap) that DEK with the user's masterkey.
  private async sealEnvelope(
    input: Uint8Array,
    masterKey: CryptoKey,
  ): Promise<SealedEnvelope> {
    if (!this.dek) {
      throw new Error("DEK is not available (forgot to .initialize()?)");
    }
    const plainBytes = new Uint8Array(input);
    const contentIv = crypto.getRandomValues(new Uint8Array(12));
    const dekIv = crypto.getRandomValues(new Uint8Array(12));

    const ciphertext = await crypto.subtle.encrypt(
      { name: CryptoUtils.AES_ALGO.name, iv: contentIv },
      this.dek,
      plainBytes,
    );

    // wrap the DEK with the Master Key (for self access)
    const wrappedDek = await crypto.subtle.wrapKey("raw", this.dek, masterKey, {
      name: CryptoUtils.AES_ALGO.name,
      iv: dekIv,
    });

    // export raw DEK for the share URL (recipient access, no master key needed)
    const rawDek = await crypto.subtle.exportKey("raw", this.dek);

    return {
      encryptedContent: this.packWithIv(contentIv, ciphertext),
      encrypted_dek: this.packWithIv(dekIv, wrappedDek),
      sharingKey: this.toBase64(new Uint8Array(rawDek)),
    };
  }

  // Unwrap the DEK with the master key to get the key back. Decrypt the content with the DEK.
  private async openEnvelope(
    encryptedContent: string,
    encrypted_dek: string,
    masterKey: CryptoKey,
  ): Promise<Uint8Array<ArrayBuffer>> {
    const { iv: dekIv, ciphertext: wrappedDek } =
      this.unpackWithIv(encrypted_dek);
    const dek = await crypto.subtle.unwrapKey(
      "raw",
      wrappedDek,
      masterKey,
      { name: CryptoUtils.AES_ALGO.name, iv: dekIv },
      CryptoUtils.AES_ALGO,
      false,
      ["decrypt"],
    );

    const { iv: contentIv, ciphertext } = this.unpackWithIv(encryptedContent);
    const plainBytes = await crypto.subtle.decrypt(
      { name: CryptoUtils.AES_ALGO.name, iv: contentIv },
      dek,
      ciphertext,
    );

    return new Uint8Array(plainBytes);
  }

  private async openEnvelopeWithSharingKey(
    encryptedContent: string,
    sharingKey: string,
  ): Promise<Uint8Array<ArrayBuffer>> {
    const dekBytes = this.fromBase64(sharingKey);
    const dek = await crypto.subtle.importKey(
      "raw",
      dekBytes,
      CryptoUtils.AES_ALGO,
      false,
      ["decrypt"],
    );

    const { iv: contentIv, ciphertext } = this.unpackWithIv(encryptedContent);
    const plainBytes = await crypto.subtle.decrypt(
      { name: CryptoUtils.AES_ALGO.name, iv: contentIv },
      dek,
      ciphertext,
    );

    return new Uint8Array(plainBytes);
  }

  /*
   * Letter functions
   */
  public async encryptLetter(
    plaintext: string,
    masterKey: CryptoKey,
  ): Promise<EncryptedLetter> {
    const { encryptedContent, encrypted_dek, sharingKey } =
      await this.sealEnvelope(new TextEncoder().encode(plaintext), masterKey);

    return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
  }

  public async decryptLetter(
    { encrypted_content, encrypted_dek }: EncryptedLetter,
    masterKey: CryptoKey,
  ): Promise<string> {
    const bytes = await this.openEnvelope(
      encrypted_content,
      encrypted_dek,
      masterKey,
    );

    return new TextDecoder().decode(bytes);
  }

  public async decryptLetterWithSharingKey(
    encrypted_content: string,
    sharingKey: string,
  ): Promise<string> {
    const bytes = await this.openEnvelopeWithSharingKey(
      encrypted_content,
      sharingKey,
    );

    return new TextDecoder().decode(bytes);
  }

  public async encryptMetadata(
    metadata: LetterMetadata,
    masterKey: CryptoKey,
  ): Promise<EncryptedLetterMetadata> {
    const { encryptedContent, encrypted_dek, sharingKey } =
      await this.sealEnvelope(
        new TextEncoder().encode(JSON.stringify(metadata)),
        masterKey,
      );

    return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
  }

  public async decryptMetadata(
    encrypted_metadata: EncryptedLetter,
    masterKey: CryptoKey,
  ): Promise<LetterMetadata> {
    const bytes = await this.openEnvelope(
      encrypted_metadata.encrypted_content,
      encrypted_metadata.encrypted_dek,
      masterKey,
    );

    return JSON.parse(new TextDecoder().decode(bytes));
  }

  public async decryptMetadataWithSharingKey(
    encrypted_content: string,
    sharingKey: string,
  ): Promise<LetterMetadata> {
    const bytes = await this.openEnvelopeWithSharingKey(
      encrypted_content,
      sharingKey,
    );

    return JSON.parse(new TextDecoder().decode(bytes));
  }

  public async encryptImage(
    file: File,
    masterKey: CryptoKey,
  ): Promise<EncryptedImageUpload> {
    const plainBytes = new Uint8Array(await file.arrayBuffer());
    const { encryptedContent, encrypted_dek } = await this.sealEnvelope(
      plainBytes,
      masterKey,
    );

    return {
      encryptedBlob: new Blob([this.fromBase64(encryptedContent)]),
      encrypted_dek,
      filename: `${crypto.randomUUID()}.bin`,
    };
  }

  public async decryptImage(
    encryptedBlob: Blob,
    encrypted_dek: string,
    masterKey: CryptoKey,
  ): Promise<string> {
    const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
    const plainBytes = await this.openEnvelope(
      this.toBase64(encryptedBytes),
      encrypted_dek,
      masterKey,
    );

    return URL.createObjectURL(new Blob([plainBytes]));
  }

  public async decryptImageWithSharingKey(
    encryptedBlob: Blob,
    sharingKey: string,
  ): Promise<string> {
    const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
    const plainBytes = await this.openEnvelopeWithSharingKey(
      this.toBase64(encryptedBytes),
      sharingKey,
    );

    return URL.createObjectURL(new Blob([plainBytes]));
  }

  // derive raw DEK on demand (browser only, not sent to server) for guest access
  public async extractSharingKey(
    encrypted_dek: string,
    masterKey: CryptoKey,
  ): Promise<string> {
    const { iv: dekIv, ciphertext: wrappedDek } =
      this.unpackWithIv(encrypted_dek);
    const rawDek = await crypto.subtle.unwrapKey(
      "raw",
      wrappedDek,
      masterKey,
      { name: CryptoUtils.AES_ALGO.name, iv: dekIv },
      CryptoUtils.AES_ALGO,
      true,
      ["decrypt"],
    );

    return this.toBase64(
      new Uint8Array(await crypto.subtle.exportKey("raw", rawDek)),
    );
  }
}
