// LiveKit E2EE manager — client-side ECDH key exchange extracted from livekitSession.ts.
// Owns the E2EE state (ephemeral keypair, room key, peer keys, rotation timers)
// and the key-exchange protocol: identity signing / TOFU pin verification (F3),
// announce/offer handling, and room-key generation/rotation.
import { ExternalE2EEKeyProvider } from "livekit-client";
import type { WsClient } from "@lib/ws";
import {
  generateECDHKeyPair,
  exportPublicKey,
  importPublicKey,
  generateRoomKey,
  roomKeyToBase64,
  wrapRoomKey,
  unwrapRoomKey,
  signEphemeralKey,
  verifyEphemeralKeySignature,
  importIdentityPublicKey,
  computeKeyFingerprint,
} from "@lib/e2eeCrypto";
import { getOrCreateIdentityKeyPair, getIdentityPin, storeIdentityPin } from "@lib/identity";
import { authStore } from "@stores/auth.store";
import { membersStore } from "@stores/members.store";
import {
  voiceStore,
  setPeerVerification,
  clearPeerVerification,
  clearPeerVerifications,
} from "@stores/voice.store";
import { createLogger } from "@lib/logger";

const log = createLogger("livekitE2EE");

// --- Dependencies passed from LiveKitSession ---

export interface E2EEDeps {
  getWs: () => WsClient | null;
  getServerHost: () => string | null;
  getCurrentChannelId: () => number | null;
}

// --- E2EEManager class ---

export class E2EEManager {
  /** E2EE key provider — shared across Room instances. The room key is generated
   *  and exchanged client-side via ECDH; the server never sees it. */
  readonly keyProvider = new ExternalE2EEKeyProvider();

  // ── Client-side E2EE state (ECDH key exchange) ───────────────────────────
  /** Ephemeral ECDH P-256 keypair for the current voice session. */
  private _ecdhKeyPair: CryptoKeyPair | null = null;
  /** The 256-bit symmetric room key (plaintext). Only held by the key holder
   *  initially; other participants receive it via ECDH-wrapped offers. */
  private _roomKey: Uint8Array | null = null;
  /** Peer ECDH public keys indexed by userId. */
  private _peerPublicKeys: Map<number, CryptoKey> = new Map();
  /** This client's long-term ECDSA identity keypair (F3 TOFU), used to sign our
   *  ephemeral announces. Loaded lazily from the OS keyring, cached per session. */
  private _identityKeyPair: CryptoKeyPair | null = null;
  /** True if this client is the key holder (longest-present participant). */
  private _isKeyHolder = false;
  /** Channel this exchange runs in, set at setupKeyExchange entry. The session
   *  facade publishes its channel id only once "connected", which is after the
   *  whole key-exchange wait — key-holder re-elections arriving in that window
   *  must not be dropped for lack of a channel id. */
  private _channelId: number | null = null;
  /** Resolver/rejector for non-key-holders waiting to receive the room key via offer. */
  private _roomKeyResolver: (() => void) | null = null;
  private _roomKeyRejector: ((err: Error) => void) | null = null;
  /** Guard: true while a key rotation is in progress (prevents concurrent rotations). */
  private _rotatingKey = false;
  /** Set when a keyed-peer leave coincides with an in-flight rotation: the rekey
   *  is deferred (not dropped) and re-run when the current rotation finishes, so
   *  a member that left mid-rotation is excluded from the fresh room key. */
  private _rotationPending = false;
  /** Monotonic counter incremented on every key rotation. handleOffer captures the
   *  epoch before async work and discards the result if epoch changed (stale offer). */
  private _e2eeEpoch = 0;
  /** Announces that arrived before our ECDH keypair was ready. Drained after keypair init. */
  private _pendingAnnounces: Array<{
    userId: number;
    publicKeyBase64: string;
    signatureBase64?: string;
  }> = [];
  /** Periodic key rotation timer — fires every KEY_ROTATION_INTERVAL_MS when key holder. */
  private _keyRotationTimer: ReturnType<typeof setTimeout> | null = null;
  /** Interval between periodic key rotations (5 minutes). */
  private static readonly KEY_ROTATION_INTERVAL_MS = 5 * 60 * 1000;
  /** Bumped every time clearState() tears down a session. An in-flight
   *  setupKeyExchange/reannounceForReconnect captures this before its first
   *  await and re-checks it before publishing to this._ecdhKeyPair — a plain
   *  `this._ecdhKeyPair === null` check can't see a teardown-then-restart
   *  that happens entirely during those awaits, since nothing is null by the
   *  time the abandoned attempt resumes. */
  private _sessionGeneration = 0;

  constructor(private deps: E2EEDeps) {}

  // --- Internal state accessors (used by LiveKitSession's test-compat proxies) ---

  get peerPublicKeys(): Map<number, CryptoKey> {
    return this._peerPublicKeys;
  }
  get epoch(): number {
    return this._e2eeEpoch;
  }
  get rotatingKey(): boolean {
    return this._rotatingKey;
  }
  set rotatingKey(value: boolean) {
    this._rotatingKey = value;
  }
  get rotationPending(): boolean {
    return this._rotationPending;
  }
  set rotationPending(value: boolean) {
    this._rotationPending = value;
  }
  get pendingAnnounces(): Array<{
    userId: number;
    publicKeyBase64: string;
    signatureBase64?: string;
  }> {
    return this._pendingAnnounces;
  }

  // ── Join-time key exchange ───────────────────────────────────────────────

