//! `E2eManager` — the single entry point the rest of the app uses to
//! drive the RPE2E protocol.
//!
//! Responsibilities:
//!
//! - own the long-term `Identity` and a handle to the `Keyring`
//! - encrypt outgoing plaintext into one or more wire-format lines
//! - decrypt an incoming wire-format line under the strict handle check
//! - build and dispatch KEYREQ (initiator side)
//! - answer KEYREQ with KEYRSP when policy allows (responder side)
//! - consume KEYRSP to install a trusted incoming session (initiator side)
//! - enforce per-peer rate limiting on outgoing KEYREQ
//!
//! The handshake key-agreement is X25519 over *ephemeral* keys carried in
//! both KEYREQ (`eph_x25519`) and KEYRSP (`ephemeral_pub`). The Ed25519
//! long-term identity is used only to sign/verify each handshake message;
//! it is never directly fed into ECDH (which would conflate two curves).
//! See `docs/plans/2026-04-11-rpee2e-implementation.md` §12b for the
//! rationale.

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};

use hkdf::Hkdf;
use sha2::Sha256;
use x25519_dalek::{PublicKey as XPub, StaticSecret};

use crate::e2e::DEFAULT_TS_TOLERANCE_SECS;
use crate::e2e::chunker::split_plaintext;
use crate::e2e::crypto::{
    aead::{self, SessionKey},
    ecdh,
    fingerprint::{Fingerprint, fingerprint},
    identity::Identity,
    sig,
};
use crate::e2e::error::{E2eError, Result};
use crate::e2e::handshake::{
    KeyRekey, KeyReq, KeyRsp, RateLimiter, encode_keyrekey, encode_keyreq, encode_keyrsp,
    signed_keyrekey_payload, signed_keyreq_payload, signed_keyrsp_payload,
};
use crate::e2e::keyring::{ChannelMode, IncomingSession, Keyring, PeerRecord, TrustStatus};
use crate::e2e::wire::{WireChunk, build_aad, fresh_msgid};

/// In-flight handshake state — the initiator's ephemeral X25519 secret,
/// held until the matching KEYRSP arrives. Stored as raw bytes because
/// `x25519_dalek::StaticSecret` is not `Clone` and we want a boring
/// `HashMap` value.
///
/// Do **not** derive `Debug` on types that hold the raw secret.
struct PendingHandshake {
    #[allow(dead_code, reason = "future diagnostics: pending channel listing")]
    channel: String,
    peer_handle: Option<String>,
    eph_x25519_secret: [u8; 32],
}

/// Inbound KEYREQ cached in Normal mode for a yet-unknown peer, so that a
/// subsequent `/e2e accept <nick>` can complete the handshake by producing
/// the KEYRSP that would have been sent immediately under `AutoAccept`.
///
/// Keyed by `(sender_handle, channel)` in `pending_inbound`.
#[derive(Debug, Clone)]
struct PendingInboundKeyReq {
    req: KeyReq,
}

/// An outbound REKEY CTCP ready to ship, paired with the target IRC handle
/// it must go to. Drained by `take_pending_rekey_sends` right after
/// `encrypt_outgoing` triggers a lazy rotation, so the caller can enqueue
/// the per-peer NOTICEs on the same tick.
#[derive(Debug, Clone)]
pub struct PendingRekeySend {
    pub target_handle: String,
    pub notice_text: String,
}

/// An outbound KEYREQ produced by the symmetric-handshake path inside
/// `handle_keyreq`: after serving a peer a KEYRSP (which establishes the
/// peer→us direction) we also queue a reciprocal KEYREQ back to the same
/// peer so the us→peer direction gets established in the same round-trip
/// from the user's POV. Drained by `take_pending_outbound_keyreqs` right
/// after each `handle_keyreq` call so the IRC event dispatcher can enqueue
/// the reciprocal NOTICE on the same tick, matching the KEYRSP dispatch
/// pattern.
#[derive(Debug, Clone)]
pub struct PendingOutboundKeyReq {
    pub peer_handle: String,
    pub channel: String,
    pub req: KeyReq,
}

/// Info surfaced when a Normal-mode KEYREQ arrives from an as-yet-untrusted
/// peer. Drained by `take_pending_accept_requests` and rendered as a themed
/// prompt instructing the user to run `/e2e accept` or `/e2e decline`.
#[derive(Debug, Clone)]
pub struct PendingAcceptRequest {
    pub nick: Option<String>,
    pub handle: String,
    pub channel: String,
}

/// Top-level E2E controller. One instance lives in `AppState` for the
/// lifetime of the process.
pub struct E2eManager {
    identity: Identity,
    keyring: Keyring,
    rate_limiter: Mutex<RateLimiter>,
    /// Configured replay-protection window (seconds). Plumbed in from
    /// `config.e2e.ts_tolerance_secs` at construction time so an operator
    /// can tighten or loosen the window per deployment without a rebuild.
    ts_tolerance_secs: i64,
    /// In-flight handshakes keyed by `(channel, keyreq_nonce)`. The
    /// responder doesn't echo the KEYREQ nonce in KEYRSP, so lookup in
    /// `handle_keyrsp` scans entries by channel.
    pending: Mutex<HashMap<(String, [u8; 16]), PendingHandshake>>,
    /// TOFU warnings generated by the handshake path (handle changed,
    /// fingerprint changed, or peer revoked). Drained by the IRC event
    /// dispatcher via `take_pending_trust_changes` and surfaced to the
    /// user as themed event messages.
    pending_trust_change: Mutex<Vec<PendingTrustNotice>>,
    /// Outbound REKEY CTCPs produced by a lazy rotation inside
    /// `encrypt_outgoing`. The input layer drains these right after each
    /// encrypt call and appends them to `AppState::pending_e2e_sends`,
    /// matching the KEYRSP dispatch pattern.
    pending_rekey_sends: Mutex<Vec<PendingRekeySend>>,
    /// Reciprocal KEYREQs queued by the symmetric-handshake path in
    /// `handle_keyreq`. The IRC event dispatcher drains these right
    /// after `handle_keyreq` returns and appends each entry to
    /// `AppState::pending_e2e_sends`, mirroring the KEYRSP dispatch.
    pending_outbound_keyreqs: Mutex<Vec<PendingOutboundKeyReq>>,
    /// Inbound KEYREQs cached in Normal mode when the peer is not yet
    /// trusted. Keyed by `(sender_handle, channel)`. Consumed by
    /// `accept_pending_inbound` when the user runs `/e2e accept`.
    pending_inbound: Mutex<HashMap<(String, String), PendingInboundKeyReq>>,
    /// Per-peer prompts surfaced when a Normal-mode KEYREQ is cached for
    /// later acceptance. Drained by `take_pending_accept_requests`.
    pending_accept_requests: Mutex<Vec<PendingAcceptRequest>>,
}

/// The outcome of evaluating a freshly-arrived peer identity against the
/// local keyring. Consumers use this to decide whether to accept the key,
/// show a warning, or hard-reject.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrustChange {
    /// Never seen this peer before — safe to TOFU.
    New,
    /// Exact same (fingerprint, handle) as before — continue trusting.
    Known,
    /// Same fingerprint, but the peer's handle has changed since last time.
    /// UI should emit `[E2E] notice: known key <fp> appeared under new handle`.
    /// Until `/e2e reverify` the session must NOT be auto-installed.
    HandleChanged {
        old_handle: String,
        new_handle: String,
        fingerprint: Fingerprint,
    },
    /// Same handle, but a DIFFERENT fingerprint is being offered. This is
    /// the SSH-style warning case — the sharpest possible alarm. Block and
    /// wait for `/e2e reverify`.
    FingerprintChanged {
        handle: String,
        old_fp: Fingerprint,
        new_fp: Fingerprint,
    },
    /// Previously revoked — do not re-trust on a new KEYREQ/KEYRSP. Only
    /// `/e2e unrevoke` can restore.
    Revoked {
        handle: String,
        fingerprint: Fingerprint,
    },
}

/// A TOFU warning ready to be surfaced to the user. `channel` identifies
/// the buffer the warning belongs in (always the handshake channel, i.e.
/// the value from the KEYREQ/KEYRSP payload — never the IRC target, which
/// is our own nick for CTCP NOTICEs).
///
/// `new_pubkey` is set on `FingerprintChanged` (and only on that variant)
/// so the `/e2e reverify` path can upsert the new identity directly from
/// the queued notice — otherwise we would have to re-read it off a peer
/// row that, by construction, does not exist yet because the rejected
/// KEYREQ never got to upsert it.
#[derive(Debug, Clone)]
pub struct PendingTrustNotice {
    pub handle: String,
    pub channel: String,
    pub change: TrustChange,
    pub new_pubkey: Option<[u8; 32]>,
}

/// Outcome of `E2eManager::reverify_peer`. The UI layer renders each
/// variant with a different message so the user knows whether their
/// `/e2e reverify <nick>` actually consumed a pending trust-change or
/// whether it was a cold purge of an existing identity.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReverifyOutcome {
    /// A `PendingTrustNotice` was waiting in the queue — the old peer row
    /// was deleted and the new pubkey was installed in its place. The
    /// caller should prompt the user to re-handshake under the new key.
    Applied {
        old_fp: Fingerprint,
        new_fp: Fingerprint,
    },
    /// No pending notice but the handle had existing keyring state;
    /// everything associated with `handle` (peer row, incoming sessions,
    /// outgoing recipients) has been deleted. `deleted` counts the rows
    /// removed so the UI can surface a short summary.
    Cleared { deleted: usize },
    /// Nothing to reverify — no pending notice AND no existing state
    /// for this handle.
    NotFound,
}

