/* Uleez — end-to-end encryption primitives (WebCrypto only, no dependencies)
 *
 * This file must stay dependency-free and buildless: `kerya-js-admin` is served as static
 * files with in-browser Babel, so anything requiring npm or WASM is off the table. That
 * rules out Argon2id (WASM only in the browser) and X25519 (not in Safari's WebCrypto),
 * which is why the scheme below is ECDH P-256 + HKDF-SHA256 + AES-256-GCM + PBKDF2.
 *
 * The same scheme is implemented byte-for-byte in `kerya_dz/lib/core/crypto/uleez_crypto.dart`
 * and in `backend/src/common/document-crypto.service.ts`; cross-implementation test vectors
 * live alongside them. Change one, change all three.
 *
 * ── Scheme ───────────────────────────────────────────────────────────────────
 *   identity        ECDH P-256 key pair, one per account
 *   seal(key→peer)  ephemeral ECDH → HKDF-SHA256 → AES-256-GCM  (ECIES-style)
 *   content         AES-256-GCM, 96-bit random IV, 128-bit tag, AAD binds the record
 *   password        PBKDF2-HMAC-SHA256, 650 000 iterations (OWASP 2023)
 *
 * The private key is stored in IndexedDB as a NON-EXTRACTABLE CryptoKey: script running
 * on this origin can use it to decrypt, but cannot read its bytes out of the browser.
 */