  /**
   * Run the client-side E2EE key exchange for a join (called from
   * connectAndSetup before room.connect). Generates a fresh ECDH keypair,
   * drains queued announces, then either generates the room key (key holder)
   * or announces and waits for the key holder's offer.
   *
   * Returns false when the key exchange timed out after retry — the caller
   * surfaces the "e2ee_timeout" error and leaves voice.
   */
  async setupKeyExchange(isKeyHolder: boolean, channelId: number): Promise<boolean> {
    // Captured before any await so a clearState() that lands anywhere below
    // (before we publish this._ecdhKeyPair) can be detected even though
    // nothing about our local state is null yet — see the field comment.
    const myGeneration = this._sessionGeneration;
    this._channelId = channelId;
    // Generate a fresh ECDH keypair for this session, but keep it local and
    // do NOT publish it to this._ecdhKeyPair until right before the drain
    // below (after _isKeyHolder/_roomKey are ready). Until then,
    // handleAnnounce's `!this._ecdhKeyPair` guard queues any announce that
    // arrives concurrently instead of running it through the live path —
    // where it would be stored in _peerPublicKeys but sent no offer (isKeyHolder
    // /roomKey not set up yet) and then never seen by the drain either (it was
    // never queued), stranding that peer until the next 5-minute rotation.
    const ecdhKeyPair = await generateECDHKeyPair();
    // Superseded already? Everything below this point mutates state a newer
    // session owns, so bail before the first write — clearing the live
    // session's peer keys/verifications would drop every subsequent rotation
    // for those peers (handleOffer's unknown-peer guard).
    if (this._sessionGeneration !== myGeneration) {
      log.warn("E2EE: setup superseded during keypair generation — aborting", { channelId });
      return false;
    }
    this._peerPublicKeys.clear();
    clearPeerVerifications();
    const myPubKeyBase64 = await exportPublicKey(ecdhKeyPair.publicKey);
    // Build the signed announce up front — this loads the identity key from
    // the keyring once, so the added identity round-trip does NOT stack on
    // the non-key-holder's 10s key-exchange stall below (F3).
    const announcePayload = await this.buildAnnouncePayload(myPubKeyBase64);

    // Same check again after the keyring round trip — the widest window of
    // the three, and the next statements install OUR role and room key over
    // whatever session is live now: a superseded non-holder attempt would
    // clear the live holder's _isKeyHolder (silently stopping its rotations
    // and its offers to new peers), and a superseded holder attempt would
    // push a room key nobody else has onto the shared key provider.
    if (this._sessionGeneration !== myGeneration) {
      log.warn("E2EE: setup superseded before key-holder setup — aborting", { channelId });
      return false;
    }

    // Use server-authoritative is_key_holder from voice_token payload — OR'd
    // with whatever this._isKeyHolder already is. The server value was
    // captured when we started joining and cannot see a handleParticipantLeft
    // promotion that landed during the awaits above: the generation check
    // just above proves no clearState() ran since myGeneration was captured,
    // so the only other writer of this field for THIS generation is that
    // promotion — unconditionally overwriting it with the stale server value
    // strands the newly-elected holder waiting for an offer nobody (least of
    // all itself) will ever send, timing out and ejecting it from voice.
    this._isKeyHolder = isKeyHolder || this._isKeyHolder;

    if (this._isKeyHolder) {
      // Generate the room key BEFORE draining queued announces, so the
      // drain's handleAnnounce calls hit the wrap-and-offer branch and every
      // drained peer receives the fresh key immediately. Mid-call peers never
      // re-announce (handleAnnounce replies with an offer, not a
      // counter-announce), so the only later delivery would be the 5-minute
      // rotation timer — stranding them on a dead key whenever a new key
      // holder joins an ongoing call.
      this._e2eeEpoch++;
      this._roomKey = generateRoomKey();
      await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
      log.info("E2EE: key holder — generated room key", { channelId });
      this.startKeyRotationTimer();
    }

    // And once more after keyProvider.setKey's await: a torn-down attempt
    // that resurrects this._ecdhKeyPair here would defeat the queue guard in
    // handleAnnounceInner and go on to announce a dead ephemeral key over a
    // live call (finding v043).
    if (this._sessionGeneration !== myGeneration) {
      log.warn("E2EE: setup superseded before keypair publish — aborting", { channelId });
      return false;
    }

    // Publish the keypair now — right before the drain, so every announce
    // that arrived during the awaits above was queued (not silently
    // processed with no offer sent) and gets its offer sent below.
    this._ecdhKeyPair = ecdhKeyPair;

    // Drain any announces that arrived before our keypair was ready. These
    // are existing participants whose keys the server relayed during
    // voice_join sync — run them through the normal verifying receive path
    // so a server-substituted peer key is caught here too.
    const queued = this._pendingAnnounces.splice(0);
    for (const { userId: qId, publicKeyBase64: qKey, signatureBase64: qSig } of queued) {
      // oxlint-disable-next-line no-await-in-loop -- sequential drain: verify each queued announce
      await this.handleAnnounce(qId, qKey, qSig);
      log.info("E2EE: drained queued announce", { userId: qId });
    }

    if (this._isKeyHolder) {
      // Announce our (signed) key so existing participants can see us.
      this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: announcePayload });
    } else {
      // Wait for the key holder to send us the room key via voice_e2ee_offer.
      // This promise resolves when handleOffer() sets _roomKey.
      log.info("E2EE: waiting for room key from key holder", { channelId });
      const roomKeyPromise = new Promise<void>((resolve, reject) => {
        this._roomKeyResolver = resolve;
        this._roomKeyRejector = reject;
      });
      // Announce BEFORE waiting (moved earlier per F3) so the key holder can
      // offer immediately. The resolver is set above, so an immediate offer
      // won't be missed.
      this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: announcePayload });
      // Wait up to 10s for the key holder to send an offer. If the first
      // attempt times out, re-announce our public key (the offer may have been
      // lost if the key holder disconnected mid-send) and wait 5s more.
      let timeoutId: ReturnType<typeof setTimeout> | null = null;
      const makeTimeout = (ms: number) =>
        new Promise<void>((_, reject) => {
          timeoutId = setTimeout(() => reject(new Error("E2EE key exchange timeout")), ms);
        });
      try {
        await Promise.race([roomKeyPromise, makeTimeout(10_000)]);
      } catch {
        // First attempt failed — re-announce and retry once. This also
        // catches a decrypt failure in handleOfferInner (which rejects
        // roomKeyPromise directly), not just a genuine timeout.
        if (timeoutId !== null) clearTimeout(timeoutId);
        // clearState() (e.g. the user left voice) also rejects roomKeyPromise
        // and, unlike a decrypt failure, nulls _ecdhKeyPair — there is nobody
        // left to retry with. Stop here instead of re-announcing into a torn-
        // down session and reinstalling a resolver nothing will ever call.
        // Compare by identity, not just null: a torn-down-then-restarted
        // session can leave this._ecdhKeyPair non-null but owned by a
        // completely different (superseded) attempt — retrying would
        // re-announce our dead ephemeral key over that live session and
        // steal its single _roomKeyResolver slot (finding v043).
        if (this._ecdhKeyPair !== ecdhKeyPair) {
          log.warn("E2EE: key exchange aborted (session cleared or superseded)", { channelId });
          return false;
        }
        log.warn("E2EE: first key exchange attempt timed out, re-announcing", { channelId });
        this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: announcePayload });
        // roomKeyPromise may already be SETTLED (rejected) at this point — a
        // decrypt failure rejects it permanently, so racing the SAME promise
        // again would resolve rejected on the very next microtask instead of
        // giving the retry its intended 5s window. Create a fresh promise and
        // reinstall the resolver/rejector before racing again.
        const retryPromise = new Promise<void>((resolve, reject) => {
          this._roomKeyResolver = resolve;
          this._roomKeyRejector = reject;
        });
        try {
          await Promise.race([retryPromise, makeTimeout(5_000)]);
        } catch {
          log.error("E2EE: key exchange timed out after retry — disconnecting", { channelId });
          this._roomKeyResolver = null;
          this._roomKeyRejector = null;
          if (timeoutId !== null) clearTimeout(timeoutId);
          return false;
        }
      } finally {
        if (timeoutId !== null) clearTimeout(timeoutId);
      }
      this._roomKeyResolver = null;
      this._roomKeyRejector = null;
    }
    return true;
  }

  /**
   * E2EE re-setup for auto-reconnect: regenerate the ECDH keypair for the new
   * session (forward secrecy) and re-announce so other participants can re-wrap
   * the room key for us. If we still have the room key from before disconnect,
   * re-apply it now so audio works immediately; the key holder will send a
   * fresh offer if the key was rotated during our absence.
   */
  async reannounceForReconnect(): Promise<void> {
    // Captured before any await so a clearState() (e.g. the user hits
    // Disconnect during auto-reconnect) that lands during this method's
    // awaits can be detected instead of silently resurrecting
    // this._ecdhKeyPair / re-announcing for a channel we already left
    // (finding v093).
    const myGeneration = this._sessionGeneration;
    const pair = await generateECDHKeyPair();
    if (this._sessionGeneration !== myGeneration) {
      log.warn("E2EE: reconnect re-announce superseded before keypair publish — aborting");
      return;
    }
    this._ecdhKeyPair = pair;
    // Peers' ECDH public keys and their TOFU verifications survive: they are
    // unaffected by regenerating OUR pair, and ECDH still works (our new
    // private key against their existing public key). Clearing them here would
    // be permanent — handleAnnounce replies with an offer rather than a
    // counter-announce, and the server relays stored peer keys only on
    // voice_join — so handleOffer's unknown-peer guard would drop every
    // subsequent rotation, stranding us on the pre-reconnect key.
    if (this._roomKey) {
      await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
    }
    const reconnectPubKey = await exportPublicKey(pair.publicKey);
    const reconnectAnnounce = await this.buildAnnouncePayload(reconnectPubKey);
    // Re-check ownership right before the send too: buildAnnouncePayload can
    // itself await a keyring round trip, another window for clearState() (or
    // a fresh setupKeyExchange) to have superseded this attempt.
    if (this._ecdhKeyPair !== pair) {
      log.warn("E2EE: reconnect re-announce superseded before send — discarding stray announce");
      return;
    }
    this.deps.getWs()?.send({ type: "voice_e2ee_announce", payload: reconnectAnnounce });
  }

  // ── Identity signing (F3 TOFU) ──────────────────────────────────────────

  /** Decode a base64 raw-key string to bytes for sign/verify. Throws on bad
   *  input (callers verifying a peer key already run inside try/catch). */
  private rawFromBase64(base64: string): Uint8Array {
    return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
  }

  /** Load (once per session) this client's long-term identity keypair from the
   *  OS keyring so we can sign ephemeral announces. Returns null when there is
   *  no server host (identity is host-scoped) OR no authenticated user id yet
   *  (identity is host+user scoped, B3-3) — the announce then goes out
   *  unsigned and peers treat us as a legacy/unverified client. A missing user
   *  id must never fall back to a placeholder scope like `?? 0`:
   *  `getOrCreateIdentityKeyPair` would mint (or migrate-and-DELETE the real
   *  legacy key into) a bogus `host:0` keyring account, and a later
   *  authenticated call would then mint a second, different keypair under
   *  `host:<realId>` — so the published key and the announce signing key
   *  permanently disagree and every peer's verifyPeerAnnounce reports a false
   *  MITM "mismatch" (see identity.ts's `identityKeyPairCache` doc). */
  private async ensureIdentityKeyPair(): Promise<CryptoKeyPair | null> {
    if (this._identityKeyPair) return this._identityKeyPair;
    const host = this.deps.getServerHost();
    if (host === null) return null;
    const myUserId = authStore.getState().user?.id;
    if (myUserId === undefined) {
      log.warn(
        "E2EE: no authenticated user id yet — announcing unsigned instead of scoping under a placeholder id",
      );
      return null;
    }
    this._identityKeyPair = await getOrCreateIdentityKeyPair(host, myUserId);
    return this._identityKeyPair;
  }

  /** Identity keys are host-scoped — the session drops the cached keypair when
   *  the host changes (and on cleanupAll) so we never sign an announce with
   *  another host's identity key. */
  clearIdentityKeyPair(): void {
    this._identityKeyPair = null;
  }

  /** Build the voice_e2ee_announce payload, signing the ephemeral public key
   *  with our identity key (F3). Signing failures degrade to an unsigned
   *  announce rather than blocking the join. */
  private async buildAnnouncePayload(
    ephemeralPubBase64: string,
  ): Promise<{ public_key: string; signature?: string }> {
    try {
      const idKeyPair = await this.ensureIdentityKeyPair();
      if (idKeyPair) {
        const myUserId = authStore.getState().user?.id ?? 0;
        const ephemeralRaw = this.rawFromBase64(ephemeralPubBase64);
        const signature = await signEphemeralKey(idKeyPair.privateKey, myUserId, ephemeralRaw);
        return { public_key: ephemeralPubBase64, signature };
      }
    } catch (err) {
      log.error("E2EE: failed to sign announce — sending unsigned", err);
    }
    return { public_key: ephemeralPubBase64 };
  }

  /**
   * F3 TOFU: resolve a peer's identity key and verify their ephemeral-announce
   * signature. Pins the identity key on first sight; on a later change it emits
   * an identity-tofu "mismatch" (via the voice store) and blocks the peer until
   * the user re-pins. Returns true when the announce may be accepted (verified,
   * or a legacy peer with no identity key), false to reject/block. The store
   * write is the surfaced verification state the voice panel reads.
   *
   * Compatibility posture (transition):
   *   - peer HAS a published identity key, signature missing/invalid → reject
   *     (fail closed);
   *   - peer has NO identity key (legacy client) → accept, mark unverified
   *     (pin-pending).
   */
  private async verifyPeerAnnounce(
    userId: number,
    publicKeyBase64: string,
    signatureBase64: string | undefined,
    myGeneration: number,
  ): Promise<boolean> {
    const publishedIdentity =
      membersStore.getState().members.get(userId)?.identityPublicKey ?? null;
    const host = this.deps.getServerHost();

    // Resolve the persisted pin FIRST — before any legacy shortcut. A server
    // must not be able to strip a pinned peer's published key (or swap it) to
    // force it back onto the legacy accept path (finding #2: TOFU pin bypass).
    const lookup = host
      ? await getIdentityPin(host, String(userId))
      : ({ status: "unpinned" } as const);

    // Fail closed when the pin store could not be read (DC-08): with the pin
    // unknown, this peer might be pinned to a different key — proceeding down
    // the first-sight path would verify against, and then RE-PIN, whatever key
    // the server delivered. Reject the announce and surface the distinct
    // "unknown" state; the peer stays blocked for E2EE until the store recovers.
    if (lookup.status === "unavailable") {
      this.setPeerVerificationIfCurrent(myGeneration, {
        userId,
        status: "unknown",
        safetyNumber: null,
      });
      log.error("E2EE: identity pin store unreadable — rejecting announce (fail closed)", {
        userId,
      });
      return false;
    }

    const pin = lookup.status === "pinned" ? lookup.pin : null;

    // Pinned peer whose delivered key is absent or differs from the pin —
    // possible server MITM. Block until the user re-pins.
    if (pin !== null && publishedIdentity !== pin) {
      this.setPeerVerificationIfCurrent(myGeneration, {
        userId,
        status: "mismatch",
        safetyNumber: null,
      });
      log.error("E2EE: pinned peer identity key missing/changed — blocking (identity-tofu)", {
        userId,
      });
      return false;
    }

    // Genuine legacy peer: never pinned AND no published identity key — accept
    // but mark unverified (pin-pending). This is the only case the compatibility
    // posture keeps open.
    if (!publishedIdentity) {
      this.setPeerVerificationIfCurrent(myGeneration, {
        userId,
        status: "unverified",
        safetyNumber: null,
      });
      log.warn("E2EE: peer has no identity key — accepting as unverified (legacy)", { userId });
      return true;
    }

    // Verify the ephemeral-key signature against the trusted identity key
    // (the pin when we have one, else the first-sight published key).
    const anchorBase64 = pin ?? publishedIdentity;
    const identityKey = await importIdentityPublicKey(anchorBase64);
    const ephemeralRaw = this.rawFromBase64(publicKeyBase64);
    const ok = signatureBase64
      ? await verifyEphemeralKeySignature(identityKey, userId, ephemeralRaw, signatureBase64)
      : false;
    if (!ok) {
      // Fail closed: peer has an identity key but no valid signature (MITM).
      this.setPeerVerificationIfCurrent(myGeneration, {
        userId,
        status: "mismatch",
        safetyNumber: null,
      });
      log.error("E2EE: peer announce signature invalid — rejecting (MITM?)", { userId });
      return false;
    }

    // First sight with a valid signature — pin the identity key now. A
    // failed write (disk full, unwritable pins file) must not display
    // "verified" with no pin ever persisted: the pin is what arms mismatch
    // detection on a LATER announce, so a peer we call verified but never
    // pinned can never have that check fire — the exact MITM window the pin
    // exists to close. "no-store" (non-Tauri: no pin store by design) is not
    // a failure and keeps the normal verified outcome below.
    let pinWriteFailed = false;
    if (pin === null && host) {
      const pinResult = await storeIdentityPin(host, String(userId), publishedIdentity);
      if (pinResult === "failed") {
        pinWriteFailed = true;
        log.error("E2EE: failed to persist identity pin — marking unverified, not verified", {
          userId,
        });
      } else {
        log.info("E2EE: pinned peer identity key on first sight", { userId });
      }
    }
    if (pinWriteFailed) {
      this.setPeerVerificationIfCurrent(myGeneration, {
        userId,
        status: "unverified",
        safetyNumber: null,
      });
      return true; // still accept the announce — the write failure alone shouldn't block the call
    }
    const safetyNumber = await computeKeyFingerprint(identityKey);
    this.setPeerVerificationIfCurrent(myGeneration, { userId, status: "verified", safetyNumber });
    return true;
  }

  /** setPeerVerification, but a no-op if a clearState() teardown happened
   *  since myGeneration was captured. verifyPeerAnnounce awaits a Tauri IPC
   *  (identity pin lookup) internally and writes verification state on every
   *  branch, so a Disconnect mid-await must not let the resumed continuation
   *  resurrect voice-store state for a session that no longer exists
   *  (finding B3-7). */
  private setPeerVerificationIfCurrent(
    myGeneration: number,
    verification: Parameters<typeof setPeerVerification>[0],
  ): void {
    if (this._sessionGeneration !== myGeneration) return;
    setPeerVerification(verification);
  }

  /**
   * F3 TOFU re-pin recovery (finding #4). Pin the EXACT identity key
   * `verifiedKey` — the bytes whose fingerprint the caller displayed and the
   * user confirmed out-of-band — overwriting the stored pin for {host,userId}
   * and clearing the mismatch block (the identity-key analogue of accepting a
   * changed TLS cert). A legitimate key rotation (reinstall / new device /
   * wiped keyring) is thus recoverable instead of a permanent lockout; the next
   * announce re-verifies against the new pin.
   *
   * The verified key MUST be passed in, never re-read from membersStore here:
   * the store is server-writable (a `user_update` mutates it), so re-reading it
   * would let a malicious server swap in an attacker key during the human
   * out-of-band verification window and have us pin THAT — a TOCTOU that
   * silently defeats the mismatch prompt. Returns false when there is no host
   * or no key to pin.
   */
  async rePinPeerIdentity(userId: number, verifiedKey: string): Promise<boolean> {
    const host = this.deps.getServerHost();
    if (!host || !verifiedKey) {
      log.warn("E2EE: cannot re-pin peer without a host and the verified identity key", { userId });
      return false;
    }
    const result = await storeIdentityPin(host, String(userId), verifiedKey);
    if (result === "failed") {
      // The old pin is still on disk — do NOT clear the mismatch block. If we
      // did, the UI would report the peer trusted while nothing was actually
      // re-pinned, and the peer's very next announce would re-fail
      // verification against the stale pin with no error ever surfaced.
      log.error("E2EE: failed to persist re-pinned identity key — mismatch block kept", {
        userId,
      });
      return false;
    }
    clearPeerVerification(userId);
    log.info("E2EE: re-pinned peer identity key (TOFU recovery)", { userId });
    return true;
  }

  // ── Client-side E2EE handlers (ECDH key exchange) ───────────────────────

  /** Serializes announce handling. Nothing chains concurrent invocations —
   *  dispatcher fires them unawaited and the queued-announce drain in
   *  setupKeyExchange is a separate, later pass — so two in-flight announces
   *  for the same peer could complete out of WS-delivery order, letting a
   *  stale announce's map write land after a fresher one and strand the peer
   *  on a dead key until the next rotation (finding v015). Mirrors
   *  _offerChain, whose identical ordering guarantee this file already
   *  relies on and tests; handleAnnounceInner swallows its own errors (see
   *  its try/catch below), so the chain cannot wedge on a failed announce. */
  private _announceChain: Promise<void> = Promise.resolve();

  /**
   * Handle a voice_e2ee_announce from the server — another participant has
   * announced their ECDH public key. Applied strictly in WS delivery order
   * (see _announceChain). Before trusting it we verify the peer's
   * identity-key signature (F3 TOFU): resolve the peer's identity key (pinning
   * it on first sight), reject on mismatch/invalid signature, and only then
   * store the ECDH key + (if key holder) wrap the room key for them. Peers with
   * no published identity key (legacy) are accepted but marked unverified.
   */
  handleAnnounce(userId: number, publicKeyBase64: string, signatureBase64?: string): Promise<void> {
    const run = this._announceChain.then(() =>
      this.handleAnnounceInner(userId, publicKeyBase64, signatureBase64),
    );
    this._announceChain = run;
    return run;
  }

  private async handleAnnounceInner(
    userId: number,
    publicKeyBase64: string,
    signatureBase64?: string,
  ): Promise<void> {
    // Queue if our keypair isn't ready yet (announce arrived during connectAndSetup).
    if (!this._ecdhKeyPair) {
      this._pendingAnnounces.push({ userId, publicKeyBase64, signatureBase64 });
      log.info("E2EE: queued announce (keypair not ready)", { userId });
      return;
    }
    // Captured before verifyPeerAnnounce's awaits (a Tauri IPC pin lookup) so
    // a clearState() that lands during them — e.g. Disconnect mid-verify —
    // can be detected before this continuation writes into a session a newer
    // (or no) attempt now owns (finding B3-7).
    const myGeneration = this._sessionGeneration;
    try {
      // ── F3 TOFU verification gate ──────────────────────────────────────
      // Resolve the peer's identity key and verify the announce signature
      // BEFORE storing the ECDH key or wrapping the room key. A malicious
      // server that swaps user_id↔ephemeral-key or forges keys fails here.
      if (
        !(await this.verifyPeerAnnounce(userId, publicKeyBase64, signatureBase64, myGeneration))
      ) {
        return; // rejected/blocked — do not store or wrap
      }

      if (this._sessionGeneration !== myGeneration) {
        log.info("E2EE: discarding stale announce (session torn down during verify)", { userId });
        return;
      }

      // Deduplicate: if the key is identical, skip the import but still
      // re-send the room key offer (the peer may be re-requesting after a
      // missed offer or reconnect).
      const existingKey = this._peerPublicKeys.get(userId);
      let peerKey: CryptoKey;
      let isDuplicate = false;
      if (existingKey) {
        const existingB64 = await exportPublicKey(existingKey);
        if (existingB64 === publicKeyBase64) {
          peerKey = existingKey;
          isDuplicate = true;
          log.debug("E2EE: duplicate announce — will re-send offer if key holder", { userId });
        } else {
          peerKey = await importPublicKey(publicKeyBase64);
          log.warn("E2EE: peer public key changed (reconnect?)", { userId });
        }
      } else {
        peerKey = await importPublicKey(publicKeyBase64);
      }
      if (!isDuplicate) {
        this._peerPublicKeys.set(userId, peerKey);
        log.info("E2EE: received peer public key", { userId });
      }

      // If we're the key holder and have a room key, wrap it for the new peer.
      // Capture keypair + roomKey before async work to avoid null dereference if
      // clearState() runs concurrently.
      const keypair = this._ecdhKeyPair;
      const currentRoomKey = this._roomKey;
      if (this._isKeyHolder && currentRoomKey && keypair) {
        // Capture epoch before the wrap await — a rotation racing this
        // announce already added the peer to _peerPublicKeys before we got
        // here, so it offers them the fresh key on its own; if that
        // happened, ship this pre-rotation wrap and the receiver's
        // strictly-ordered _offerChain ends up on the dead key.
        const epochBefore = this._e2eeEpoch;
        const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, currentRoomKey);
        // Discard if either the epoch advanced (a rotation landed during the
        // wrap) OR the keypair no longer matches (a concurrent
        // reannounceForReconnect() swapped it without bumping the epoch) —
        // mirrors handleOfferInner's dual guard. An offer wrapped under an
        // abandoned keypair is undecryptable by the peer (finding v101).
        if (this._e2eeEpoch !== epochBefore || this._ecdhKeyPair !== keypair) {
          log.info("E2EE: discarding stale announce-offer (epoch or keypair changed during wrap)", {
            userId,
            epochBefore,
            epochNow: this._e2eeEpoch,
          });
          return;
        }
        this.deps.getWs()?.send({
          type: "voice_e2ee_offer",
          payload: { target_user_id: userId, encrypted_key: encryptedKey, iv },
        });
        log.info("E2EE: sent room key offer to peer", { userId });
      }
    } catch (err) {
      log.error("E2EE: failed to handle announce", err);
    }
  }

  /** Serializes offer application. The offer payload carries no epoch or
   *  sequence and WebCrypto gives no cross-operation ordering guarantee, so
   *  two in-flight offers could complete out of order — applying the older
   *  key last and stranding this receiver on a dead key until the next
   *  rotation. Chaining applies offers strictly in WS delivery order. */
  private _offerChain: Promise<void> = Promise.resolve();

  /**
   * Handle a voice_e2ee_offer from the server — the key holder has sent us
   * the encrypted room key. Unwrap it and apply to the E2EE key provider.
   * Offers are applied one at a time, in delivery order.
   */
  handleOffer(fromUserId: number, encryptedKeyBase64: string, ivBase64: string): Promise<void> {
    // handleOfferInner never rejects (it catches internally), so the chain
    // cannot wedge on a failed offer.
    const run = this._offerChain.then(() =>
      this.handleOfferInner(fromUserId, encryptedKeyBase64, ivBase64),
    );
    this._offerChain = run;
    return run;
  }

  private async handleOfferInner(
    fromUserId: number,
    encryptedKeyBase64: string,
    ivBase64: string,
  ): Promise<void> {
    try {
      const peerKey = this._peerPublicKeys.get(fromUserId);
      if (!peerKey) {
        log.warn("E2EE: received offer from unknown peer", { fromUserId });
        return;
      }
      const keypair = this._ecdhKeyPair;
      if (!keypair) {
        log.warn("E2EE: received offer but no ECDH keypair");
        return;
      }

      // Capture epoch before async work — if a key rotation occurs during
      // unwrap, the epoch will have advanced and we discard this stale result.
      const epochBefore = this._e2eeEpoch;

      const unwrapped = await unwrapRoomKey(
        keypair.privateKey,
        peerKey,
        encryptedKeyBase64,
        ivBase64,
      );

      // Discard if either the epoch advanced (a rotation landed during
      // unwrap) OR the keypair no longer matches (clearState() ran and a new
      // session generated a fresh one — possible when the epoch is 0 in both
      // the old and new session, since a non-key-holder never bumps it).
      if (this._e2eeEpoch !== epochBefore || this._ecdhKeyPair !== keypair) {
        log.info("E2EE: discarding stale offer (epoch or session keypair changed during unwrap)", {
          fromUserId,
          epochBefore,
          epochNow: this._e2eeEpoch,
        });
        return;
      }

      this._roomKey = unwrapped;
      await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
      log.info("E2EE: room key received and applied", { fromUserId });

      // Accepting an offer proves the sender is the server-authoritative key
      // holder (the server gates outgoing offers on IsVoiceKeyHolder), so if we
      // still think we hold the key, we have been re-elected away — a lower
      // userID joined. Stand down: our rotations would be rejected with
      // NOT_KEY_HOLDER, but only after we applied the new key locally, leaving
      // us deaf and mute until the real holder rotates again.
      // handleParticipantLeft can still re-promote us later.
      if (this._isKeyHolder) {
        this._isKeyHolder = false;
        this.clearKeyRotationTimer();
        log.info("E2EE: stood down as key holder — accepted an offer from the elected holder", {
          fromUserId,
        });
      }

      // Resolve the pending connect promise if we were waiting for the key.
      if (this._roomKeyResolver) {
        this._roomKeyResolver();
        this._roomKeyResolver = null;
        this._roomKeyRejector = null;
      }
    } catch (err) {
      log.error("E2EE: failed to handle offer", err);
      // Propagate decryption failure so the waiting setupKeyExchange unblocks.
      if (this._roomKeyRejector) {
        this._roomKeyRejector(err instanceof Error ? err : new Error(String(err)));
        this._roomKeyResolver = null;
        this._roomKeyRejector = null;
      }
    }
  }

  /**
   * Wrap the room key for each peer and send an offer, one at a time. Bails
   * out (without sending further offers) as soon as a concurrent keypair
   * swap (reannounceForReconnect) or room-key change invalidates the wrap —
   * an offer wrapped under an abandoned keypair/key is undecryptable by the
   * peer and would otherwise silently strand them on the stale key until the
   * next rotation (finding v045). Shared by the become-holder distribution,
   * its late-arrival (H3) pass, and the periodic rotation loop.
   */
  private async distributeRoomKey(
    keypair: CryptoKeyPair,
    roomKey: Uint8Array,
    peers: Iterable<[number, CryptoKey]>,
  ): Promise<void> {
    for (const [peerId, peerKey] of peers) {
      if (this._ecdhKeyPair !== keypair || this._roomKey !== roomKey) {
        log.warn("E2EE: aborting key distribution — keypair/room key changed mid-loop", {
          peerId,
        });
        return;
      }
      const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, roomKey);
      if (this._ecdhKeyPair !== keypair || this._roomKey !== roomKey) {
        log.info("E2EE: discarding stale room-key offer (keypair/room key changed during wrap)", {
          peerId,
        });
        return;
      }
      this.deps.getWs()?.send({
        type: "voice_e2ee_offer",
        payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv },
      });
    }
  }

  /**
   * Handle a participant leaving the voice channel. If we become the new key
   * holder, rotate the room key and distribute to remaining peers. If we are
   * ALREADY the key holder and a peer that held the room key left, we also
   * rotate — so the departed member's copy can no longer decrypt future audio
   * against the untrusted SFU (membership forward secrecy).
   *
   * Key holder election: the participant with the lowest user ID among remaining
   * participants is elected. This is deterministic and does not depend on Map
   * insertion order (which is not guaranteed to match server join order).
   */
  async handleParticipantLeft(userId: number): Promise<void> {
    const hadPeerKey = this._peerPublicKeys.has(userId);
    this._peerPublicKeys.delete(userId);
    clearPeerVerification(userId);

    const channelId = this._channelId ?? this.deps.getCurrentChannelId();
    if (!channelId) return;

    const state = voiceStore.getState();
    const channelUsers = state.voiceUsers.get(channelId);
    if (!channelUsers || channelUsers.size === 0) return;

    // Elect key holder: lowest user_id among remaining participants.
    let lowestUserId = Infinity;
    for (const uid of channelUsers.keys()) {
      if (uid < lowestUserId) lowestUserId = uid;
    }

    const wasKeyHolder = this._isKeyHolder;
    const myUserId = authStore.getState().user?.id ?? 0;

    if (myUserId !== 0 && lowestUserId === myUserId && !wasKeyHolder) {
      // A rotation is already in flight (e.g. we stood down mid-rotation
      // after accepting another holder's offer, and are now re-elected
      // because THEY left). Don't drop the re-election — that would strand
      // the room with no key holder until the next voice_leave self-heals
      // it. Mirror the sibling branch below: defer, don't drop. The
      // in-flight rotation's finally -> drainPendingRotationOrArmTimer will
      // run rotateKeyPeriodically as holder once it completes.
      if (this._rotatingKey) {
        this._isKeyHolder = true;
        this._rotationPending = true;
        log.warn("E2EE: key rotation already in progress — deferring re-election as holder", {
          userId,
          channelId,
        });
        return;
      }
      this._rotatingKey = true;
      this._isKeyHolder = true;
      log.info("E2EE: became key holder after participant left", { userId, channelId });

      // Rotate the room key — generate a new one and distribute to all remaining peers.
      try {
        this._e2eeEpoch++;
        this._roomKey = generateRoomKey();
        await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
        log.info("E2EE: rotated room key", { channelId, epoch: this._e2eeEpoch });

        // A client elected while still waiting inside setupKeyExchange has a
        // pending resolver — the offer it is waiting for will never arrive
        // (we are the holder now), so unblock it with the key just generated.
        if (this._roomKeyResolver) {
          this._roomKeyResolver();
          this._roomKeyResolver = null;
          this._roomKeyRejector = null;
        }

        // Snapshot peers (and the keypair/room key) before the async loop —
        // new peers that arrive during wrapping are handled by the
        // post-rotation check below.
        const keypair = this._ecdhKeyPair;
        const roomKey = this._roomKey;
        const peersSnapshot = new Map(this._peerPublicKeys);

        if (keypair && roomKey) {
          await this.distributeRoomKey(keypair, roomKey, peersSnapshot);
          log.info("E2EE: distributed rotated key to peers", {
            peerCount: peersSnapshot.size,
          });

          // H3: Check for peers that arrived during the rotation loop and
          // send them the new key too.
          if (keypair === this._ecdhKeyPair && this._roomKey === roomKey) {
            const lateArrivals = [...this._peerPublicKeys].filter(
              ([peerId]) => !peersSnapshot.has(peerId),
            );
            if (lateArrivals.length > 0) {
              await this.distributeRoomKey(keypair, roomKey, lateArrivals);
              log.info("E2EE: sent rotated key to late-arriving peers", {
                peerCount: lateArrivals.length,
              });
            }
          }
        }
      } catch (err) {
        log.error("E2EE: failed to rotate room key", err);
      } finally {
        this._rotatingKey = false;
      }
      // If a keyed peer left while this become-holder rotation was in flight, its
      // rekey was deferred (not dropped) — run it now so the departed member is
      // excluded from the fresh key; otherwise re-arm the periodic timer.
      await this.drainPendingRotationOrArmTimer();
    } else if (wasKeyHolder && hadPeerKey) {
      // Membership forward secrecy: I remain the key holder and a peer that held
      // the room key left, so rotate + redistribute to the CURRENT peer set
      // (which already excludes the leaver, deleted above) — otherwise the
      // departed member keeps a valid room key against the untrusted SFU until
      // the next periodic rotation.
      if (this._rotatingKey) {
        // A rotation is already in flight and may already have sent the current
        // key to this leaver before they left. Don't DROP the rekey (that would
        // leave the departed member holding a live key) — defer it so it re-runs
        // when the in-flight rotation completes, excluding them.
        this._rotationPending = true;
      } else {
        await this.rotateKeyPeriodically();
      }
    }
  }

  // ── Periodic key rotation ──────────────────────────────────────────────────

  /** Start the periodic key rotation timer (only meaningful for key holders). */
  private startKeyRotationTimer(): void {
    this.clearKeyRotationTimer();
    if (!this._isKeyHolder) return;
    this._keyRotationTimer = setTimeout(() => {
      this._keyRotationTimer = null;
      void this.rotateKeyPeriodically();
    }, E2EEManager.KEY_ROTATION_INTERVAL_MS);
    log.debug("E2EE: key rotation timer started", {
      intervalMs: E2EEManager.KEY_ROTATION_INTERVAL_MS,
    });
  }

  private clearKeyRotationTimer(): void {
    if (this._keyRotationTimer !== null) {
      clearTimeout(this._keyRotationTimer);
      this._keyRotationTimer = null;
    }
  }

  /** Rotate the room key on a timer tick (forward secrecy improvement). */
  async rotateKeyPeriodically(): Promise<void> {
    if (!this._isKeyHolder || this._rotatingKey) return;
    const channelId = this._channelId ?? this.deps.getCurrentChannelId();
    if (!channelId) return;

    this._rotatingKey = true;
    try {
      this._e2eeEpoch++;
      this._roomKey = generateRoomKey();
      await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
      log.info("E2EE: periodic key rotation", { channelId, epoch: this._e2eeEpoch });

      const keypair = this._ecdhKeyPair;
      const roomKey = this._roomKey;
      if (keypair && roomKey) {
        const peerCount = this._peerPublicKeys.size;
        // Pass the live map (not a snapshot): peers that arrive mid-loop are
        // still visited, matching the original behavior — only the
        // keypair/room-key ownership check is new here.
        await this.distributeRoomKey(keypair, roomKey, this._peerPublicKeys);
        log.info("E2EE: distributed periodically rotated key", { peerCount });
      }
    } catch (err) {
      log.error("E2EE: periodic key rotation failed", err);
    } finally {
      this._rotatingKey = false;
    }

    // Re-arm the periodic timer, or run a rotation deferred by a keyed-peer leave
    // that coincided with this one.
    await this.drainPendingRotationOrArmTimer();
  }

  /** After a rotation completes: if a keyed-peer leave coincided with it (its
   *  rekey was deferred, not dropped), run one more rotation to exclude the
   *  departed member; otherwise re-arm the periodic rotation timer. */
  private async drainPendingRotationOrArmTimer(): Promise<void> {
    if (this._rotationPending) {
      this._rotationPending = false;
      await this.rotateKeyPeriodically();
      return;
    }
    this.startKeyRotationTimer();
  }

  /** Clear all E2EE state (called on voice leave). The long-term identity
   *  keypair is intentionally NOT cleared here — it persists across calls to
   *  the same host (cleared only on host change / cleanupAll). */
  clearState(): void {
    this._sessionGeneration++;
    this._channelId = null;
    this._offerChain = Promise.resolve();
    this._announceChain = Promise.resolve();
    this._ecdhKeyPair = null;
    this._roomKey = null;
    this._peerPublicKeys.clear();
    clearPeerVerifications();
    this._isKeyHolder = false;
    this._rotatingKey = false;
    this._rotationPending = false;
    this._e2eeEpoch = 0;
    this._pendingAnnounces.length = 0;
    this.clearKeyRotationTimer();
    // Reject (not resolve) so waiting setupKeyExchange sees a failure, not a
    // silent success with no room key.
    if (this._roomKeyRejector) {
      this._roomKeyRejector(new Error("Voice session ended"));
    }
    this._roomKeyResolver = null;
    this._roomKeyRejector = null;
  }
}