/// Outcome of attempting to decrypt an incoming wire-format line.
#[derive(Debug, Clone)]
pub enum DecryptOutcome {
    /// One plaintext fragment (single chunk). Callers concatenate across
    /// chunks at the UI layer; the protocol is stateless per chunk.
    Plaintext(String),
    /// The chunk is well-formed but we don't have a session key for this
    /// sender on this channel yet. Callers should consider sending a
    /// `KEYREQ` (subject to rate limiting).
    MissingKey { handle: String, channel: String },
    /// Security-level rejection: handle mismatch, replay window violation,
    /// AEAD failure, peer not trusted, etc. Callers should surface this as
    /// an e2e warning rather than as a normal message.
    Rejected(String),
}

impl E2eManager {
    fn clear_pending_state_for_handle(&self, handle: &str) -> usize {
        let mut deleted = 0usize;

        let mut pending = self.pending.lock().expect("e2e pending mutex poisoned");
        let pending_before = pending.len();
        pending.retain(|_, ph| ph.peer_handle.as_deref() != Some(handle));
        deleted += pending_before.saturating_sub(pending.len());
        drop(pending);

        let mut pending_inbound = self
            .pending_inbound
            .lock()
            .expect("e2e pending inbound mutex poisoned");
        let pending_inbound_before = pending_inbound.len();
        pending_inbound.retain(|(pending_handle, _), _| pending_handle != handle);
        deleted += pending_inbound_before.saturating_sub(pending_inbound.len());
        drop(pending_inbound);

        let mut pending_accept = self
            .pending_accept_requests
            .lock()
            .expect("e2e pending accept requests mutex poisoned");
        let accept_before = pending_accept.len();
        pending_accept.retain(|req| req.handle != handle);
        deleted += accept_before.saturating_sub(pending_accept.len());
        drop(pending_accept);

        let mut notices = self
            .pending_trust_change
            .lock()
            .expect("e2e pending trust change mutex poisoned");
        let notices_before = notices.len();
        notices.retain(|notice| notice.handle != handle);
        deleted += notices_before.saturating_sub(notices.len());
        drop(notices);

        let mut outbound = self
            .pending_outbound_keyreqs
            .lock()
            .expect("e2e pending outbound keyreqs mutex poisoned");
        let outbound_before = outbound.len();
        outbound.retain(|req| req.peer_handle != handle);
        deleted += outbound_before.saturating_sub(outbound.len());

        deleted
    }

    /// Load the identity row from the keyring, or generate and persist a
    /// fresh one if none exists. Uses the built-in default replay-window
    /// tolerance (`DEFAULT_TS_TOLERANCE_SECS`). Production callers should
    /// prefer [`Self::load_or_init_with_config`] to honor the operator's
    /// `config.e2e.ts_tolerance_secs` setting.
    pub fn load_or_init(keyring: Keyring) -> Result<Self> {
        Self::load_or_init_with_tolerance(keyring, DEFAULT_TS_TOLERANCE_SECS)
    }

    /// Like [`Self::load_or_init`] but reads the replay-window tolerance
    /// from the supplied `E2eConfig`. This is the entry point used by the
    /// main application at startup.
    pub fn load_or_init_with_config(
        keyring: Keyring,
        cfg: &crate::config::E2eConfig,
    ) -> Result<Self> {
        Self::load_or_init_with_tolerance(keyring, cfg.ts_tolerance_secs)
    }

    fn load_or_init_with_tolerance(keyring: Keyring, ts_tolerance_secs: i64) -> Result<Self> {
        let identity = if let Some((stored_pk, sk, stored_fp, _ts)) = keyring.load_identity()? {
            // Self-consistency check (G13): recompute the public key from
            // the stored secret and the fingerprint from that public key,
            // and reject the keyring if either column has drifted. Guards
            // against partial SQLite writes, manual edits to the identity
            // row, or library version drift in the Ed25519 backend.
            let id = Identity::from_secret_bytes(&sk);
            let computed_pk = id.public_bytes();
            if computed_pk != stored_pk {
                return Err(E2eError::Crypto(
                    "stored identity public key does not match secret — keyring may be corrupted"
                        .into(),
                ));
            }
            let computed_fp = fingerprint(&computed_pk);
            if computed_fp != stored_fp {
                return Err(E2eError::Crypto(
                    "stored identity fingerprint does not match pubkey — keyring may be corrupted"
                        .into(),
                ));
            }
            id
        } else {
            let id = Identity::generate()?;
            let pk = id.public_bytes();
            let sk = id.secret_bytes();
            let fp = fingerprint(&pk);
            let now = now_unix();
            keyring.save_identity(&pk, &sk, &fp, now)?;
            id
        };
        Ok(Self {
            identity,
            keyring,
            rate_limiter: Mutex::new(RateLimiter::new()),
            ts_tolerance_secs,
            pending: Mutex::new(HashMap::new()),
            pending_trust_change: Mutex::new(Vec::new()),
            pending_rekey_sends: Mutex::new(Vec::new()),
            pending_outbound_keyreqs: Mutex::new(Vec::new()),
            pending_inbound: Mutex::new(HashMap::new()),
            pending_accept_requests: Mutex::new(Vec::new()),
        })
    }

    /// Classify an incoming `(fingerprint, handle)` against the keyring.
    /// Read-only; never mutates. The ordering matters:
    ///
    /// 1. Look up by fingerprint. If present and revoked → `Revoked`.
    /// 2. Same fingerprint with a different `last_handle` → `HandleChanged`.
    /// 3. Same fingerprint and same handle → `Known`.
    /// 4. Fingerprint unknown, but the handle maps to a different
    ///    fingerprint in `e2e_peers` → `FingerprintChanged`.
    /// 5. Otherwise → `New` (safe to TOFU-pin).
    fn classify_peer_change(&self, new_fp: &Fingerprint, new_handle: &str) -> Result<TrustChange> {
        if let Some(existing) = self.keyring.get_peer_by_fingerprint(new_fp)? {
            if existing.global_status == TrustStatus::Revoked {
                return Ok(TrustChange::Revoked {
                    handle: new_handle.to_string(),
                    fingerprint: *new_fp,
                });
            }
            if existing.last_handle.as_deref() != Some(new_handle) {
                return Ok(TrustChange::HandleChanged {
                    old_handle: existing.last_handle.unwrap_or_default(),
                    new_handle: new_handle.to_string(),
                    fingerprint: *new_fp,
                });
            }
            return Ok(TrustChange::Known);
        }
        // Unknown fingerprint — check if the handle was previously owned by
        // a different identity.
        if let Some(existing) = self.keyring.get_peer_by_handle(new_handle)? {
            return Ok(TrustChange::FingerprintChanged {
                handle: new_handle.to_string(),
                old_fp: existing.fingerprint,
                new_fp: *new_fp,
            });
        }
        Ok(TrustChange::New)
    }

    /// Push a TOFU warning for later surfacing by the IRC dispatcher.
    fn record_trust_change(&self, notice: PendingTrustNotice) {
        self.pending_trust_change
            .lock()
            .expect("e2e pending trust change mutex poisoned")
            .push(notice);
    }

    /// Drain and return all pending TOFU warnings. The IRC event dispatcher
    /// calls this after every `handle_keyreq` / `handle_keyrsp` to surface
    /// the warnings to the user as themed event messages.
    pub fn take_pending_trust_changes(&self) -> Vec<PendingTrustNotice> {
        let mut guard = self
            .pending_trust_change
            .lock()
            .expect("e2e pending trust change mutex poisoned");
        std::mem::take(&mut *guard)
    }

