/* Uleez — end-to-end encryption session layer (web)
 *
 * `crypto.jsx` (window.ULC) holds the primitives. THIS file holds the policy: when a key is
 * created, how it is unsealed, how a conversation key is obtained and cached, and what a
 * screen has to call to send or read an encrypted message.
 *
 * Everything a chat screen needs is four calls:
 *   E2E.ready()                              → is encryption usable in this session?
 *   E2E.encryptOutgoing(ticketId, payload)   → { body, encIv, encVersion } to POST
 *   E2E.decryptIncoming(ticketId, message)   → message with a readable `text`/`location`
 *   E2E.grantsForNewConversation(userIds)    → sealed key copies to send with a new thread
 *
 * ── Threat model, stated plainly ────────────────────────────────────────────
 * The server stores ciphertext and sealed keys it cannot open. It CAN still see who talks
 * to whom, when, and how large each message is: metadata is not encrypted. The support
 * desk holds a key copy for every thread (moderation and dispute handling), and each staff
 * decryption is written to the audit log. Read the privacy policy alongside this file.
 */
(function () {
  const state = {
    userId: null,
    privateKey: null,        // non-extractable CryptoKey, or null while locked
    publicJwk: null,
    deskPrivateKey: null,    // staff only, unsealed with the desk passphrase
    conversationKeys: new Map(), // ticketId → AES CryptoKey
    lastError: null,
  };

  const ready = () => !!(window.ULC && ULC.available && state.privateKey);
  const isStaffUnlocked = () => !!state.deskPrivateKey;

  /* ── enrolment / unlock ─────────────────────────────────────────────────── */

  /**
   * Bring this browser into the encrypted world for `userId`.
   *
   * Three cases, in order:
   *   1. the key is already in IndexedDB (normal return visit) → use it;
   *   2. the account has a key on the server → unseal it with the password-derived key;
   *   3. the account has none → generate one, seal it under the password AND under a
   *      one-time recovery code, publish both sealed copies.
   *
   * Returns { recoveryCode } only in case 3 — that string is shown ONCE and never stored.
   */
  async function unlock({ userId, phone, password }) {
    if (!window.ULC || !ULC.available) {
      state.lastError = 'WebCrypto indisponible';
      return { ok: false, reason: state.lastError };
    }
    state.userId = userId;

    const cached = await ULC.loadPrivateKey(userId).catch(() => null);
    let remote = null;
    try { remote = await API.keys.mine(); } catch (_) { remote = null; }

    if (cached && remote) {
      state.privateKey = cached;
      state.publicJwk = remote.publicKeyJwk;
      return { ok: true };
    }

    if (remote) {
      // Le sel STOCKÉ au scellement, jamais un sel re-dérivé du numéro courant.
      //
      // `authSalt` est un SHA-256 du numéro de téléphone. Le numéro peut avoir changé de
      // forme depuis l'enrôlement — normalisation E.164 en V0.9.0, suppression d'un zéro de
      // tête — et la clé obtenue n'ouvre alors plus rien : tout l'historique chiffré du
      // compte devient « indisponible sur cet appareil », sans erreur ni explication. Le
      // `wrapSalt` renvoyé par le serveur est celui qui a réellement servi, et il ne bouge
      // pas. Repli sur le numéro pour les enrôlements antérieurs à sa publication.
      const escrowSalt = remote.wrapSalt
        ? ULC.fromB64(remote.wrapSalt)
        : await ULC.authSalt(phone);
      const escrow = await ULC.deriveEscrowKeyFromSalt(password, escrowSalt, remote.kdfIterations);
      let priv;
      try {
        priv = await ULC.openPrivateKey(remote.wrappedPrivateKey, escrow);
      } catch (_) {
        // The sealed key predates the current password (it was re-set without the recovery
        // code, so nothing could re-seal it). Say so instead of silently starting over —
        // generating a fresh key here would orphan every past conversation.
        state.lastError = 'CLÉ_ILLISIBLE';
        return { ok: false, reason: state.lastError };
      }
      state.privateKey = priv;
      state.publicJwk = remote.publicKeyJwk;
      await ULC.storePrivateKey(userId, priv).catch(() => {});
      return { ok: true };
    }

    // First enrolment.
    const pair = await ULC.generateIdentity();
    const publicJwk = await ULC.exportPublicJwk(pair.publicKey);
    const escrow = await ULC.deriveEscrowKey(password, phone, ULC.KDF_ITERATIONS);
    const sealed = await ULC.sealPrivateKey(pair.privateKey, escrow, '');

    const recoveryCode = ULC.generateRecoveryCode();
    const recoverySalt = ULC.randomBytes(16);
    const recoveryKey = await ULC.deriveRecoveryKey(recoveryCode, recoverySalt);
    const sealedRecovery = await ULC.sealPrivateKey(pair.privateKey, recoveryKey, '');

    await API.keys.publish({
      publicKeyJwk: publicJwk,
      wrappedPrivateKey: sealed.blob,
      wrapSalt: ULC.toB64(await ULC.authSalt(phone)),
      kdfIterations: ULC.KDF_ITERATIONS,
      recoveryWrappedPrivateKey: sealedRecovery.blob,
      recoverySalt: ULC.toB64(recoverySalt),
    });

    // Re-import non-extractable before keeping it: the generated pair is extractable (it
    // had to be, to be sealed), and nothing should hold an extractable copy afterwards.
    const priv = await ULC.importPrivateJwk(await ULC.exportPrivateJwk(pair.privateKey), false);
    state.privateKey = priv;
    state.publicJwk = publicJwk;
    await ULC.storePrivateKey(userId, priv).catch(() => {});
    return { ok: true, recoveryCode };
  }

  /**
   * Remettre le chiffrement en service pour une session RESTAURÉE, sans mot de passe.
   *
   * `unlock` n'était appelé que par le formulaire de connexion. Un simple rafraîchissement
   * de page — ou tout retour avec un jeton encore valide — laissait donc `state.privateKey`
   * vide : chaque message chiffré s'affichait « indisponible sur cet appareil », et il
   * fallait se déconnecter puis se reconnecter pour lire quoi que ce soit. L'envoi tombait
   * de la même façon en clair, faute de clé de conversation.
   *
   * Aucun mot de passe n'est disponible ici, et c'est sans conséquence : la clé privée est
   * conservée dans IndexedDB à l'enrôlement comme à chaque ouverture. On ne touche donc
   * JAMAIS au chemin dérivé du mot de passe — s'il n'y a pas de clé locale, on renvoie
   * simplement `false` et l'appelant demandera une vraie connexion.
   */
  async function resume(userId) {
    if (!window.ULC || !ULC.available || !userId) return false;
    if (state.privateKey && state.userId === userId) return true;
    state.userId = userId;
    const cached = await ULC.loadPrivateKey(userId).catch(() => null);
    if (!cached) return false;
    let remote = null;
    try { remote = await API.keys.mine(); } catch (_) { remote = null; }
    if (!remote) return false;
    state.privateKey = cached;
    state.publicJwk = remote.publicKeyJwk;
    return true;
  }

  /** Last-resort path when the password no longer opens the sealed key. */
  async function unlockWithRecoveryCode(code) {
    const remote = await API.keys.mine();
    if (!remote?.recoveryWrappedPrivateKey) throw new Error('Aucun code de récupération enregistré');
    const key = await ULC.deriveRecoveryKey(code, ULC.fromB64(remote.recoverySalt));
    const priv = await ULC.openPrivateKey(remote.recoveryWrappedPrivateKey, key);
    state.privateKey = priv;
    state.publicJwk = remote.publicKeyJwk;
    if (state.userId) await ULC.storePrivateKey(state.userId, priv).catch(() => {});
    return true;
  }

  /**
   * Re-seal the private key under a new password. Call right after a successful change.
   *
   * Needs the OLD password: the key in memory is non-extractable by design, so it cannot be
   * re-exported. The sealed blob is opened afresh (extractable, briefly), re-sealed under
   * the new password, and the extractable handle is dropped immediately.
   *
   * Skipping this step is what makes a password change lose every past conversation, so
   * the caller must treat a failure here as a hard error, not a warning.
   */
  async function resealUnderPassword(phone, oldPassword, newPassword) {
    const remote = await API.keys.mine();
    if (!remote) return null; // nothing enrolled yet — nothing to re-seal
    // Sel stocké pour OUVRIR (cf. `unlock`) — sans quoi un changement de mot de passe échoue
    // sur les comptes dont le numéro a changé de forme, et le message d'erreur accuse à tort
    // l'ancien mot de passe.
    const oldSalt = remote.wrapSalt ? ULC.fromB64(remote.wrapSalt) : await ULC.authSalt(phone);
    const oldEscrow = await ULC.deriveEscrowKeyFromSalt(oldPassword, oldSalt, remote.kdfIterations);
    const extractable = await ULC.openPrivateKey(remote.wrappedPrivateKey, oldEscrow, true);
    const newEscrow = await ULC.deriveEscrowKey(newPassword, phone, ULC.KDF_ITERATIONS);
    const sealed = await ULC.sealPrivateKey(extractable, newEscrow, '');
    // Renvoyé, PAS publié : le blob voyage avec POST /auth/change-password, qui écrit le hachage
    // et la clé dans la même transaction. Un PUT /keys/me séparé pouvait laisser la clé scellée
    // sous un mot de passe que le serveur ignorait (« clé illisible » au login suivant).
    return {
      wrappedPrivateKey: sealed.blob,
      wrapSalt: ULC.toB64(await ULC.authSalt(phone)),
      kdfIterations: ULC.KDF_ITERATIONS,
    };
  }

  /**
   * Re-sceller la clé privée lors d'une RÉINITIALISATION, en l'ouvrant avec le CODE DE
   * RÉCUPÉRATION. Contrepartie web de `resealForPasswordReset` (mobile).
   *
   * La page de reset n'est pas authentifiée : le matériel de clé vient de
   * `checkResetToken`. On ouvre la copie de récupération (extractible, brièvement), on
   * re-scelle sous le nouveau mot de passe avec le sel PERSISTÉ, et on renvoie le blob à
   * joindre à `POST /auth/reset-password`. Lève si le code est faux.
   */
  async function resealForReset({ recoveryCode, newPassword, recoveryWrappedPrivateKey, recoverySalt, wrapSalt, kdfIterations }) {
    const recKey = await ULC.deriveRecoveryKey(recoveryCode, ULC.fromB64(recoverySalt));
    const extractable = await ULC.openPrivateKey(recoveryWrappedPrivateKey, recKey, true);
    const newEscrow = await ULC.deriveEscrowKeyFromSalt(newPassword, ULC.fromB64(wrapSalt), kdfIterations);
    const sealed = await ULC.sealPrivateKey(extractable, newEscrow, '');
    return { wrappedPrivateKey: sealed.blob, wrapSalt, kdfIterations };
  }

  /**
   * Établir une identité E2E NEUVE lors d'un reset SANS code de récupération. Contrepartie web
   * de `buildFreshIdentityForReset` (mobile).
   *
   * Sans le code, l'ancienne clé est inouvrable : on en génère une neuve, publiée par le serveur
   * dans la transaction de reset (rotation sûre, cf. backend). SEL ALÉATOIRE stocké dans
   * `wrapSalt` — la page, ouverte depuis un lien e-mail, ne connaît pas le numéro. L'ancien
   * historique chiffré est perdu ; un nouveau code de récupération est renvoyé (montré une fois).
   */
  async function buildFreshIdentityForReset(newPassword) {
    const pair = await ULC.generateIdentity();
    const publicJwk = await ULC.exportPublicJwk(pair.publicKey);
    const wrapSalt = ULC.randomBytes(16);
    const escrow = await ULC.deriveEscrowKeyFromSalt(newPassword, wrapSalt, ULC.KDF_ITERATIONS);
    const sealed = await ULC.sealPrivateKey(pair.privateKey, escrow, '');
    const recoveryCode = ULC.generateRecoveryCode();
    const recoverySalt = ULC.randomBytes(16);
    const recoveryKey = await ULC.deriveRecoveryKey(recoveryCode, recoverySalt);
    const sealedRecovery = await ULC.sealPrivateKey(pair.privateKey, recoveryKey, '');
    return {
      payload: {
        publicKeyJwk: publicJwk,
        wrappedPrivateKey: sealed.blob,
        wrapSalt: ULC.toB64(wrapSalt),
        kdfIterations: ULC.KDF_ITERATIONS,
        recoveryWrappedPrivateKey: sealedRecovery.blob,
        recoverySalt: ULC.toB64(recoverySalt),
      },
      recoveryCode,
    };
  }

  /* ── support desk ───────────────────────────────────────────────────────── */

  // The desk key is shared by the whole staff, so its KDF salt is a fixed label rather than
  // a per-account identifier — every agent must derive the same wrapping key from the same
  // passphrase.
  const DESK_SALT_LABEL = 'uleez-support-desk';

  async function unlockSupportDesk(passphrase) {
    const { wrappedPrivateKey } = await API.keys.supportDeskWrapped();
    const { publicKeyJwk } = await API.keys.supportDeskPublic();
    const key = await ULC.deriveEscrowKey(passphrase, DESK_SALT_LABEL, ULC.KDF_ITERATIONS);
    state.deskPrivateKey = await ULC.openPrivateKey(wrappedPrivateKey, key);
    state.deskPublicJwk = publicKeyJwk;
    return true;
  }

  async function enrolSupportDesk(passphrase) {
    const pair = await ULC.generateIdentity();
    const publicJwk = await ULC.exportPublicJwk(pair.publicKey);
    const key = await ULC.deriveEscrowKey(passphrase, DESK_SALT_LABEL, ULC.KDF_ITERATIONS);
    const sealed = await ULC.sealPrivateKey(pair.privateKey, key, '');
    await API.keys.enrolSupportDesk({ publicKeyJwk: publicJwk, wrappedPrivateKey: sealed.blob });
    state.deskPrivateKey = await ULC.importPrivateJwk(await ULC.exportPrivateJwk(pair.privateKey), false);
    state.deskPublicJwk = publicJwk;
    return true;
  }

  /* ── conversation keys ──────────────────────────────────────────────────── */

  /**
   * The AES key of one conversation, unsealed with whichever grant applies to us: our own
   * personal copy, or — for staff — the support-desk copy.
   *
   * Cached per ticket for the session. A miss returns null rather than throwing, so a
   * thread we genuinely cannot read degrades to "message chiffré" instead of a broken page.
   */
  async function conversationKey(ticketId) {
    if (state.conversationKeys.has(ticketId)) return state.conversationKeys.get(ticketId);
    let grants;
    try { grants = await API.keys.forConversation(ticketId); } catch (_) { return null; }
    if (!grants || !grants.length) return null;

    const mine = grants.find(g => g.recipientKind === 'user');
    const desk = grants.find(g => g.recipientKind === 'support_desk');

    let raw = null;
    if (mine && state.privateKey) {
      try { raw = await ULC.openFromPeer(mine.wrappedKey, state.privateKey); } catch (_) {}
    }
    if (!raw && desk && state.deskPrivateKey) {
      try { raw = await ULC.openFromPeer(desk.wrappedKey, state.deskPrivateKey); } catch (_) {}
    }
    if (!raw) return null;

    const key = await ULC.importAesKey(raw);
    state.conversationKeys.set(ticketId, key);
    return key;
  }

  /**
   * Seal a brand-new conversation key for each participant plus the support desk.
   *
   * The desk copy is written at creation ON PURPOSE: an admin joining a dispute weeks later
   * would otherwise need a participant to be online to grant them access, which never
   * happens in practice. This is a deliberate, documented reduction in confidentiality.
   */
  async function grantsForNewConversation(participantUserIds) {
    if (!ready()) return null;
    const raw = await ULC.newConversationKey();
    const grants = [];

    for (const uid of participantUserIds.filter(Boolean)) {
      try {
        const { publicKeyJwk } = await API.keys.publicOf(uid);
        grants.push({
          recipientKind: 'user',
          recipientUserId: uid,
          wrappedKey: await ULC.sealForPeer(raw, publicKeyJwk),
        });
      } catch (_) {
        // A participant who has never opened an encrypting client has no public key yet.
        // Refuse rather than silently fall back to plaintext — see the caller.
        return null;
      }
    }

    try {
      const { publicKeyJwk } = await API.keys.supportDeskPublic();
      grants.push({
        recipientKind: 'support_desk',
        wrappedKey: await ULC.sealForPeer(raw, publicKeyJwk),
      });
    } catch (_) {
      return null; // no desk key enrolled → moderation would be impossible, so don't encrypt
    }

    return { grants, rawKey: raw };
  }

  /** Remember a key we just minted, so the first message can be encrypted immediately. */
  async function rememberConversationKey(ticketId, rawKey) {
    state.conversationKeys.set(ticketId, await ULC.importAesKey(rawKey));
  }

  /* ── messages ───────────────────────────────────────────────────────────── */

  async function encryptOutgoing(ticketId, payload) {
    const key = await conversationKey(ticketId);
    if (!key) return null;
    return ULC.encryptMessage(key, ticketId, payload);
  }

  /**
   * Turn a wire message into a readable one.
   *
   * encVersion 0 is a legacy plaintext message and passes through untouched, flagged so the
   * UI can badge it. Anything encrypted we cannot open becomes `undecryptable`, which the
   * UI shows as a locked bubble — never as an empty one.
   */
  async function decryptIncoming(ticketId, m) {
    if (!m) return m;
    // `ticketId` is stamped onto the attachment because the bytes are sealed under the
    // CONVERSATION key: whoever downloads them later needs to know which key to use, and
    // the download path only identifies the message.
    const withTicket = (att) => (att ? { ...att, ticketId } : null);

    if ((m.encVersion ?? 0) === 0) {
      return { ...m, attachment: withTicket(m.attachment), legacyPlaintext: true };
    }
    // Message chiffré SANS corps à ouvrir : une pièce jointe sans légende, ou une ligne
    // écrite avant que l'IV de la légende ne soit transmis. Il n'y a rien à déchiffrer, donc
    // rien à signaler — annoncer « indisponible sur cet appareil » sous une image qui
    // s'affiche parfaitement ne décrit pas la réalité et inquiète pour rien.
    if (m.attachment && (!m.encIv || !m.body)) {
      return { ...m, body: '', attachment: withTicket(m.attachment) };
    }
    const key = await conversationKey(ticketId);
    if (!key) return { ...m, body: '', attachment: withTicket(m.attachment), undecryptable: true };
    try {
      const payload = await ULC.decryptMessage(key, ticketId, m.body, m.encIv);
      const att = withTicket(m.attachment);
      return {
        ...m,
        body: payload.text || '',
        location: payload.location || null,
        attachment: att
          ? {
              ...att,
              name: payload.attachmentName || att.name,
              thumb: payload.thumb,
              // Album position/size come from the SEALED payload when the sender put them
              // there: the plaintext columns are outside the AAD, so a database-level
              // adversary could otherwise reorder or truncate an album undetected.
              groupIndex: payload.albumIndex != null ? payload.albumIndex : att.groupIndex,
              groupCount: payload.albumCount != null ? payload.albumCount : att.groupCount,
            }
          : null,
      };
    } catch (_) {
      return { ...m, body: '', attachment: withTicket(m.attachment), undecryptable: true };
    }
  }

  async function decryptThread(ticketId, messages) {
    const out = [];
    for (const m of messages || []) out.push(await decryptIncoming(ticketId, m));
    return out;
  }

  /* ── attachments ────────────────────────────────────────────────────────── */

  async function encryptAttachment(ticketId, bytes) {
    const key = await conversationKey(ticketId);
    if (!key) return null;
    return ULC.encryptBytes(key, ticketId, bytes);
  }

  async function decryptAttachment(ticketId, blob) {
    const key = await conversationKey(ticketId);
    if (!key) throw new Error('Conversation illisible');
    return ULC.decryptBytes(key, ticketId, new Uint8Array(blob));
  }

  /**
   * Re-check a decrypted attachment's real type before anything renders it.
   *
   * This is the compensating control for the server no longer being able to validate magic
   * bytes on ciphertext. Mirrors backend/src/common/file-validation.util.ts.
   */
  const MAGIC = [
    { mime: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
    { mime: 'image/png',  bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
    { mime: 'application/pdf', bytes: [0x25, 0x50, 0x44, 0x46] },
  ];
  function sniffMime(bytes) {
    const b = new Uint8Array(bytes);
    for (const sig of MAGIC) {
      if (sig.bytes.every((v, i) => b[i] === v)) return sig.mime;
    }
    if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46
        && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return 'image/webp';
    if (b[4] === 0x66 && b[5] === 0x74 && b[6] === 0x79 && b[7] === 0x70) return 'audio/mp4';
    if (b[0] === 0x4f && b[1] === 0x67 && b[2] === 0x67 && b[3] === 0x53) return 'audio/ogg';
    if (b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) return 'audio/mpeg';
    return null;
  }

  /* ── session teardown ───────────────────────────────────────────────────── */

  async function forget() {
    const uid = state.userId;
    state.privateKey = null;
    state.deskPrivateKey = null;
    state.publicJwk = null;
    state.conversationKeys.clear();
    state.userId = null;
    // Drop the key from IndexedDB too: on a shared computer the next person to sign in
    // must not inherit the previous account's ability to read conversations.
    if (uid) await ULC.forgetPrivateKey(uid).catch(() => {});
  }

  window.E2E = {
    ready, isStaffUnlocked,
    unlock, resume, unlockWithRecoveryCode, resealUnderPassword,
    resealForReset, buildFreshIdentityForReset,
    unlockSupportDesk, enrolSupportDesk,
    conversationKey, grantsForNewConversation, rememberConversationKey,
    encryptOutgoing, decryptIncoming, decryptThread,
    encryptAttachment, decryptAttachment, sniffMime,
    forget,
    get lastError() { return state.lastError; },
  };
})();