(function () {
  const subtle = window.crypto && window.crypto.subtle;
  if (!subtle) {
    // Non-fatal: the app still runs, encrypted conversations simply stay unreadable and
    // say so, rather than the page dying on load.
    console.error('[ULC] WebCrypto indisponible — le chiffrement de bout en bout est désactivé.');
  }

  const KDF_ITERATIONS = 650000;
  const IV_LEN = 12;
  const CURVE = 'P-256';

  /* ── byte helpers ───────────────────────────────────────────────────────── */
  const enc = new TextEncoder();
  const dec = new TextDecoder();

  function toB64(buf) {
    const bytes = new Uint8Array(buf);
    let s = '';
    // Chunked: String.fromCharCode(...bytes) blows the argument limit past ~100 KB,
    // which a photo attachment reaches immediately.
    const CHUNK = 0x8000;
    for (let i = 0; i < bytes.length; i += CHUNK) {
      s += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
    }
    return btoa(s);
  }

  function fromB64(b64) {
    const bin = atob(b64);
    const out = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
    return out;
  }

  function concat(...parts) {
    const total = parts.reduce((n, p) => n + p.length, 0);
    const out = new Uint8Array(total);
    let off = 0;
    for (const p of parts) { out.set(p, off); off += p.length; }
    return out;
  }

  function randomBytes(n) {
    const b = new Uint8Array(n);
    window.crypto.getRandomValues(b);
    return b;
  }

  /* ── AES-256-GCM ────────────────────────────────────────────────────────── */

  async function importAesKey(raw) {
    return subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
  }

  // Returns { iv, ciphertext } — the GCM tag is appended to the ciphertext by WebCrypto,
  // which is also what Node and Dart do, so the three stay wire-compatible.
  async function aesEncrypt(key, plaintext, aad) {
    const iv = randomBytes(IV_LEN);
    const params = { name: 'AES-GCM', iv, tagLength: 128 };
    if (aad) params.additionalData = enc.encode(aad);
    const ct = await subtle.encrypt(params, key, plaintext);
    return { iv, ciphertext: new Uint8Array(ct) };
  }

  async function aesDecrypt(key, iv, ciphertext, aad) {
    const params = { name: 'AES-GCM', iv, tagLength: 128 };
    if (aad) params.additionalData = enc.encode(aad);
    return new Uint8Array(await subtle.decrypt(params, key, ciphertext));
  }

  /* ── identity key pair ──────────────────────────────────────────────────── */

  // `extractable` is true only while enrolling, just long enough to seal the private key
  // under the password. Everywhere else the key is imported non-extractable.
  async function generateIdentity() {
    return subtle.generateKey({ name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);
  }

  async function exportPublicJwk(publicKey) {
    const jwk = await subtle.exportKey('jwk', publicKey);
    // Canonical field order so the same key always serialises identically — the backend
    // compares the stored public key against the posted one to refuse silent rotation.
    return JSON.stringify({ kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, ext: true });
  }

  async function importPublicJwk(jwkString) {
    const jwk = typeof jwkString === 'string' ? JSON.parse(jwkString) : jwkString;
    return subtle.importKey(
      'jwk',
      { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, ext: true },
      { name: 'ECDH', namedCurve: CURVE }, true, [],
    );
  }

  // Private keys are carried as JWK, not PKCS8, because the sealed blob has to be openable
  // by BOTH clients: the Dart `cryptography` package has no PKCS8 codec for EC keys, but
  // both sides speak {kty,crv,x,y,d} natively.
  async function importPrivateJwk(jwkString, extractable) {
    const jwk = typeof jwkString === 'string' ? JSON.parse(jwkString) : jwkString;
    return subtle.importKey(
      'jwk',
      { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, d: jwk.d, ext: true },
      { name: 'ECDH', namedCurve: CURVE }, !!extractable, ['deriveBits'],
    );
  }

  async function exportPrivateJwk(privateKey) {
    const jwk = await subtle.exportKey('jwk', privateKey);
    return JSON.stringify({ kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, d: jwk.d, ext: true });
  }

  /* ── password-derived keys ──────────────────────────────────────────────── */

  /**
   * masterKey = PBKDF2-HMAC-SHA256(password, salt, iterations)
   *
   * Two independent branches are derived from it with HKDF:
   *   'uleez/auth/v1'   → sent to the server in place of the password
   *   'uleez/escrow/v1' → NEVER leaves the device; seals the private key
   *
   * Because the server only ever receives the auth branch, it cannot reconstruct the
   * escrow branch, and therefore cannot open the sealed private key it stores.
   */
  async function deriveMasterKey(password, salt, iterations) {
    const base = await subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']);
    return new Uint8Array(await subtle.deriveBits(
      { name: 'PBKDF2', salt, iterations: iterations || KDF_ITERATIONS, hash: 'SHA-256' },
      base, 256,
    ));
  }

  async function hkdf(ikm, info, salt) {
    const base = await subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
    return new Uint8Array(await subtle.deriveBits(
      { name: 'HKDF', hash: 'SHA-256', salt: salt || new Uint8Array(0), info: enc.encode(info) },
      base, 256,
    ));
  }

  /** Deterministic per-account salt for the login KDF — no pre-login round-trip, and no
   *  endpoint that could be turned into an account-existence oracle. */
  async function authSalt(loginIdentifier) {
    const normalized = String(loginIdentifier || '').trim().replace(/\s+/g, '');
    return new Uint8Array(await subtle.digest('SHA-256', enc.encode('uleez.auth.v1|' + normalized)));
  }

  /** What the client sends in the `password` field once the account is on kdfVersion 1. */
  async function deriveAuthSecret(password, loginIdentifier, iterations) {
    const master = await deriveMasterKey(password, await authSalt(loginIdentifier), iterations);
    return toB64(await hkdf(master, 'uleez/auth/v1'));
  }

  /** The half that never leaves the device. */
  async function deriveEscrowKey(password, loginIdentifier, iterations) {
    return deriveEscrowKeyFromSalt(password, await authSalt(loginIdentifier), iterations);
  }

  /** Même clé, mais à partir du sel RÉELLEMENT utilisé au scellement.
   *
   *  À préférer partout où un `wrapSalt` est stocké côté serveur. `authSalt` dérive du numéro
   *  de téléphone COURANT : si celui-ci change de forme — normalisation E.164, correction
   *  d'un zéro de tête — la clé obtenue n'est plus celle qui a scellé la clé privée, et tout
   *  l'historique chiffré du compte devient illisible sans que rien ne l'explique. Le sel
   *  stocké, lui, ne bouge pas. */
  async function deriveEscrowKeyFromSalt(password, saltBytes, iterations) {
    const master = await deriveMasterKey(password, saltBytes, iterations);
    return importAesKey(await hkdf(master, 'uleez/escrow/v1'));
  }

  /* ── sealing the private key under a password / recovery code ───────────── */

  async function sealPrivateKey(privateKey, wrappingKey, saltB64) {
    const jwk = enc.encode(await exportPrivateJwk(privateKey));
    const { iv, ciphertext } = await aesEncrypt(wrappingKey, jwk, 'uleez/privkey/v1');
    jwk.fill(0);
    return { blob: toB64(concat(iv, ciphertext)), salt: saltB64 };
  }

  /**
   * Unseal a private key.
   *
   * `extractable` defaults to FALSE: the recovered key can decrypt but its bytes can never
   * be read back out by script. Pass true only for the one operation that genuinely needs
   * to re-export it — re-sealing under a new password — and drop the reference right after.
   */
  async function openPrivateKey(blobB64, wrappingKey, extractable) {
    const raw = fromB64(blobB64);
    const jwk = await aesDecrypt(wrappingKey, raw.subarray(0, IV_LEN), raw.subarray(IV_LEN), 'uleez/privkey/v1');
    return importPrivateJwk(dec.decode(jwk), !!extractable);
  }

  /* ── sealing a conversation key to a peer (ECIES over P-256) ────────────── */

  /**
   * Wire format: b64( 2-byte big-endian jwkLen | ephemeralPublicJwk | iv | ciphertext ).
   * A fresh ephemeral key pair per seal means compromising one recipient's long-term key
   * does not retroactively expose keys sealed for other recipients.
   */
  async function sealForPeer(rawKeyBytes, peerPublicJwk) {
    const peer = await importPublicJwk(peerPublicJwk);
    const eph = await subtle.generateKey({ name: 'ECDH', namedCurve: CURVE }, true, ['deriveBits']);
    const shared = new Uint8Array(await subtle.deriveBits({ name: 'ECDH', public: peer }, eph.privateKey, 256));
    const wrapKey = await importAesKey(await hkdf(shared, 'uleez/wrap/v1'));
    const ephJwk = enc.encode(await exportPublicJwk(eph.publicKey));
    const { iv, ciphertext } = await aesEncrypt(wrapKey, rawKeyBytes, 'uleez/wrap/v1');
    const len = new Uint8Array([(ephJwk.length >> 8) & 0xff, ephJwk.length & 0xff]);
    return toB64(concat(len, ephJwk, iv, ciphertext));
  }

  async function openFromPeer(sealedB64, myPrivateKey) {
    const raw = fromB64(sealedB64);
    const jwkLen = (raw[0] << 8) | raw[1];
    const ephJwk = dec.decode(raw.subarray(2, 2 + jwkLen));
    const rest = raw.subarray(2 + jwkLen);
    const eph = await importPublicJwk(ephJwk);
    const shared = new Uint8Array(await subtle.deriveBits({ name: 'ECDH', public: eph }, myPrivateKey, 256));
    const wrapKey = await importAesKey(await hkdf(shared, 'uleez/wrap/v1'));
    return aesDecrypt(wrapKey, rest.subarray(0, IV_LEN), rest.subarray(IV_LEN), 'uleez/wrap/v1');
  }

  /* ── message payloads ───────────────────────────────────────────────────── */

  /**
   * A message payload is JSON, so everything sensitive travels inside the sealed blob:
   * body text, shared coordinates, and the blurred attachment thumbnail. Those used to be
   * plaintext columns the server could read — `latitude`/`longitude` and `attachment_thumb`
   * stay NULL for encrypted messages precisely so that stops being true.
   *
   * AAD = "<ticketId>|<encVersion>" binds the ciphertext to its conversation: a message
   * lifted from one thread and replayed into another fails to authenticate.
   */
  async function encryptMessage(conversationKey, ticketId, payload) {
    const plain = enc.encode(JSON.stringify(payload));
    const { iv, ciphertext } = await aesEncrypt(conversationKey, plain, ticketId + '|1');
    return { body: toB64(ciphertext), encIv: toB64(iv), encVersion: 1 };
  }

  async function decryptMessage(conversationKey, ticketId, bodyB64, ivB64) {
    const plain = await aesDecrypt(conversationKey, fromB64(ivB64), fromB64(bodyB64), ticketId + '|1');
    return JSON.parse(dec.decode(plain));
  }

  async function encryptBytes(conversationKey, ticketId, bytes) {
    const { iv, ciphertext } = await aesEncrypt(conversationKey, bytes, ticketId + '|blob');
    return concat(iv, ciphertext);
  }

  async function decryptBytes(conversationKey, ticketId, blob) {
    const b = new Uint8Array(blob);
    return aesDecrypt(conversationKey, b.subarray(0, IV_LEN), b.subarray(IV_LEN), ticketId + '|blob');
  }

  async function newConversationKey() {
    return randomBytes(32);
  }

  /* ── recovery code ──────────────────────────────────────────────────────── */

  // Crockford base32 without I/L/O/U: no character pairs a user can misread or mistype,
  // and no accidental words. 20 symbols ≈ 100 bits of entropy.
  const RECOVERY_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';

  function generateRecoveryCode() {
    const bytes = randomBytes(20);
    let out = '';
    for (let i = 0; i < 20; i++) {
      if (i > 0 && i % 5 === 0) out += '-';
      out += RECOVERY_ALPHABET[bytes[i] % RECOVERY_ALPHABET.length];
    }
    return out;
  }

  function normalizeRecoveryCode(code) {
    return String(code || '').toUpperCase().replace(/[^0-9A-Z]/g, '')
      .replace(/I/g, '1').replace(/L/g, '1').replace(/O/g, '0');
  }

  async function deriveRecoveryKey(code, saltBytes) {
    const master = await deriveMasterKey(normalizeRecoveryCode(code), saltBytes, KDF_ITERATIONS);
    return importAesKey(await hkdf(master, 'uleez/recovery/v1'));
  }

  /* ── private key at rest, in IndexedDB ──────────────────────────────────── */

  const DB_NAME = 'uleez-e2e';
  const STORE = 'keys';

  function openDb() {
    return new Promise((resolve, reject) => {
      const req = indexedDB.open(DB_NAME, 1);
      req.onupgradeneeded = () => {
        if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE);
      };
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
  }

  function idbOp(mode, fn) {
    return openDb().then(db => new Promise((resolve, reject) => {
      const tx = db.transaction(STORE, mode);
      const req = fn(tx.objectStore(STORE));
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
      tx.oncomplete = () => db.close();
    }));
  }

  // Scoped by user id: a shared computer must never hand one account's key to the next.
  const keyRecord = (userId) => `privkey:${userId}`;

  // A CryptoKey survives structured cloning into IndexedDB WITHOUT its bytes ever being
  // exposed to script — that is why the key is stored as an object, not as base64.
  const storePrivateKey = (userId, key) => idbOp('readwrite', s => s.put(key, keyRecord(userId)));
  const loadPrivateKey = (userId) => idbOp('readonly', s => s.get(keyRecord(userId)));
  const forgetPrivateKey = (userId) => idbOp('readwrite', s => s.delete(keyRecord(userId)));
  const forgetAllKeys = () => idbOp('readwrite', s => s.clear());

  window.ULC = {
    available: !!subtle,
    KDF_ITERATIONS,
    // bytes
    toB64, fromB64, concat, randomBytes,
    // identity
    generateIdentity, exportPublicJwk, importPublicJwk, exportPrivateJwk, importPrivateJwk,
    // password-derived material
    deriveAuthSecret, deriveEscrowKey, deriveEscrowKeyFromSalt, authSalt,
    sealPrivateKey, openPrivateKey,
    // recovery
    generateRecoveryCode, normalizeRecoveryCode, deriveRecoveryKey,
    // conversation keys
    newConversationKey, sealForPeer, openFromPeer, importAesKey,
    // messages + attachments
    encryptMessage, decryptMessage, encryptBytes, decryptBytes,
    // storage
    storePrivateKey, loadPrivateKey, forgetPrivateKey, forgetAllKeys,
  };
})();