    /// Reverify a peer after a fingerprint change.
    ///
    /// Looks for a `PendingTrustNotice` in the queue whose handle
    /// matches `nick_or_handle`. If one is found AND it carries the
    /// new pubkey (i.e. it was a `FingerprintChanged` notice threaded
    /// through the handshake path), this:
    ///   1. Deletes the old peer row by fingerprint.
    ///   2. Deletes every incoming-session row for the handle across
    ///      all channels — the old key is stale in every context.
    ///   3. Deletes every outgoing-recipient row for the handle so we
    ///      stop pushing our key at the evicted identity.
    ///   4. Upserts a brand-new peer row carrying the fingerprint and
    ///      pubkey extracted from the notice. The status is set to
    ///      `Trusted` because `/e2e reverify` IS the user's explicit
    ///      consent to the new key.
    ///   5. Clears the notice from the pending queue (and any other
    ///      queued notices for the same handle so the user is not
    ///      warned twice for a single reverification).
    ///
    /// If no pending notice is found — or a notice was found but it
    /// did not carry a new pubkey (a `HandleChanged` or `Revoked`
    /// warning, neither of which the reverify path knows how to apply
    /// automatically) — the handle is still purged from the keyring so
    /// a subsequent handshake starts cold. This is the destructive
    /// reverify path documented in the `/e2e reverify` help text.
    ///
    /// Returns `ReverifyOutcome::NotFound` only when there is neither a
    /// pending notice nor any existing keyring state for this handle.
    pub fn reverify_peer(&self, nick_or_handle: &str) -> Result<ReverifyOutcome> {
        // Drain the pending-notice queue into two partitions: notices
        // for `nick_or_handle` (consumed here) and everything else
        // (preserved). Within the consumed set we look for the first
        // FingerprintChanged variant whose notice also carries the new
        // pubkey — that's the only combination the automatic apply
        // path can act on without a second handshake.
        let mut notices_guard = self
            .pending_trust_change
            .lock()
            .expect("e2e pending trust change mutex poisoned");
        let mut applied: Option<(Fingerprint, Fingerprint, [u8; 32])> = None;
        let mut kept: Vec<PendingTrustNotice> = Vec::with_capacity(notices_guard.len());
        for notice in std::mem::take(&mut *notices_guard) {
            if notice.handle != nick_or_handle {
                kept.push(notice);
                continue;
            }
            if applied.is_none()
                && let TrustChange::FingerprintChanged { old_fp, new_fp, .. } = &notice.change
                && let Some(pk) = notice.new_pubkey
            {
                applied = Some((*old_fp, *new_fp, pk));
            }
            // Other match-handle notices are dropped (consumed) so we
            // don't surface a duplicate warning after the user has
            // already signalled consent via /e2e reverify.
        }
        *notices_guard = kept;
        drop(notices_guard);

        // Branch 1: we found a pending FingerprintChanged notice with
        // an attached pubkey. Install the new identity directly.
        if let Some((old_fp, new_fp, new_pubkey)) = applied {
            self.keyring.delete_peer_by_fingerprint(&old_fp)?;
            self.keyring
                .delete_incoming_sessions_for_handle(nick_or_handle)?;
            self.keyring
                .delete_outgoing_recipients_for_handle(nick_or_handle)?;
            self.clear_pending_state_for_handle(nick_or_handle);
            let now = now_unix();
            self.keyring.upsert_peer(&PeerRecord {
                fingerprint: new_fp,
                pubkey: new_pubkey,
                last_handle: Some(nick_or_handle.to_string()),
                last_nick: None,
                first_seen: now,
                last_seen: now,
                // Reverify is the user consenting to the NEW key.
                global_status: TrustStatus::Trusted,
            })?;
            return Ok(ReverifyOutcome::Applied { old_fp, new_fp });
        }

        // Branch 2: destructive purge. Remove every trace of this
        // handle so a subsequent handshake starts cold. If nothing is
        // found, return NotFound so the UI can warn the user.
        let mut deleted: usize = 0;
        if let Some(peer) = self.keyring.get_peer_by_handle(nick_or_handle)? {
            self.keyring.delete_peer_by_fingerprint(&peer.fingerprint)?;
            deleted += 1;
        }
        deleted += self
            .keyring
            .delete_incoming_sessions_for_handle(nick_or_handle)?;
        deleted += self
            .keyring
            .delete_outgoing_recipients_for_handle(nick_or_handle)?;
        deleted += self.clear_pending_state_for_handle(nick_or_handle);
        if deleted == 0 {
            Ok(ReverifyOutcome::NotFound)
        } else {
            Ok(ReverifyOutcome::Cleared { deleted })
        }
    }

    #[must_use]
    pub fn fingerprint(&self) -> Fingerprint {
        fingerprint(&self.identity.public_bytes())
    }

    #[must_use]
    pub fn keyring(&self) -> &Keyring {
        &self.keyring
    }

    #[must_use]
    pub fn identity_pub(&self) -> [u8; 32] {
        self.identity.public_bytes()
    }

    // ---------- encrypt outgoing ----------

    /// Encrypt `plaintext` for `channel` and return one wire-format line per
    /// chunk. Callers send these verbatim via PRIVMSG. Honors lazy rotation:
    /// if the outgoing session is flagged `pending_rotation`, a fresh key is
    /// generated first.
    pub fn encrypt_outgoing(&self, channel: &str, plaintext: &str) -> Result<Vec<String>> {
        let sk = self.get_or_generate_outgoing_key(channel)?;
        // `split_plaintext` refuses empty input outright (G13) and always
        // returns ≥1 chunks on success, so no zero-ciphertext chunk can
        // reach the wire. The `total == 0` branch below is a belt-and-
        // -suspenders invariant — it can only fire if `split_plaintext`
        // is later modified to permit empty input, at which point the
        // `build_aad` / wire format `part >= 1` invariant would already
        // be broken.
        let chunks = split_plaintext(plaintext)?;
        let total_usize = chunks.len();
        let total = u8::try_from(total_usize).map_err(|_| E2eError::ChunkLimit(u8::MAX))?;
        if total == 0 {
            return Err(E2eError::Wire("chunker produced zero chunks".into()));
        }
        let msgid = fresh_msgid();
        let ts = now_unix();
        let mut out = Vec::with_capacity(total_usize);
        for (idx, plain) in chunks.iter().enumerate() {
            // idx is in 0..total, so idx+1 fits in u8 because total ≤ u8::MAX.
            let part = u8::try_from(idx + 1).map_err(|_| E2eError::ChunkLimit(u8::MAX))?;
            let aad = build_aad(channel, msgid, ts, part, total);
            let (nonce, ct) = aead::encrypt(&sk, &aad, plain)?;
            let wire = WireChunk {
                msgid,
                ts,
                part,
                total,
                nonce,
                ciphertext: ct,
            };
            out.push(wire.encode()?);
        }
        Ok(out)
    }

    /// Return our outgoing session key for `channel`, creating one on
    /// first use. If the current session is flagged `pending_rotation`
    /// (spec §5.3 — set by `/e2e revoke` or `/e2e rotate`) a fresh key
    /// is generated AND a `REKEY` CTCP is queued for every remaining
    /// trusted peer on the channel. The queue is drained by the caller
    /// via `take_pending_rekey_sends`.
    fn get_or_generate_outgoing_key(&self, channel: &str) -> Result<SessionKey> {
        if let Some(sess) = self.keyring.get_outgoing_session(channel)?
            && !sess.pending_rotation
        {
            return Ok(sess.sk);
        }
        // Either no session yet, or the current one is flagged
        // `pending_rotation` (lazy rotate). Remember whether we need to
        // distribute before INSERT OR REPLACE clears the flag.
        let had_pending_rotation = self
            .keyring
            .get_outgoing_session(channel)?
            .is_some_and(|s| s.pending_rotation);
        let fresh = aead::generate_session_key()?;
        self.keyring
            .set_outgoing_session(channel, &fresh, now_unix())?;

        if had_pending_rotation {
            // Distribute the fresh key to every remaining recipient of
            // our outgoing key on this channel. The recipients table is
            // populated on each KEYRSP we serve and pruned on /e2e
            // revoke, so the list here is exactly the remaining peers
            // that need the new key (spec §5.3).
            let recipients = self.keyring.list_outgoing_recipients(channel)?;
            if !recipients.is_empty() {
                let mut queue = self
                    .pending_rekey_sends
                    .lock()
                    .expect("e2e pending rekey mutex poisoned");
                for (handle, fp) in recipients {
                    // Look up the peer row by fingerprint so we sign
                    // against the stored long-term Ed25519 pubkey.
                    let Some(peer) = self.keyring.get_peer_by_fingerprint(&fp)? else {
                        tracing::warn!(
                            "rekey: no peer row for fp={} (handle={}); skipping",
                            hex::encode(fp),
                            handle,
                        );
                        continue;
                    };
                    match self.build_rekey_for_peer(channel, &peer, &fresh) {
                        Ok(rk) => {
                            let body = format!("\x01{}\x01", encode_keyrekey(&rk));
                            queue.push(PendingRekeySend {
                                target_handle: handle,
                                notice_text: body,
                            });
                        }
                        Err(e) => {
                            tracing::warn!("rekey: build_rekey_for_peer failed for {handle}: {e}");
                        }
                    }
                }
            }
        }

        Ok(fresh)
    }

    /// Produce a `KeyRekey` CTCP delivering `new_sk` for `channel` to the
    /// given peer. A fresh X25519 ephemeral is generated; the peer's
    /// Ed25519 identity is converted to its Montgomery counterpart via
    /// the RFC 7748 birational map so the ECDH target is a stable key.
    fn build_rekey_for_peer(
        &self,
        channel: &str,
        peer: &PeerRecord,
        new_sk: &SessionKey,
    ) -> Result<KeyRekey> {
        // Fresh ephemeral X25519 from us (initiator of the distribution).
        let mut eph_sk_bytes = [0u8; 32];
        rand::fill(&mut eph_sk_bytes);
        let eph_sk = StaticSecret::from(eph_sk_bytes);
        let eph_pub = XPub::from(&eph_sk).to_bytes();

        // Derive the peer's X25519 public from their Ed25519 identity.
        let peer_x25519_pub = ecdh::ed25519_pub_to_x25519(&peer.pubkey)?;
        let shared = eph_sk.diffie_hellman(&XPub::from(peer_x25519_pub));

        // HKDF → 32-byte wrap key (same labels as the handshake path so
        // libsodium-based peers can reuse their existing wrap helpers,
        // with the `REKEY` info string acting as the domain separator).
        let info = rekey_info(channel);
        let hk = Hkdf::<Sha256>::new(Some(b"RPE2E01-WRAP"), shared.as_bytes());
        let mut wrap_key = [0u8; 32];
        hk.expand(info.as_bytes(), &mut wrap_key)
            .expect("hkdf expand 32 bytes never fails");

        let (wrap_nonce, wrap_ct) = aead::encrypt(&wrap_key, info.as_bytes(), new_sk)?;

        let mut nonce = [0u8; 16];
        rand::fill(&mut nonce);
        let pubkey = self.identity.public_bytes();
        let sig_payload =
            signed_keyrekey_payload(channel, &pubkey, &eph_pub, &wrap_nonce, &wrap_ct, &nonce);
        let sig_bytes = sig::sign(self.identity.signing_key(), &sig_payload);

        Ok(KeyRekey {
            channel: channel.to_string(),
            pubkey,
            eph_pub,
            wrap_nonce,
            wrap_ct,
            nonce,
            sig: sig_bytes,
        })
    }

    /// Drain the queue of REKEY CTCPs that accumulated during the most
    /// recent `encrypt_outgoing` rotation. The caller (`app::input`) drains
    /// right after encrypt and enqueues each entry into `pending_e2e_sends`.
    pub fn take_pending_rekey_sends(&self) -> Vec<PendingRekeySend> {
        let mut guard = self
            .pending_rekey_sends
            .lock()
            .expect("e2e pending rekey mutex poisoned");
        std::mem::take(&mut *guard)
    }

    /// Drain any reciprocal KEYREQs queued by the most recent
    /// `handle_keyreq` call via the symmetric-handshake path. The IRC
    /// event dispatcher calls this right after `handle_keyreq` returns
    /// and enqueues each entry on `AppState::pending_e2e_sends` for
    /// dispatch, mirroring the KEYRSP queueing pattern.
    pub fn take_pending_outbound_keyreqs(&self) -> Vec<PendingOutboundKeyReq> {
        let mut guard = self
            .pending_outbound_keyreqs
            .lock()
            .expect("e2e pending outbound keyreqs mutex poisoned");
        std::mem::take(&mut *guard)
    }

    /// Drain the queue of Normal-mode pending-accept prompts accumulated
    /// during `handle_keyreq` for surfacing to the user.
    pub fn take_pending_accept_requests(&self) -> Vec<PendingAcceptRequest> {
        let mut guard = self
            .pending_accept_requests
            .lock()
            .expect("e2e pending accept requests mutex poisoned");
        std::mem::take(&mut *guard)
    }

    pub fn forget_peer_on_channel(&self, handle: &str, channel: &str) -> Result<usize> {
        let mut deleted = usize::from(
            self.keyring
                .get_incoming_session(handle, channel)?
                .is_some(),
        );
        if deleted != 0 {
            self.keyring.delete_incoming_session(handle, channel)?;
        }
        let removed_pending_inbound = self
            .pending_inbound
            .lock()
            .expect("e2e pending inbound mutex poisoned")
            .remove(&(handle.to_string(), channel.to_string()))
            .is_some();
        deleted += usize::from(removed_pending_inbound);
        let mut requests = self
            .pending_accept_requests
            .lock()
            .expect("e2e pending accept requests mutex poisoned");
        let before = requests.len();
        requests.retain(|r| !(r.handle == handle && r.channel == channel));
        deleted += before.saturating_sub(requests.len());
        drop(requests);
        let mut pending = self.pending.lock().expect("e2e pending mutex poisoned");
        let pending_before = pending.len();
        pending.retain(|(pending_channel, _), ph| {
            !(pending_channel == channel && ph.peer_handle.as_deref() == Some(handle))
        });
        deleted += pending_before.saturating_sub(pending.len());
        drop(pending);
        let mut outbound = self
            .pending_outbound_keyreqs
            .lock()
            .expect("e2e pending outbound keyreqs mutex poisoned");
        let outbound_before = outbound.len();
        outbound.retain(|req| !(req.peer_handle == handle && req.channel == channel));
        deleted += outbound_before.saturating_sub(outbound.len());
        Ok(deleted)
    }

    pub fn forget_peer_everywhere(&self, handle: &str) -> Result<usize> {
        let mut deleted = 0usize;
        if let Some(peer) = self.keyring.get_peer_by_handle(handle)? {
            self.keyring.delete_peer_by_fingerprint(&peer.fingerprint)?;
            deleted += 1;
        }
        deleted += self.keyring.delete_incoming_sessions_for_handle(handle)?;
        deleted += self.keyring.delete_outgoing_recipients_for_handle(handle)?;
        deleted += self.clear_pending_state_for_handle(handle);
        Ok(deleted)
    }

    /// Consume an unsolicited REKEY CTCP from `sender_handle`. Verifies
    /// signature, runs TOFU classification, unwraps the session key using
    /// our Ed25519-derived X25519 secret, and installs it as the new
    /// trusted incoming session for `(sender_handle, channel)`.
    pub fn handle_rekey(&self, sender_handle: &str, rekey: &KeyRekey) -> Result<()> {
        // Verify signature first — no state touched until we're sure the
        // message is authentic against the pubkey it carries.
        let sig_payload = signed_keyrekey_payload(
            &rekey.channel,
            &rekey.pubkey,
            &rekey.eph_pub,
            &rekey.wrap_nonce,
            &rekey.wrap_ct,
            &rekey.nonce,
        );
        sig::verify(&rekey.pubkey, &sig_payload, &rekey.sig)?;

        // TOFU classification. A REKEY from a peer we've never seen is
        // rejected outright: legitimate lazy-rotate distribution always
        // happens to peers with whom we've already exchanged a handshake,
        // so an "unsolicited" REKEY from an unknown peer is either a
        // replay or an attacker.
        let new_fp = fingerprint(&rekey.pubkey);
        let change = self.classify_peer_change(&new_fp, sender_handle)?;
        match &change {
            TrustChange::New => {
                return Err(E2eError::Handshake(
                    "REKEY from unknown peer; ignoring".into(),
                ));
            }
            TrustChange::Known => {}
            TrustChange::HandleChanged { .. }
            | TrustChange::FingerprintChanged { .. }
            | TrustChange::Revoked { .. } => {
                let err = handle_mismatch_for(&change, sender_handle);
                // On FingerprintChanged we carry the rekey sender's
                // pubkey into the notice so `/e2e reverify` can install
                // it directly without waiting for a second handshake.
                let new_pubkey = matches!(change, TrustChange::FingerprintChanged { .. })
                    .then_some(rekey.pubkey);
                self.record_trust_change(PendingTrustNotice {
                    handle: sender_handle.to_string(),
                    channel: rekey.channel.clone(),
                    change,
                    new_pubkey,
                });
                return Err(err);
            }
        }

        // Derive our X25519 secret from our Ed25519 seed and complete ECDH.
        let my_seed = self.identity.secret_bytes();
        let my_x25519_scalar = ecdh::ed25519_seed_to_x25519(&my_seed);
        let my_sk = StaticSecret::from(my_x25519_scalar);
        let shared = my_sk.diffie_hellman(&XPub::from(rekey.eph_pub));
        let info = rekey_info(&rekey.channel);
        let hk = Hkdf::<Sha256>::new(Some(b"RPE2E01-WRAP"), shared.as_bytes());
        let mut wrap_key = [0u8; 32];
        hk.expand(info.as_bytes(), &mut wrap_key)
            .expect("hkdf expand 32 bytes never fails");

        let new_sk_bytes = aead::decrypt(
            &wrap_key,
            &rekey.wrap_nonce,
            info.as_bytes(),
            &rekey.wrap_ct,
        )?;
        if new_sk_bytes.len() != 32 {
            return Err(E2eError::Crypto(format!(
                "rekey sk has unexpected length {}",
                new_sk_bytes.len()
            )));
        }
        let mut new_sk = [0u8; 32];
        new_sk.copy_from_slice(&new_sk_bytes);

        let sess = IncomingSession {
            handle: sender_handle.to_string(),
            channel: rekey.channel.clone(),
            fingerprint: new_fp,
            sk: new_sk,
            status: TrustStatus::Trusted,
            created_at: now_unix(),
        };
        self.keyring.install_incoming_session_strict(&sess)?;
        Ok(())
    }

    // ---------- decrypt incoming ----------

    /// Decrypt an incoming wire-format line. `sender_handle` **must** come
    /// from the raw IRC `user@host` prefix captured by the IRC parser —
    /// never from a field inside the encrypted payload. Strict handle check
    /// is what binds a session key to an on-wire identity.
    pub fn decrypt_incoming(
        &self,
        sender_handle: &str,
        channel: &str,
        wire_line: &str,
    ) -> Result<DecryptOutcome> {
        let Some(wire) = WireChunk::parse(wire_line)? else {
            // Not an RPE2E01 line at all — caller should render it as the
            // plain IRC text.
            return Ok(DecryptOutcome::Plaintext(wire_line.to_string()));
        };

        // Replay window check (application layer, on top of AEAD). The
        // tolerance is a per-instance value plumbed in from
        // `config.e2e.ts_tolerance_secs` at construction time.
        let now = now_unix();
        let skew = (now - wire.ts).abs();
        if skew > self.ts_tolerance_secs {
            return Ok(DecryptOutcome::Rejected(format!(
                "ts outside tolerance window ({skew}s skew)"
            )));
        }

        let Some(sess) = self.keyring.get_incoming_session(sender_handle, channel)? else {
            return Ok(DecryptOutcome::MissingKey {
                handle: sender_handle.to_string(),
                channel: channel.to_string(),
            });
        };
        if sess.status != TrustStatus::Trusted {
            return Ok(DecryptOutcome::Rejected(format!(
                "peer not trusted (status={:?})",
                sess.status
            )));
        }

        let aad = build_aad(channel, wire.msgid, wire.ts, wire.part, wire.total);
        match aead::decrypt(&sess.sk, &wire.nonce, &aad, &wire.ciphertext) {
            Ok(pt) => match String::from_utf8(pt) {
                Ok(s) => Ok(DecryptOutcome::Plaintext(s)),
                Err(e) => Ok(DecryptOutcome::Rejected(format!("utf8: {e}"))),
            },
            Err(e) => Ok(DecryptOutcome::Rejected(format!("aead failed: {e}"))),
        }
    }

    // ---------- handshake initiator ----------

    /// Build a signed KEYREQ for `channel`, generating and stashing a fresh
    /// ephemeral X25519 secret. The secret is retrieved later in
    /// `handle_keyrsp` to derive the wrap key.
    pub fn build_keyreq(&self, channel: &str) -> Result<KeyReq> {
        self.build_keyreq_for_peer(channel, None)
    }

    pub fn build_keyreq_for_peer(
        &self,
        channel: &str,
        peer_handle: Option<&str>,
    ) -> Result<KeyReq> {
        // Multiple pending handshakes per channel are allowed: each is
        // keyed by `(channel, nonce)` in `self.pending`, and
        // `handle_keyrsp` iterates over matches, trying the stored
        // ephemeral secrets until one unwraps the KEYRSP successfully.
        //
        // Reasons this is not guarded:
        // 1. Legitimate re-handshake paths (user runs /e2e handshake
        //    twice, peer rejoins with a new host, /e2e reverify replay)
        //    need to create a fresh entry without tearing down older
        //    in-flight state.
        // 2. The `try_decrypt_e2e` self-echo auto-KEYREQ used to
        //    create a stale, unreachable entry that blocked subsequent
        //    reciprocal KEYREQs in `/e2e accept` — fixed at source in
        //    `handle_privmsg`, but even if something else leaks a
        //    stale entry in the future, the match-by-unwrap in
        //    `handle_keyrsp` makes it harmless.
        let mut nonce = [0u8; 16];
        rand::fill(&mut nonce);

        let mut eph_secret = [0u8; 32];
        rand::fill(&mut eph_secret);
        let eph_pub = {
            let sec = StaticSecret::from(eph_secret);
            XPub::from(&sec).to_bytes()
        };

        let pubkey = self.identity.public_bytes();
        let sig_payload = signed_keyreq_payload(channel, &pubkey, &eph_pub, &nonce);
        let sig_bytes = sig::sign(self.identity.signing_key(), &sig_payload);

        self.pending
            .lock()
            .expect("e2e pending mutex poisoned")
            .insert(
                (channel.to_string(), nonce),
                PendingHandshake {
                    channel: channel.to_string(),
                    peer_handle: peer_handle.map(ToOwned::to_owned),
                    eph_x25519_secret: eph_secret,
                },
            );

        Ok(KeyReq {
            channel: channel.to_string(),
            pubkey,
            eph_x25519: eph_pub,
            nonce,
            sig: sig_bytes,
        })
    }

    #[must_use]
    pub fn has_pending_keyreq(&self, channel: &str) -> bool {
        self.pending
            .lock()
            .expect("e2e pending mutex poisoned")
            .keys()
            .any(|(pending_channel, _)| pending_channel == channel)
    }

    /// Wrap a KEYREQ in CTCP framing ready for NOTICE dispatch.
    #[must_use]
    #[allow(
        clippy::unused_self,
        reason = "method form is kept for symmetry with the rest of the public API; \
                  future versions may bind per-instance state (e.g. NOTICE ID counters)"
    )]
    pub fn encode_keyreq_ctcp(&self, req: &KeyReq) -> String {
        format!("\x01{}\x01", encode_keyreq(req))
    }

    /// Rate-limit check for outgoing KEYREQ. Returns `true` if a request to
    /// `peer_handle` is allowed right now.
    pub fn allow_keyreq(&self, peer_handle: &str) -> bool {
        self.rate_limiter
            .lock()
            .expect("rate limiter mutex poisoned")
            .allow_outgoing(peer_handle)
    }

    /// Rate-limit check for incoming KEYREQ. Returns `true` if we should
    /// respond to a KEYREQ from `peer_handle` right now. Enforces spec
    /// §5.4 — max 3 KEYREQ per peer per minute, then a 5-minute backoff.
    /// Call this BEFORE any crypto work so a signature-flood is cheap to
    /// reject.
    pub fn allow_incoming_keyreq(&self, peer_handle: &str) -> bool {
        self.rate_limiter
            .lock()
            .expect("rate limiter mutex poisoned")
            .allow_incoming(peer_handle)
    }

    // ---------- handshake responder ----------

    /// Handle an incoming KEYREQ. Returns `Some(KeyRsp)` ready to send back
    /// via NOTICE if policy allows, `None` if the request is silently
    /// ignored (channel disabled / quiet mode) or cached for `/e2e accept`
    /// (Normal mode). Returns `Err` only on protocol-level problems
    /// (bad signature, malformed field, TOFU failure).
    /// Return the effective mode for `channel` with autotrust applied, or
    /// `Ok(None)` if the channel is disabled. Keeps `handle_keyreq` compact.
    fn effective_channel_mode(
        &self,
        sender_handle: &str,
        channel: &str,
    ) -> Result<Option<ChannelMode>> {
        let Some(ch_cfg) = self.keyring.get_channel_config(channel)? else {
            return Ok(None);
        };
        if !ch_cfg.enabled {
            return Ok(None);
        }
        if self.keyring.autotrust_matches(sender_handle, channel)? {
            return Ok(Some(ChannelMode::AutoAccept));
        }
        Ok(Some(ch_cfg.mode))
    }

    /// Upsert the peer row on a New/Known KEYREQ, or record a trust-change
    /// notice + bail out on HandleChanged/FingerprintChanged/Revoked.
    fn tofu_upsert_on_keyreq(
        &self,
        sender_handle: &str,
        sender_nick: Option<&str>,
        req: &KeyReq,
        fp: &Fingerprint,
    ) -> Result<()> {
        let now = now_unix();
        let change = self.classify_peer_change(fp, sender_handle)?;
        match change {
            TrustChange::New | TrustChange::Known => {
                let global_status = if matches!(change, TrustChange::Known) {
                    self.keyring
                        .get_peer_by_fingerprint(fp)?
                        .map_or(TrustStatus::Pending, |p| p.global_status)
                } else {
                    TrustStatus::Pending
                };
                let peer_rec = PeerRecord {
                    fingerprint: *fp,
                    pubkey: req.pubkey,
                    last_handle: Some(sender_handle.to_string()),
                    last_nick: sender_nick.map(ToOwned::to_owned),
                    first_seen: now,
                    last_seen: now,
                    global_status,
                };
                self.keyring.upsert_peer(&peer_rec)?;
                Ok(())
            }
            TrustChange::HandleChanged { .. }
            | TrustChange::FingerprintChanged { .. }
            | TrustChange::Revoked { .. } => {
                let err_msg = handle_mismatch_for(&change, sender_handle);
                // Same rationale as `handle_rekey`: on FingerprintChanged
                // we thread the KEYREQ's pubkey into the notice so that
                // `/e2e reverify <nick>` can install the new identity
                // directly after the user has compared SAS out-of-band.
                let new_pubkey =
                    matches!(change, TrustChange::FingerprintChanged { .. }).then_some(req.pubkey);
                self.record_trust_change(PendingTrustNotice {
                    handle: sender_handle.to_string(),
                    channel: req.channel.clone(),
                    change,
                    new_pubkey,
                });
                Err(err_msg)
            }
        }
    }

    /// Cache an incoming Normal-mode KEYREQ and emit a pending-accept
    /// prompt. Installs a `Pending` incoming-session row so `/e2e list`
    /// surfaces the peer, stashes the full KEYREQ for later
    /// `accept_pending_inbound`, and queues a `PendingAcceptRequest`.
    fn cache_pending_inbound_normal_mode(
        &self,
        sender_handle: &str,
        sender_nick: Option<&str>,
        req: &KeyReq,
        fp: &Fingerprint,
    ) {
        let pending_sess = IncomingSession {
            handle: sender_handle.to_string(),
            channel: req.channel.clone(),
            fingerprint: *fp,
            // Zero-filled placeholder. Row is `Pending`, so the decrypt
            // path rejects it before ever touching these bytes; it is
            // replaced on `/e2e accept` when the real KEYRSP session is
            // installed.
            sk: [0u8; 32],
            status: TrustStatus::Pending,
            created_at: now_unix(),
        };
        if let Err(e) = self.keyring.install_incoming_session_strict(&pending_sess) {
            tracing::warn!("normal-mode pending session install failed: {e}");
        }
        self.pending_inbound
            .lock()
            .expect("e2e pending inbound mutex poisoned")
            .insert(
                (sender_handle.to_string(), req.channel.clone()),
                PendingInboundKeyReq { req: req.clone() },
            );
        self.pending_accept_requests
            .lock()
            .expect("e2e pending accept mutex poisoned")
            .push(PendingAcceptRequest {
                nick: sender_nick.map(ToOwned::to_owned),
                handle: sender_handle.to_string(),
                channel: req.channel.clone(),
            });
    }

    pub fn handle_keyreq(&self, sender_handle: &str, req: &KeyReq) -> Result<Option<KeyRsp>> {
        self.handle_keyreq_with_nick(sender_handle, None, req)
    }

    pub fn handle_keyreq_with_nick(
        &self,
        sender_handle: &str,
        sender_nick: Option<&str>,
        req: &KeyReq,
    ) -> Result<Option<KeyRsp>> {
        // Rate-limit gate (spec §5.4). Must come BEFORE any crypto work so
        // a signature-flood from one peer is cheap to reject — we don't
        // even want to do the Ed25519 verify on traffic beyond the limit.
        if !self
            .rate_limiter
            .lock()
            .expect("rate limiter mutex poisoned")
            .allow_incoming(sender_handle)
        {
            return Err(E2eError::RateLimit(sender_handle.to_string()));
        }

        // Verify signature over the full KEYREQ payload, binding `eph_x25519`.
        let sig_payload =
            signed_keyreq_payload(&req.channel, &req.pubkey, &req.eph_x25519, &req.nonce);
        sig::verify(&req.pubkey, &sig_payload, &req.sig)?;

        // Channel config + autotrust mode promotion.
        let Some(effective_mode) = self.effective_channel_mode(sender_handle, &req.channel)? else {
            return Ok(None);
        };

        // TOFU classification: only safe outcomes (New / Known) proceed to
        // the upsert; warning outcomes record a PendingTrustNotice and bail.
        let fp = fingerprint(&req.pubkey);
        self.tofu_upsert_on_keyreq(sender_handle, sender_nick, req, &fp)?;

        // Policy gate (spec §5.2). Normal-mode unknown peers are cached
        // for later `/e2e accept`; Quiet-mode unknown peers are dropped.
        let already_trusted = self
            .keyring
            .get_incoming_session(sender_handle, &req.channel)?
            .is_some_and(|s| s.status == TrustStatus::Trusted);
        let allow = match effective_mode {
            ChannelMode::AutoAccept => true,
            ChannelMode::Normal => {
                if already_trusted {
                    true
                } else {
                    self.cache_pending_inbound_normal_mode(sender_handle, sender_nick, req, &fp);
                    return Ok(None);
                }
            }
            ChannelMode::Quiet => already_trusted,
        };
        if !allow {
            return Ok(None);
        }

        // Our outgoing channel key is what we wrap and hand the peer so
        // they can decrypt our future messages.
        let our_sk = self.get_or_generate_outgoing_key(&req.channel)?;

        // Record the peer as a recipient of our outgoing key on this
        // channel so the lazy-rotate distribution loop knows to push a
        // fresh key to them on the next `/e2e revoke` rotation.
        self.keyring.record_outgoing_recipient(
            &req.channel,
            sender_handle,
            &fingerprint(&req.pubkey),
            now_unix(),
        )?;

        // Fresh ephemeral X25519 keypair for ECDH with the initiator's
        // ephemeral public.
        let mut our_eph_secret = [0u8; 32];
        rand::fill(&mut our_eph_secret);
        let our_eph_sec = StaticSecret::from(our_eph_secret);
        let our_eph_pub = XPub::from(&our_eph_sec).to_bytes();

        // `info` (and AEAD `aad`) must be computable identically by both
        // sides. We deliberately omit `sender_handle` here because the
        // initiator does not know its own server-assigned handle at the
        // point it calls `handle_keyrsp`. The ephemeral X25519 keypairs
        // themselves bind the exchange to a specific peer.
        let info = wrap_info(&req.channel);
        let wrap_key = derive_wrap_key(&our_eph_sec, &req.eph_x25519, info.as_bytes());
        let (wrap_nonce, wrap_ct) = aead::encrypt(&wrap_key, info.as_bytes(), &our_sk)?;

        // Sign response. `pubkey` is our long-term Ed25519 identity; it is
        // bound into the signature so the initiator can verify the KEYRSP
        // against the exact pubkey the responder claims and TOFU-pin that
        // pubkey in one atomic step.
        let our_pubkey = self.identity.public_bytes();
        let mut rsp_nonce = [0u8; 16];
        rand::fill(&mut rsp_nonce);
        let sig_payload = signed_keyrsp_payload(
            &req.channel,
            &our_pubkey,
            &our_eph_pub,
            &wrap_nonce,
            &wrap_ct,
            &rsp_nonce,
        );
        let sig_bytes = sig::sign(self.identity.signing_key(), &sig_payload);

        // Symmetric handshake (spec §5.3, G13): after serving a KEYRSP
        // we also queue a reciprocal KEYREQ from us back to the peer so
        // the us→peer direction gets established in the same round-trip.
        // Skip when we already hold a trusted incoming session for this
        // peer on this channel — the peer has already completed their
        // own KEYREQ→KEYRSP in that direction and another reciprocal
        // would be pure churn. The outgoing rate limiter is an
        // additional backstop against mutual-KEYREQ loops: two clients
        // that both start ticking their reciprocals will each hit the
        // 30-second per-peer bucket and skip.
        let already_incoming = self
            .keyring
            .get_incoming_session(sender_handle, &req.channel)?
            .is_some_and(|s| s.status == TrustStatus::Trusted);
        let allow_out = self
            .rate_limiter
            .lock()
            .expect("rate limiter mutex poisoned")
            .allow_outgoing(sender_handle);
        if !already_incoming && allow_out {
            let reciprocal = self.build_keyreq_for_peer(&req.channel, Some(sender_handle))?;
            self.pending_outbound_keyreqs
                .lock()
                .expect("e2e pending outbound keyreqs mutex poisoned")
                .push(PendingOutboundKeyReq {
                    peer_handle: sender_handle.to_string(),
                    channel: req.channel.clone(),
                    req: reciprocal,
                });
        }

        Ok(Some(KeyRsp {
            channel: req.channel.clone(),
            pubkey: our_pubkey,
            ephemeral_pub: our_eph_pub,
            wrap_nonce,
            wrap_ct,
            nonce: rsp_nonce,
            sig: sig_bytes,
        }))
    }

    /// Complete a Normal-mode pending KEYREQ by running the full
    /// auto-accept branch against the cached request. Returns the
    /// generated `KeyRsp` so the caller can enqueue it for dispatch.
    ///
    /// Returns `Ok(None)` if no pending inbound KEYREQ exists for this
    /// `(handle, channel)` — the `/e2e accept` command uses that return
    /// shape to emit a "nothing to accept" error message.
    pub fn accept_pending_inbound(
        &self,
        sender_handle: &str,
        channel: &str,
    ) -> Result<Option<KeyRsp>> {
        let cached = {
            let mut guard = self
                .pending_inbound
                .lock()
                .expect("e2e pending inbound mutex poisoned");
            guard.remove(&(sender_handle.to_string(), channel.to_string()))
        };
        let Some(PendingInboundKeyReq { req }) = cached else {
            return Ok(None);
        };

        // Keep the placeholder incoming-session row as `Pending` until the
        // reciprocal KEYREQ/KEYRSP actually installs a real Bob→Alice
        // session. Marking the zero-filled placeholder `Trusted` would make
        // the decrypt path try to use it and reject legitimate traffic.
        self.build_keyrsp_for_accepted_request(sender_handle, &req)
            .map(Some)
    }

    /// Build a KEYRSP for an already-vetted KEYREQ (TOFU classification
    /// done, channel enabled, caller has decided the peer is trusted).
    /// Shared between the `AutoAccept` branch of `handle_keyreq` and the
    /// `accept_pending_inbound` path.
    fn build_keyrsp_for_accepted_request(
        &self,
        sender_handle: &str,
        req: &KeyReq,
    ) -> Result<KeyRsp> {
        // Re-upsert the peer row with Trusted status (Normal-mode cache
        // wrote Pending, and we are now promoting).
        let fp = fingerprint(&req.pubkey);
        let now = now_unix();
        let existing_peer = self.keyring.get_peer_by_fingerprint(&fp)?;
        let peer_rec = PeerRecord {
            fingerprint: fp,
            pubkey: req.pubkey,
            last_handle: Some(sender_handle.to_string()),
            last_nick: existing_peer.as_ref().and_then(|p| p.last_nick.clone()),
            first_seen: existing_peer.as_ref().map_or(now, |p| p.first_seen),
            last_seen: now,
            global_status: TrustStatus::Trusted,
        };
        self.keyring.upsert_peer(&peer_rec)?;

        // Our outgoing channel key — possibly triggers lazy rotate.
        let our_sk = self.get_or_generate_outgoing_key(&req.channel)?;

        // Record the peer as a recipient of our outgoing key (accept path).
        self.keyring
            .record_outgoing_recipient(&req.channel, sender_handle, &fp, now_unix())?;

        // Fresh ephemeral X25519 keypair for ECDH.
        let mut our_eph_secret = [0u8; 32];
        rand::fill(&mut our_eph_secret);
        let our_eph_sec = StaticSecret::from(our_eph_secret);
        let our_eph_pub = XPub::from(&our_eph_sec).to_bytes();

        let info = wrap_info(&req.channel);
        let wrap_key = derive_wrap_key(&our_eph_sec, &req.eph_x25519, info.as_bytes());
        let (wrap_nonce, wrap_ct) = aead::encrypt(&wrap_key, info.as_bytes(), &our_sk)?;

        let our_pubkey = self.identity.public_bytes();
        let mut rsp_nonce = [0u8; 16];
        rand::fill(&mut rsp_nonce);
        let sig_payload = signed_keyrsp_payload(
            &req.channel,
            &our_pubkey,
            &our_eph_pub,
            &wrap_nonce,
            &wrap_ct,
            &rsp_nonce,
        );
        let sig_bytes = sig::sign(self.identity.signing_key(), &sig_payload);

        // Mirror the AutoAccept path: after serving a KEYRSP we also queue a
        // reciprocal KEYREQ so the reverse direction converges without a
        // separate manual handshake. This is especially important for
        // Normal-mode `/e2e accept`, where the cached incoming-session row is
        // still only a pending placeholder until the reciprocal completes.
        //
        // `build_keyreq` allows multiple pending handshakes per channel,
        // and `handle_keyrsp` disambiguates by trying each ephemeral
        // secret until one unwraps the wrap_ct. That means a stale
        // pending entry (e.g. from an earlier auto-KEYREQ that never
        // got a response) does not block this reciprocal.
        let already_incoming = self
            .keyring
            .get_incoming_session(sender_handle, &req.channel)?
            .is_some_and(|s| s.status == TrustStatus::Trusted);
        let allow_out = self
            .rate_limiter
            .lock()
            .expect("rate limiter mutex poisoned")
            .allow_outgoing(sender_handle);
        if !already_incoming && allow_out {
            let reciprocal = self.build_keyreq_for_peer(&req.channel, Some(sender_handle))?;
            self.pending_outbound_keyreqs
                .lock()
                .expect("e2e pending outbound keyreqs mutex poisoned")
                .push(PendingOutboundKeyReq {
                    peer_handle: sender_handle.to_string(),
                    channel: req.channel.clone(),
                    req: reciprocal,
                });
        }

        Ok(KeyRsp {
            channel: req.channel.clone(),
            pubkey: our_pubkey,
            ephemeral_pub: our_eph_pub,
            wrap_nonce,
            wrap_ct,
            nonce: rsp_nonce,
            sig: sig_bytes,
        })
    }

    /// Wrap a KEYRSP in CTCP framing ready for NOTICE dispatch.
    #[must_use]
    #[allow(
        clippy::unused_self,
        reason = "method form is kept for symmetry with the rest of the public API"
    )]
    pub fn encode_keyrsp_ctcp(&self, rsp: &KeyRsp) -> String {
        format!("\x01{}\x01", encode_keyrsp(rsp))
    }

    /// Wrap a REKEY in CTCP framing ready for NOTICE dispatch. Used by
    /// tests; the `encrypt_outgoing` lazy-rotate path queues pre-wrapped
    /// bodies directly into `pending_rekey_sends`.
    #[must_use]
    #[allow(
        clippy::unused_self,
        reason = "method form is kept for symmetry with the rest of the public API"
    )]
    pub fn encode_keyrekey_ctcp(&self, rk: &KeyRekey) -> String {
        format!("\x01{}\x01", encode_keyrekey(rk))
    }

    // ---------- handshake initiator (KEYRSP consumer) ----------

    /// Find the pending handshake whose stored ephemeral secret unwraps
    /// `rsp.wrap_ct` against `rsp.ephemeral_pub`, consume it, and return
    /// the derived session key.
    ///
    /// Multiple pending handshakes per channel are allowed (see
    /// `build_keyreq`), so we iterate over every candidate for
    /// `rsp.channel` and try each one. The AEAD tag on `rsp.wrap_ct`
    /// discriminates the match — a wrong secret produces a
    /// tag-verification failure in `aead::decrypt` and we move on.
    /// Unrelated entries for the same channel stay in the map; they
    /// are still awaiting their own KEYRSPs.
    fn consume_matching_pending_for_keyrsp(&self, rsp: &KeyRsp) -> Result<[u8; 32]> {
        let info = wrap_info(&rsp.channel);
        let candidate_keys: Vec<(String, [u8; 16])> = {
            let pending = self.pending.lock().expect("e2e pending mutex poisoned");
            pending
                .iter()
                .filter(|(k, _)| k.0 == rsp.channel)
                .map(|(k, _)| k.clone())
                .collect()
        };
        if candidate_keys.is_empty() {
            return Err(E2eError::Handshake(
                "no pending handshake for channel".into(),
            ));
        }

        let matched = candidate_keys.iter().find_map(|key| {
            let eph_secret = {
                let pending = self.pending.lock().expect("e2e pending mutex poisoned");
                pending.get(key).map(|ph| ph.eph_x25519_secret)?
            };
            let our_sec = StaticSecret::from(eph_secret);
            let wrap_key = derive_wrap_key(&our_sec, &rsp.ephemeral_pub, info.as_bytes());
            let sk_bytes =
                aead::decrypt(&wrap_key, &rsp.wrap_nonce, info.as_bytes(), &rsp.wrap_ct).ok()?;
            if sk_bytes.len() != 32 {
                return None;
            }
            let mut sk_arr = [0u8; 32];
            sk_arr.copy_from_slice(&sk_bytes);
            Some((sk_arr, key.clone()))
        });
        let Some((sk, matched_key)) = matched else {
            return Err(E2eError::Crypto(
                "KEYRSP did not match any pending handshake for channel".into(),
            ));
        };
        {
            let mut pending = self.pending.lock().expect("e2e pending mutex poisoned");
            pending.remove(&matched_key);
        }
        Ok(sk)
    }

    /// Consume an incoming KEYRSP. The responder's long-term Ed25519 public
    /// key is carried on the wire inside `rsp.pubkey`; we verify the
    /// signature against it and simultaneously TOFU-pin that pubkey in the
    /// peer table. The initiator no longer needs to know the responder's
    /// pubkey out-of-band. We then look up our pending ephemeral secret,
    /// complete the ECDH, unwrap the session key, and install it as a
    /// *trusted* incoming session — the initiator already consented by
    /// having sent the KEYREQ in the first place.
    pub fn handle_keyrsp(&self, sender_handle: &str, rsp: &KeyRsp) -> Result<()> {
        // The pubkey the initiator will verify against is the one embedded
        // in the response. Signature binding on `rsp.pubkey` (see
        // `sig_payload_keyrsp`) means a MitM cannot swap it.
        let sender_pubkey = rsp.pubkey;

        // Verify signature first, before touching any state.
        let sig_payload = signed_keyrsp_payload(
            &rsp.channel,
            &sender_pubkey,
            &rsp.ephemeral_pub,
            &rsp.wrap_nonce,
            &rsp.wrap_ct,
            &rsp.nonce,
        );
        sig::verify(&sender_pubkey, &sig_payload, &rsp.sig)?;

        // Find and consume the matching pending handshake.
        let sk = self.consume_matching_pending_for_keyrsp(rsp)?;

        // TOFU classification on the responder's long-term pubkey. Only
        // New / Known outcomes auto-install a trusted session. Every other
        // outcome records a PendingTrustNotice and bails with
        // HandleMismatch so the caller surfaces a warning to the user.
        let fp = fingerprint(&sender_pubkey);
        let now = now_unix();
        let change = self.classify_peer_change(&fp, sender_handle)?;
        match change {
            TrustChange::New | TrustChange::Known => {
                self.keyring.upsert_peer(&PeerRecord {
                    fingerprint: fp,
                    pubkey: sender_pubkey,
                    last_handle: Some(sender_handle.to_string()),
                    last_nick: None,
                    first_seen: now,
                    last_seen: now,
                    // Sending our own KEYREQ is explicit consent — we
                    // always upgrade the peer to Trusted on a successful
                    // KEYRSP consumption. Known already-Trusted rows stay
                    // Trusted; brand-new peers become Trusted here.
                    global_status: TrustStatus::Trusted,
                })?;
            }
            TrustChange::HandleChanged { .. }
            | TrustChange::FingerprintChanged { .. }
            | TrustChange::Revoked { .. } => {
                let err = handle_mismatch_for(&change, sender_handle);
                let new_pubkey = matches!(change, TrustChange::FingerprintChanged { .. })
                    .then_some(sender_pubkey);
                self.record_trust_change(PendingTrustNotice {
                    handle: sender_handle.to_string(),
                    channel: rsp.channel.clone(),
                    change,
                    new_pubkey,
                });
                return Err(err);
            }
        }

        // Install the session under strict TOFU. A second KEYRSP for the
        // same (handle, channel) with a different fingerprint gets
        // rejected here as a second line of defense — the classifier above
        // already caught the common case via `e2e_peers` lookup, but
        // `install_incoming_session_strict` also blocks the edge case
        // where the peer row is fresh/inconsistent but the incoming
        // session row carries a different fingerprint.
        let sess = IncomingSession {
            handle: sender_handle.to_string(),
            channel: rsp.channel.clone(),
            fingerprint: fp,
            sk,
            status: TrustStatus::Trusted,
            created_at: now,
        };
        if let Err(e) = self.keyring.install_incoming_session_strict(&sess) {
            if matches!(e, E2eError::HandleMismatch { .. }) {
                // Surface as a FingerprintChanged notice.
                let existing_fp = self
                    .keyring
                    .get_incoming_session(sender_handle, &rsp.channel)?
                    .map(|s| s.fingerprint)
                    .unwrap_or_default();
                self.record_trust_change(PendingTrustNotice {
                    handle: sender_handle.to_string(),
                    channel: rsp.channel.clone(),
                    change: TrustChange::FingerprintChanged {
                        handle: sender_handle.to_string(),
                        old_fp: existing_fp,
                        new_fp: fp,
                    },
                    new_pubkey: Some(sender_pubkey),
                });
            }
            return Err(e);
        }
        Ok(())
    }
}

/// Build the `E2eError::HandleMismatch` that corresponds to a rejected
/// `TrustChange`. The `expected`/`got` fields carry a human-readable
/// summary that the IRC dispatcher ignores — it prefers the richer
/// `PendingTrustNotice`. This error exists so that callers which aren't
/// the IRC dispatcher (tests, library consumers) still see a distinct
/// Err(...) return rather than a silent success.
fn handle_mismatch_for(change: &TrustChange, got_handle: &str) -> E2eError {
    let expected = match change {
        TrustChange::HandleChanged { old_handle, .. } => old_handle.clone(),
        TrustChange::FingerprintChanged { handle, old_fp, .. } => {
            format!("{handle} (fp={})", hex::encode(old_fp))
        }
        TrustChange::Revoked {
            handle,
            fingerprint,
        } => {
            format!("{handle} (revoked, fp={})", hex::encode(fingerprint))
        }
        TrustChange::New | TrustChange::Known => String::new(),
    };
    E2eError::HandleMismatch {
        expected,
        got: got_handle.to_string(),
    }
}

/// Canonical HKDF `info` / AEAD `aad` for the handshake wrap step. Must be
/// computable identically by both initiator and responder from data they
/// already have. We bind only the channel; the ephemeral X25519 keypairs
/// already bind the exchange to a specific peer pair.
fn wrap_info(channel: &str) -> String {
    format!("RPE2E01-WRAP:{channel}")
}

/// Canonical HKDF `info` / AEAD `aad` for the REKEY distribution wrap.
/// The distinct `REKEY` prefix is a hard domain separator so a leaked
/// KEYRSP wrap cannot be replayed as a REKEY (and vice versa) even if an
/// attacker somehow produced the same ECDH shared.
fn rekey_info(channel: &str) -> String {
    format!("RPE2E01-REKEY:{channel}")
}

/// Derive a 32-byte wrap key from a pair of ephemeral X25519 keys. Matches
/// the same HKDF construction used by `crypto::ecdh::EphemeralKeypair`.
fn derive_wrap_key(secret: &StaticSecret, peer_pub_bytes: &[u8; 32], info: &[u8]) -> [u8; 32] {
    let peer_pub = XPub::from(*peer_pub_bytes);
    let shared = secret.diffie_hellman(&peer_pub);
    let hk = Hkdf::<Sha256>::new(Some(b"RPE2E01-WRAP"), shared.as_bytes());
    let mut okm = [0u8; 32];
    hk.expand(info, &mut okm)
        .expect("hkdf expand 32 bytes never fails for OKM ≤ 255 * HashLen");
    okm
}

fn now_unix() -> i64 {
    // SystemTime can technically be before UNIX_EPOCH on misconfigured
    // systems; in that case we fall back to 0. The replay-window check
    // works relatively, so a consistent-but-wrong clock still keeps both
    // sides in sync. AEAD is the real authenticity guarantee.
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::e2e::keyring::Keyring;
    use rusqlite::Connection;
    use std::sync::{Arc, Mutex as StdMutex};

    const SCHEMA: &str = "
        CREATE TABLE e2e_identity (id INTEGER PRIMARY KEY CHECK (id = 1), pubkey BLOB NOT NULL, privkey BLOB NOT NULL, fingerprint BLOB NOT NULL, created_at INTEGER NOT NULL);
        CREATE TABLE e2e_peers (fingerprint BLOB PRIMARY KEY, pubkey BLOB NOT NULL, last_handle TEXT, last_nick TEXT, first_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL, global_status TEXT NOT NULL DEFAULT 'pending');
        CREATE TABLE e2e_outgoing_sessions (channel TEXT PRIMARY KEY, sk BLOB NOT NULL, created_at INTEGER NOT NULL, pending_rotation INTEGER NOT NULL DEFAULT 0);
        CREATE TABLE e2e_incoming_sessions (handle TEXT NOT NULL, channel TEXT NOT NULL, fingerprint BLOB NOT NULL, sk BLOB NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at INTEGER NOT NULL, PRIMARY KEY (handle, channel));
        CREATE TABLE e2e_channel_config (channel TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 0, mode TEXT NOT NULL DEFAULT 'normal');
        CREATE TABLE e2e_autotrust (id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, handle_pattern TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(scope, handle_pattern));
        CREATE TABLE e2e_outgoing_recipients (channel TEXT NOT NULL, handle TEXT NOT NULL, fingerprint BLOB NOT NULL, first_sent_at INTEGER NOT NULL, PRIMARY KEY (channel, handle));
    ";

    fn make_manager() -> E2eManager {
        let conn = Connection::open_in_memory().unwrap();
        conn.execute_batch(SCHEMA).unwrap();
        let kr = Keyring::new(Arc::new(StdMutex::new(conn)));
        E2eManager::load_or_init(kr).unwrap()
    }

    #[test]
    fn load_or_init_persists_identity() {
        let conn = Connection::open_in_memory().unwrap();
        conn.execute_batch(SCHEMA).unwrap();
        let shared = Arc::new(StdMutex::new(conn));

        let kr1 = Keyring::new(shared.clone());
        let m1 = E2eManager::load_or_init(kr1).unwrap();
        let pk1 = m1.identity_pub();

        let kr2 = Keyring::new(shared);
        let m2 = E2eManager::load_or_init(kr2).unwrap();
        let pk2 = m2.identity_pub();

        assert_eq!(pk1, pk2, "identity must persist across loads");
    }

    #[test]
    fn build_keyreq_stores_pending_and_is_signed() {
        let mgr = make_manager();
        let req = mgr.build_keyreq("#x").unwrap();
        // Signature verifies with the same pubkey embedded in the KEYREQ.
        let payload = signed_keyreq_payload("#x", &req.pubkey, &req.eph_x25519, &req.nonce);
        sig::verify(&req.pubkey, &payload, &req.sig).unwrap();
        // Pending map has exactly one entry.
        assert_eq!(
            mgr.pending
                .lock()
                .expect("pending mutex poisoned in test")
                .len(),
            1
        );
    }

    #[test]
    fn build_keyreq_allows_multiple_in_flight_per_channel() {
        // Multiple pending handshakes per channel are intentionally
        // allowed — `handle_keyrsp` disambiguates by trying each
        // stored ephemeral secret against the incoming wrap_ct. A
        // strict single-pending guard would break legitimate
        // re-handshake flows and block reciprocals in `/e2e accept`
        // when a stale self-targeted auto-KEYREQ is lingering.
        let mgr = make_manager();
        let req1 = mgr.build_keyreq("#x").unwrap();
        let req2 = mgr.build_keyreq("#x").unwrap();
        assert_ne!(req1.nonce, req2.nonce, "each build should fresh-nonce");
        assert!(mgr.has_pending_keyreq("#x"));
    }

    #[test]
    fn encrypt_decrypt_requires_session() {
        let mgr = make_manager();
        // No incoming session installed → decrypt_incoming returns MissingKey.
        let wire = mgr.encrypt_outgoing("#x", "hi").unwrap().remove(0);
        let outcome = mgr.decrypt_incoming("~alice@host", "#x", &wire).unwrap();
        match outcome {
            DecryptOutcome::MissingKey { .. } => {}
            other => panic!("expected MissingKey, got {other:?}"),
        }
    }

    #[test]
    fn forget_peer_everywhere_clears_handle_scoped_pending_state() {
        let mgr = make_manager();
        let _ = mgr.build_keyreq_for_peer("#x", Some("~bob@host")).unwrap();
        mgr.pending_inbound
            .lock()
            .expect("pending inbound mutex poisoned in test")
            .insert(
                ("~bob@host".to_string(), "#x".to_string()),
                PendingInboundKeyReq {
                    req: mgr.build_keyreq("#x").unwrap(),
                },
            );
        mgr.pending_accept_requests
            .lock()
            .expect("pending accept mutex poisoned in test")
            .push(PendingAcceptRequest {
                nick: Some("bob".to_string()),
                handle: "~bob@host".to_string(),
                channel: "#x".to_string(),
            });
        mgr.pending_outbound_keyreqs
            .lock()
            .expect("pending outbound mutex poisoned in test")
            .push(PendingOutboundKeyReq {
                peer_handle: "~bob@host".to_string(),
                channel: "#x".to_string(),
                req: mgr.build_keyreq("#x").unwrap(),
            });

        let deleted = mgr.forget_peer_everywhere("~bob@host").unwrap();
        assert!(deleted >= 4, "expected multiple pending rows removed");
        assert!(
            mgr.pending
                .lock()
                .expect("pending mutex poisoned in test")
                .values()
                .all(|p| p.peer_handle.as_deref() != Some("~bob@host"))
        );
        assert!(
            mgr.pending_inbound
                .lock()
                .expect("pending inbound mutex poisoned in test")
                .is_empty()
        );
        assert!(mgr.take_pending_accept_requests().is_empty());
        assert!(mgr.take_pending_outbound_keyreqs().is_empty());
    }
}
