//! `ChatManager` — the central coordinator for chat-v1 DM sessions.
//!
//! The manager holds per-peer session state and orchestrates ECDH key
//! agreement, message encryption, statement assembly, and inbound
//! statement dispatch. All I/O is delegated to injected trait adapters so
//! the crate has no direct I/O dependencies.

use std::collections::HashMap;
use std::sync::{Arc, Mutex};

use zeroize::Zeroizing;

use crate::{
    crypto,
    error::ChatV1Error,
    events::{ChatMessageEvent, ContentType, DeliveryStatus, DeliveryStatusEvent},
    session::{ConversationState, PeerSession, MAX_SEEN_MESSAGE_IDS},
    traits::{ChatEventSink, ChatKeyResolver, ChatTransport},
};
use host_encoding::statement_store::{
    assemble_statement, build_signing_payload, decode_statement, extract_signing_payload,
};
use host_wallet::HostSigner;

/// Maximum plaintext message size (64 KiB).
const MAX_MESSAGE_BYTES: usize = 65_536;

/// Derivation path used by the chat identity key on both local and peer.
const CHAT_KEY_PATH: &str = "//wallet//chat";

/// The main entry point for the chat-v1 session management layer.
///
/// `ChatManager` is generic over the three injected adapters so hosts can
/// provide platform-specific implementations without coupling this crate to
/// any I/O runtime.
pub struct ChatManager<T: ChatTransport, K: ChatKeyResolver, E: ChatEventSink> {
    /// sr25519 account ID for our local chat identity.
    local_account_id: [u8; 32],

    /// P-256 private scalar (zeroized on drop).
    local_p256_secret: Zeroizing<[u8; 32]>,

    /// Uncompressed P-256 public key (65 bytes, 0x04 || X || Y).
    local_p256_public: [u8; 65],

    /// Per-peer session table, keyed by the peer's 32-byte account ID.
    sessions: Mutex<HashMap<[u8; 32], PeerSession>>,

    /// Signer used to produce sr25519 signatures for outbound statements and ACKs.
    signer: Arc<dyn HostSigner>,

    transport: T,
    key_resolver: K,
    event_sink: E,
}

impl<T: ChatTransport, K: ChatKeyResolver, E: ChatEventSink> ChatManager<T, K, E> {
    /// Construct a new manager with the local identity and injected adapters.
    ///
    /// `local_p256_secret` must be the 32-byte scalar derived from the chat
    /// sr25519 key via `host-wallet::crypto::p256_from_sr25519_scalar`.
    ///
    /// `signer` is used to produce sr25519 signatures for all outbound
    /// statements and ACKs without exposing private key material.
    pub fn new(
        local_account_id: [u8; 32],
        local_p256_secret: Zeroizing<[u8; 32]>,
        local_p256_public: [u8; 65],
        signer: Arc<dyn HostSigner>,
        transport: T,
        key_resolver: K,
        event_sink: E,
    ) -> Self {
        Self {
            local_account_id,
            local_p256_secret,
            local_p256_public,
            sessions: Mutex::new(HashMap::new()),
            signer,
            transport,
            key_resolver,
            event_sink,
        }
    }

    /// Return the local chat identity as `(account_id, p256_public)`.
    pub fn get_identity(&self) -> ([u8; 32], [u8; 65]) {
        (self.local_account_id, self.local_p256_public)
    }

    /// Open a conversation with a peer. Idempotent if already open.
    ///
    /// 1. Resolve the peer's P-256 public key via `key_resolver`.
    /// 2. Perform ECDH → HKDF to derive a 32-byte shared secret.
    /// 3. Derive directional session IDs and channel topics.
    /// 4. Subscribe to the peer's inbound channels via `transport`.
    /// 5. Store the session (no-op if a session already exists).
    pub fn open_conversation(&self, peer_account_id: [u8; 32]) -> Result<(), ChatV1Error> {
        {
            let sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
            if sessions.contains_key(&peer_account_id) {
                log::debug!("open_conversation: session already exists, returning early");
                return Ok(());
            }
        }

        // Resolve peer's P-256 public key from chain.
        let peer_pub_bytes = self
            .key_resolver
            .resolve_p256_pubkey(&peer_account_id)
            .map_err(ChatV1Error::PeerKeyResolutionFailed)?;

        // Parse P-256 keys; the local secret is a raw scalar.
        let local_secret = p256::SecretKey::from_slice(self.local_p256_secret.as_ref())
            .map_err(|_| ChatV1Error::InvalidP256PublicKey)?;
        let peer_public = p256::PublicKey::from_sec1_bytes(&peer_pub_bytes)
            .map_err(|_| ChatV1Error::InvalidP256PublicKey)?;

        // ECDH → raw 32-byte x-coordinate.
        let ecdh_raw = p256_ecdh_raw(&local_secret, &peer_public);

        // HKDF-SHA256 to stretch into a proper symmetric key.
        let shared_secret = hkdf_chat(&ecdh_raw);

        // Derive directional session IDs.
        // By convention the lexicographically-smaller account ID is the initiator
        // so that both sides independently derive the same pair of session IDs.
        let (initiator, responder) = if self.local_account_id <= peer_account_id {
            (self.local_account_id, peer_account_id)
        } else {
            (peer_account_id, self.local_account_id)
        };

        // out_session_id: local is the first party in the initiator/responder ordering.
        let local_is_initiator = self.local_account_id <= peer_account_id;

        let session_id_forward = crypto::derive_session_id(
            &shared_secret,
            &initiator,
            &responder,
            CHAT_KEY_PATH,
            CHAT_KEY_PATH,
        );
        let session_id_reverse = crypto::derive_session_id(
            &shared_secret,
            &responder,
            &initiator,
            CHAT_KEY_PATH,
            CHAT_KEY_PATH,
        );

        // Assign out/in based on who is locally the initiator.
        let (out_session_id, in_session_id) = if local_is_initiator {
            (session_id_forward, session_id_reverse)
        } else {
            (session_id_reverse, session_id_forward)
        };

        let request_channel = crypto::derive_channel(&out_session_id, "request");
        let response_channel = crypto::derive_channel(&out_session_id, "response");
        let peer_request_channel = crypto::derive_channel(&in_session_id, "request");
        let peer_response_channel = crypto::derive_channel(&in_session_id, "response");

        // Subscribe to both of the peer's inbound channels.
        let subscribe_topics = [peer_request_channel, peer_response_channel];
        self.transport
            .subscribe(
                &subscribe_topics,
                Box::new(|_raw| {
                    // Deliveries are routed through `handle_incoming_statement`; this
                    // callback is left empty here because the host calls that method
                    // directly with raw bytes from the transport layer.
                }),
            )
            .map_err(ChatV1Error::SubscribeFailed)?;

        let session = PeerSession {
            peer_account_id,
            shared_secret: Zeroizing::new(shared_secret),
            out_session_id,
            in_session_id,
            request_channel,
            response_channel,
            peer_request_channel,
            peer_response_channel,
            seen_ids_order: std::collections::VecDeque::with_capacity(MAX_SEEN_MESSAGE_IDS),
            seen_ids_set: std::collections::HashSet::with_capacity(MAX_SEEN_MESSAGE_IDS),
            state: ConversationState::Active,
            pending_request_id: None,
        };

        let mut sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
        sessions.insert(peer_account_id, session);
        Ok(())
    }

    /// Send a text message to a peer.
    ///
    /// # Steps
    /// 1. Validate text length.
    /// 2. Encode the message payload (content-type byte + UTF-8 text).
    /// 3. Encrypt with AES-256-GCM using the session shared secret.
    /// 4. Build and sign a statement payload.
    /// 5. Assemble the SCALE statement and submit via transport.
    /// 6. Push a `Sent` delivery status event.
    ///
    /// Returns the UUID message ID assigned to this message.
    pub fn send_message(
        &self,
        peer_account_id: [u8; 32],
        text: &str,
    ) -> Result<String, ChatV1Error> {
        let text_bytes = text.as_bytes();
        if text_bytes.len() > MAX_MESSAGE_BYTES {
            return Err(ChatV1Error::MessageTooLong {
                size: text_bytes.len(),
                max: MAX_MESSAGE_BYTES,
            });
        }

        // Copy what we need from the session, then drop the lock BEFORE signing.
        // This prevents blocking concurrent session access during signing.
        let (shared_secret, channel) = {
            let sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
            let session = sessions
                .get(&peer_account_id)
                .ok_or_else(|| ChatV1Error::NoOpenConversation(hex_account(&peer_account_id)))?;
            (
                zeroize::Zeroizing::new(*session.shared_secret),
                session.request_channel,
            )
        };

        let message_id = uuid::Uuid::new_v4().to_string();

        let plaintext = build_message_payload(ContentType::Text, &message_id, text_bytes);
        let ciphertext = crypto::encrypt(&shared_secret, &plaintext);

        let now_secs = current_unix_secs();
        let (payload, num_fields) =
            build_signing_payload(now_secs, None, Some(&channel), 0, &[], &ciphertext)
                .map_err(ChatV1Error::StatementSubmitFailed)?;

        let sig = self
            .signer
            .sign(&payload)
            .map_err(|e| ChatV1Error::StatementSubmitFailed(e.to_string()))?;

        let encoded = assemble_statement(&payload, num_fields, &self.local_account_id, &sig);

        self.transport
            .submit_statement(&encoded)
            .map_err(ChatV1Error::StatementSubmitFailed)?;

        self.event_sink.push_delivery_status(DeliveryStatusEvent {
            message_id: message_id.clone(),
            status: DeliveryStatus::Sent,
        });

        Ok(message_id)
    }

    /// Return the current conversation state for a peer (or `None` if no session).
    pub fn get_conversation_state(&self, peer_account_id: &[u8; 32]) -> ConversationState {
        self.sessions
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .get(peer_account_id)
            .map(|s| s.state.clone())
            .unwrap_or(ConversationState::None)
    }

    /// Process a raw statement delivered by the transport layer.
    ///
    /// The host should call this method whenever a subscribed topic fires.
    ///
    /// # Steps
    /// 1. Decode the SCALE statement.
    /// 2. Match the channel to a known session.
    /// 3. Verify the sr25519 signature and that the signer is the expected peer.
    /// 4. Reject replays via expiry timestamp.
    /// 5. Decrypt the data payload with the session secret.
    /// 6. Decode the content type and dispatch a `ChatMessageEvent`.
    /// 7. Auto-send an acknowledgement on the peer's response channel.
    pub fn handle_incoming_statement(&self, raw: &[u8]) -> Result<(), ChatV1Error> {
        let statement = decode_statement(raw)
            .map_err(|e| ChatV1Error::MalformedStatement(format!("decode: {e}")))?;

        let incoming_channel = match statement.channel {
            Some(ch) => ch,
            None => return Ok(()), // not a channel-addressed statement; ignore
        };

        // Find which session this channel belongs to.
        let mut sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
        let session = sessions.values_mut().find(|s| {
            s.peer_request_channel == incoming_channel
                || s.peer_response_channel == incoming_channel
        });

        let session = match session {
            Some(s) => s,
            None => return Ok(()), // unknown channel; drop silently
        };

        // Verify the sr25519 proof BEFORE decrypting to prevent forgery.
        // A captured AES session key is not sufficient to inject messages
        // without also controlling the peer's sr25519 signing key.
        let expected_peer = session.peer_account_id;
        verify_statement_proof(raw, &statement, &expected_peer)?;

        // Decrypt the data field.
        let plaintext = crypto::decrypt(&session.shared_secret, &statement.data)
            .map_err(ChatV1Error::DecryptionFailed)?;

        // Parse the message payload: content_type (1B) || message_id (UUID, 36B) || 0x00 || text.
        let (content_type, message_id, text) = parse_message_payload(&plaintext);

        // Replay protection (CHAT-002): reject messages with already-seen IDs.
        // Note: the message ID is inside the encrypted payload, so we must decrypt
        // before we can check. This is a known trade-off — an attacker replaying a
        // captured ciphertext causes one AES-GCM decrypt before rejection.
        if session.seen_ids_set.contains(&message_id) {
            log::debug!("chat-v1: replay detected for message_id={message_id}, discarding");
            return Ok(());
        }
        // Add to the ring buffer, evicting the oldest entry when full.
        if session.seen_ids_order.len() >= MAX_SEEN_MESSAGE_IDS {
            if let Some(evicted) = session.seen_ids_order.pop_front() {
                session.seen_ids_set.remove(&evicted);
            }
        }
        session.seen_ids_order.push_back(message_id.clone());
        session.seen_ids_set.insert(message_id.clone());

        let peer_account_id = session.peer_account_id;
        let response_channel = session.response_channel;
        let shared_secret = zeroize::Zeroizing::new(*session.shared_secret);

        drop(sessions);

        // Push decrypted message to the SPA.
        self.event_sink.push_message(ChatMessageEvent {
            peer_account_id,
            message_id: message_id.clone(),
            timestamp: current_unix_secs(),
            content_type,
            text,
            request_id: None,
            referenced_message_id: None,
            emoji: None,
            amount: None,
        });

        // Auto-acknowledge: send a minimal ACK statement on our response channel.
        self.send_ack(&response_channel, &shared_secret, &message_id)
            .unwrap_or_else(|e| {
                log::warn!("handle_incoming_statement: ACK failed: {e}");
            });

        Ok(())
    }

    /// Return a copy of `(shared_secret, peer_request_channel)` for a given peer.
    ///
    /// Used by subscription_handler tests when chain-subscription feature is active.
    #[cfg(test)]
    #[allow(dead_code)] // only used with chain-subscription feature
    pub(crate) fn test_session_crypto(
        &self,
        peer_id: &[u8; 32],
    ) -> Option<(zeroize::Zeroizing<[u8; 32]>, [u8; 32])> {
        let sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
        sessions.get(peer_id).map(|s| {
            (
                zeroize::Zeroizing::new(*s.shared_secret),
                s.peer_request_channel,
            )
        })
    }

    /// Zeroize and remove all per-peer sessions.
    ///
    /// Call this when the wallet is locked. The `Zeroizing` wrapper on
    /// `shared_secret` ensures memory is erased on drop.
    pub fn clear_sessions(&self) {
        let mut sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
        sessions.clear();
    }

    // -----------------------------------------------------------------------
    // Private helpers
    // -----------------------------------------------------------------------

    /// Send a minimal ACK on the response channel so the peer can mark delivery.
    fn send_ack(
        &self,
        channel: &[u8; 32],
        shared_secret: &[u8; 32],
        acked_message_id: &str,
    ) -> Result<(), ChatV1Error> {
        // ACK payload: content_type=ChatAccepted (0x02) + acked message ID.
        let payload_pt = build_message_payload(ContentType::ChatAccepted, acked_message_id, &[]);
        let ciphertext = crypto::encrypt(shared_secret, &payload_pt);

        let now_secs = current_unix_secs();
        let (stmt_payload, num_fields) =
            build_signing_payload(now_secs, None, Some(channel), 0, &[], &ciphertext)
                .map_err(ChatV1Error::StatementSubmitFailed)?;

        let sig = self
            .signer
            .sign(&stmt_payload)
            .map_err(|e| ChatV1Error::StatementSubmitFailed(e.to_string()))?;

        let encoded = assemble_statement(&stmt_payload, num_fields, &self.local_account_id, &sig);
        self.transport
            .submit_statement(&encoded)
            .map_err(ChatV1Error::StatementSubmitFailed)
    }
}

// ---------------------------------------------------------------------------
// Module-private pure helpers
// ---------------------------------------------------------------------------

/// Verify the sr25519 proof on a raw encoded statement.
///
/// Checks two properties:
/// 1. The embedded signature is cryptographically valid over the signing payload.
/// 2. The signer (`proof_pubkey`) matches the expected peer account ID, preventing
///    an attacker from substituting a different valid key.
///
/// Returns `Err(SignatureVerificationFailed)` on any check failure.
fn verify_statement_proof(
    raw: &[u8],
    statement: &host_encoding::statement_store::Statement,
    expected_peer: &[u8; 32],
) -> Result<(), ChatV1Error> {
    let pubkey = statement
        .proof_pubkey
        .ok_or_else(|| ChatV1Error::SignatureVerificationFailed("missing proof pubkey".into()))?;
    let signature = statement.proof_signature.ok_or_else(|| {
        ChatV1Error::SignatureVerificationFailed("missing proof signature".into())
    })?;

    // Verify cryptographic proof first, then check authorization (identity).
    // This order avoids a timing oracle that could distinguish "wrong peer"
    // from "bad signature".
    let signing_payload = extract_signing_payload(raw)
        .map_err(|e| ChatV1Error::MalformedStatement(format!("signing payload: {e}")))?;

    if !crypto::verify_sr25519_proof(&pubkey, &signature, signing_payload) {
        return Err(ChatV1Error::SignatureVerificationFailed(
            "sr25519 signature invalid".into(),
        ));
    }

    // The signer must be the peer we expect for this session.
    if pubkey != *expected_peer {
        return Err(ChatV1Error::SignatureVerificationFailed(
            "proof pubkey does not match expected peer".into(),
        ));
    }

    Ok(())
}

/// Perform P-256 ECDH and return the raw 32-byte x-coordinate.
fn p256_ecdh_raw(own_secret: &p256::SecretKey, peer_public: &p256::PublicKey) -> [u8; 32] {
    let shared =
        p256::ecdh::diffie_hellman(own_secret.to_nonzero_scalar(), peer_public.as_affine());
    let mut out = [0u8; 32];
    out.copy_from_slice(shared.raw_secret_bytes());
    out
}

/// HKDF-SHA256 stretch of the raw ECDH output into a 32-byte session key.
fn hkdf_chat(ecdh_raw: &[u8; 32]) -> [u8; 32] {
    use hkdf::Hkdf;
    use sha2::Sha256;

    let hk = Hkdf::<Sha256>::new(None, ecdh_raw);
    let mut okm = [0u8; 32];
    hk.expand(b"polkadot-chat-v1", &mut okm)
        .expect("32 bytes is within HKDF-SHA256 output limit");
    okm
}

/// Encode a message payload: `content_type (1B) || message_id_bytes || 0x00 || body`.
pub(crate) fn build_message_payload(
    content_type: ContentType,
    message_id: &str,
    body: &[u8],
) -> Vec<u8> {
    let id_bytes = message_id.as_bytes();
    let mut out = Vec::with_capacity(1 + id_bytes.len() + 1 + body.len());
    out.push(content_type.to_wire());
    out.extend_from_slice(id_bytes);
    out.push(0x00); // null separator between ID and body
    out.extend_from_slice(body);
    out
}

/// Decode a message payload produced by `build_message_payload`.
///
/// Returns `(ContentType, message_id, Option<text>)`.
/// On any parse error the message ID falls back to an empty string and the
/// content type to `Unknown(0)`, so the caller always gets a usable event.
fn parse_message_payload(data: &[u8]) -> (ContentType, String, Option<String>) {
    if data.is_empty() {
        return (ContentType::Unknown(0), String::new(), None);
    }

    let content_type = ContentType::from_wire(data[0]);
    let rest = &data[1..];

    // Find the null-byte separator between message ID and body.
    let sep = rest.iter().position(|&b| b == 0x00);
    let (id_bytes, body) = match sep {
        Some(pos) => (&rest[..pos], &rest[pos + 1..]),
        None => (rest, &[] as &[u8]),
    };

    let message_id = String::from_utf8_lossy(id_bytes).into_owned();
    let text = if body.is_empty() {
        None
    } else {
        Some(String::from_utf8_lossy(body).into_owned())
    };

    (content_type, message_id, text)
}

/// Hex-encode a 32-byte account ID for error messages.
fn hex_account(account_id: &[u8; 32]) -> String {
    account_id.iter().fold(String::new(), |mut s, b| {
        use std::fmt::Write;
        let _ = write!(s, "{b:02x}");
        s
    })
}

/// Current Unix timestamp in seconds.
///
/// Uses `std::time::SystemTime` on native targets. For WASM the `uuid` crate
/// uses `getrandom` (enabled via feature), so this is fine on native only.
fn current_unix_secs() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

#[cfg(test)]
pub(crate) mod tests {
    use super::*;
    use crate::events::{ChatMessageEvent, ChatRequestEvent, DeliveryStatusEvent};
    use crate::traits::{ChatEventSink, ChatKeyResolver, ChatTransport, StatementCallback};
    use host_wallet::SignerError;
    use std::sync::{Arc, Mutex};

    // -----------------------------------------------------------------------
    // Test stubs
    // -----------------------------------------------------------------------

    struct NoopTransport;
    impl ChatTransport for NoopTransport {
        fn submit_statement(&self, _encoded: &[u8]) -> Result<(), String> {
            Ok(())
        }
        fn subscribe(
            &self,
            _topics: &[[u8; 32]],
            _callback: StatementCallback,
        ) -> Result<(), String> {
            Ok(())
        }
        fn unsubscribe(&self, _topics: &[[u8; 32]]) -> Result<(), String> {
            Ok(())
        }
    }

    struct FailingTransport;
    impl ChatTransport for FailingTransport {
        fn submit_statement(&self, _encoded: &[u8]) -> Result<(), String> {
            Err("network unavailable".into())
        }
        fn subscribe(
            &self,
            _topics: &[[u8; 32]],
            _callback: StatementCallback,
        ) -> Result<(), String> {
            Err("subscribe failed".into())
        }
        fn unsubscribe(&self, _topics: &[[u8; 32]]) -> Result<(), String> {
            Ok(())
        }
    }

    /// Resolver that hands back a fixed P-256 public key for any account.
    struct FixedKeyResolver {
        pubkey: [u8; 65],
    }
    impl ChatKeyResolver for FixedKeyResolver {
        fn resolve_p256_pubkey(&self, _account_id: &[u8; 32]) -> Result<[u8; 65], String> {
            Ok(self.pubkey)
        }
    }

    struct FailingKeyResolver;
    impl ChatKeyResolver for FailingKeyResolver {
        fn resolve_p256_pubkey(&self, _account_id: &[u8; 32]) -> Result<[u8; 65], String> {
            Err("not found".into())
        }
    }

    /// Event sink that records events for assertion.
    #[derive(Default)]
    struct RecordingSink {
        messages: Arc<Mutex<Vec<ChatMessageEvent>>>,
        deliveries: Arc<Mutex<Vec<DeliveryStatusEvent>>>,
        requests: Arc<Mutex<Vec<ChatRequestEvent>>>,
    }
    impl ChatEventSink for RecordingSink {
        fn push_message(&self, event: ChatMessageEvent) {
            self.messages
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .push(event);
        }
        fn push_delivery_status(&self, event: DeliveryStatusEvent) {
            self.deliveries
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .push(event);
        }
        fn push_chat_request(&self, event: ChatRequestEvent) {
            self.requests
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .push(event);
        }
    }

    // -----------------------------------------------------------------------
    // Key-pair helpers
    // -----------------------------------------------------------------------

    /// Generate a deterministic P-256 key pair from a seed byte.
    pub(crate) fn test_p256_keypair(seed: u8) -> (Zeroizing<[u8; 32]>, [u8; 65], [u8; 32]) {
        use p256::elliptic_curve::sec1::ToEncodedPoint;

        let mut scalar_bytes = [seed; 32];
        // Make the scalar non-zero and within range.
        scalar_bytes[0] = seed.max(1);
        let sk = p256::SecretKey::from_slice(&scalar_bytes).unwrap();
        let pk = sk.public_key();
        let pk_bytes: [u8; 65] = pk.to_encoded_point(false).as_bytes().try_into().unwrap();
        // Derive a fake account ID from the seed.
        let mut account_id = [0u8; 32];
        account_id[0] = seed;
        (Zeroizing::new(scalar_bytes), pk_bytes, account_id)
    }

    /// Dummy signer that always returns 64 zero bytes — suitable for tests
    /// that do not verify the outbound signature.
    pub(crate) struct DummySigner;

    impl HostSigner for DummySigner {
        fn public_key(&self) -> Result<[u8; 32], SignerError> {
            Ok([0u8; 32])
        }

        fn sign(&self, _payload: &[u8]) -> Result<[u8; 64], SignerError> {
            Ok([0u8; 64])
        }
    }

    /// Signer that always returns an error — used to test signing failure paths.
    pub(crate) struct FailingSigner;

    impl HostSigner for FailingSigner {
        fn public_key(&self) -> Result<[u8; 32], SignerError> {
            Err(SignerError::Backend("unavailable".into()))
        }

        fn sign(&self, _payload: &[u8]) -> Result<[u8; 64], SignerError> {
            Err(SignerError::Backend("signing failed in test".into()))
        }
    }

    /// Generate a deterministic schnorrkel keypair from a seed byte.
    ///
    /// Returns `(keypair, account_id)` where `account_id` is the 32-byte
    /// sr25519 public key — the chat protocol uses the public key as the
    /// account identifier.
    pub(crate) fn test_sr25519_keypair(seed: u8) -> (schnorrkel::Keypair, [u8; 32]) {
        use schnorrkel::{ExpansionMode, MiniSecretKey};
        let mut seed_bytes = [0u8; 32];
        seed_bytes[0] = seed.max(1);
        let mini =
            MiniSecretKey::from_bytes(&seed_bytes).expect("32 bytes is a valid MiniSecretKey");
        let keypair = mini.expand_to_keypair(ExpansionMode::Ed25519);
        let account_id = keypair.public.to_bytes();
        (keypair, account_id)
    }

    /// Sign `payload` with a schnorrkel keypair using the `b"substrate"` context.
    pub(crate) fn sr25519_sign(keypair: &schnorrkel::Keypair, payload: &[u8]) -> [u8; 64] {
        const SIGNING_CTX: &[u8] = b"substrate";
        let ctx = schnorrkel::signing_context(SIGNING_CTX);
        keypair.sign(ctx.bytes(payload)).to_bytes()
    }

    // -----------------------------------------------------------------------
    // get_identity
    // -----------------------------------------------------------------------

    #[test]
    fn test_get_identity_returns_keys() {
        let (secret, pubkey, account_id) = test_p256_keypair(0x01);
        let manager = ChatManager::new(
            account_id,
            secret,
            pubkey,
            Arc::new(DummySigner),
            NoopTransport,
            FailingKeyResolver,
            RecordingSink::default(),
        );
        let (id, pk) = manager.get_identity();
        assert_eq!(id, account_id);
        assert_eq!(pk, pubkey);
    }

    // -----------------------------------------------------------------------
    // open_conversation
    // -----------------------------------------------------------------------

    #[test]
    fn test_open_conversation_creates_session() {
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);
        let (_peer_secret, peer_pub, peer_id) = test_p256_keypair(0x02);

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(DummySigner),
            NoopTransport,
            FixedKeyResolver { pubkey: peer_pub },
            RecordingSink::default(),
        );

        manager.open_conversation(peer_id).unwrap();
        assert_eq!(
            manager.get_conversation_state(&peer_id),
            ConversationState::Active
        );
    }

    #[test]
    fn test_open_conversation_is_idempotent() {
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);
        let (_peer_secret, peer_pub, peer_id) = test_p256_keypair(0x02);

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(DummySigner),
            NoopTransport,
            FixedKeyResolver { pubkey: peer_pub },
            RecordingSink::default(),
        );

        manager.open_conversation(peer_id).unwrap();
        // Second call must succeed without error.
        manager.open_conversation(peer_id).unwrap();
        assert_eq!(
            manager.get_conversation_state(&peer_id),
            ConversationState::Active
        );
    }

    #[test]
    fn test_open_conversation_fails_on_key_resolution_error() {
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);
        let peer_id = [0x02u8; 32];

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(DummySigner),
            NoopTransport,
            FailingKeyResolver,
            RecordingSink::default(),
        );

        let err = manager.open_conversation(peer_id).unwrap_err();
        assert!(
            matches!(err, ChatV1Error::PeerKeyResolutionFailed(_)),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn test_open_conversation_fails_on_subscribe_error() {
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);
        let (_peer_secret, peer_pub, peer_id) = test_p256_keypair(0x02);

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(DummySigner),
            FailingTransport,
            FixedKeyResolver { pubkey: peer_pub },
            RecordingSink::default(),
        );

        let err = manager.open_conversation(peer_id).unwrap_err();
        assert!(
            matches!(err, ChatV1Error::SubscribeFailed(_)),
            "unexpected error: {err}"
        );
    }

    // -----------------------------------------------------------------------
    // get_conversation_state
    // -----------------------------------------------------------------------

    #[test]
    fn test_conversation_state_transitions() {
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);
        let (_peer_secret, peer_pub, peer_id) = test_p256_keypair(0x02);

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(DummySigner),
            NoopTransport,
            FixedKeyResolver { pubkey: peer_pub },
            RecordingSink::default(),
        );

        // No session yet.
        assert_eq!(
            manager.get_conversation_state(&peer_id),
            ConversationState::None
        );

        manager.open_conversation(peer_id).unwrap();

        // After open_conversation the session is Active.
        assert_eq!(
            manager.get_conversation_state(&peer_id),
            ConversationState::Active
        );

        manager.clear_sessions();

        // After clearing, back to None.
        assert_eq!(
            manager.get_conversation_state(&peer_id),
            ConversationState::None
        );
    }

    // -----------------------------------------------------------------------
    // send_message
    // -----------------------------------------------------------------------

    #[test]
    fn test_send_message_returns_message_id_and_emits_delivery_event() {
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);
        let (_peer_secret, peer_pub, peer_id) = test_p256_keypair(0x02);
        let sink = RecordingSink::default();
        let deliveries = Arc::clone(&sink.deliveries);

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(DummySigner),
            NoopTransport,
            FixedKeyResolver { pubkey: peer_pub },
            sink,
        );

        manager.open_conversation(peer_id).unwrap();
        let msg_id = manager.send_message(peer_id, "hello").unwrap();

        assert!(!msg_id.is_empty(), "message ID must not be empty");
        let events = deliveries.lock().unwrap_or_else(|e| e.into_inner());
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].message_id, msg_id);
        assert_eq!(events[0].status, DeliveryStatus::Sent);
    }

    #[test]
    fn test_send_message_fails_without_open_session() {
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);
        let peer_id = [0x99u8; 32];

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(DummySigner),
            NoopTransport,
            FailingKeyResolver,
            RecordingSink::default(),
        );

        let err = manager.send_message(peer_id, "hi").unwrap_err();
        assert!(
            matches!(err, ChatV1Error::NoOpenConversation(_)),
            "unexpected: {err}"
        );
    }

    #[test]
    fn test_send_message_rejects_oversized_text() {
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);
        let (_peer_secret, peer_pub, peer_id) = test_p256_keypair(0x02);

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(DummySigner),
            NoopTransport,
            FixedKeyResolver { pubkey: peer_pub },
            RecordingSink::default(),
        );

        manager.open_conversation(peer_id).unwrap();
        let huge = "x".repeat(MAX_MESSAGE_BYTES + 1);
        let err = manager.send_message(peer_id, &huge).unwrap_err();
        assert!(
            matches!(err, ChatV1Error::MessageTooLong { .. }),
            "unexpected: {err}"
        );
    }

    #[test]
    fn test_send_message_rejects_signer_error() {
        // FailingSigner returns Err, which must map to StatementSubmitFailed.
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);
        let (_peer_secret, peer_pub, peer_id) = test_p256_keypair(0x02);

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(FailingSigner),
            NoopTransport,
            FixedKeyResolver { pubkey: peer_pub },
            RecordingSink::default(),
        );

        manager.open_conversation(peer_id).unwrap();
        let err = manager.send_message(peer_id, "hi").unwrap_err();
        assert!(
            matches!(err, ChatV1Error::StatementSubmitFailed(_)),
            "unexpected: {err}"
        );
    }

    // -----------------------------------------------------------------------
    // clear_sessions
    // -----------------------------------------------------------------------

    #[test]
    fn test_clear_sessions_removes_all_sessions() {
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);
        let (_p1_secret, p1_pub, p1_id) = test_p256_keypair(0x02);
        let (_p2_secret, p2_pub, p2_id) = test_p256_keypair(0x03);

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(DummySigner),
            NoopTransport,
            FixedKeyResolver { pubkey: p1_pub },
            RecordingSink::default(),
        );

        manager.open_conversation(p1_id).unwrap();

        // Replace resolver for second peer by re-using a different pubkey.
        // Since we cannot swap the resolver at runtime, open a second session
        // by temporarily borrowing the FixedKeyResolver. Instead, just test
        // that one session is cleared correctly.
        manager.clear_sessions();
        assert_eq!(
            manager.get_conversation_state(&p1_id),
            ConversationState::None
        );
        let _ = (p2_pub, p2_id); // suppress unused warnings
    }

    // -----------------------------------------------------------------------
    // handle_incoming_statement
    // -----------------------------------------------------------------------

    // -----------------------------------------------------------------------
    // Helpers shared by signature-verification tests
    // -----------------------------------------------------------------------

    /// Build an encoded inbound statement signed by `peer_kp` on `channel`.
    ///
    /// Returns the raw SCALE bytes suitable for passing to
    /// `handle_incoming_statement`.
    pub(crate) fn build_signed_statement(
        peer_kp: &schnorrkel::Keypair,
        peer_id: &[u8; 32],
        channel: &[u8; 32],
        ciphertext: &[u8],
    ) -> Vec<u8> {
        use host_encoding::statement_store::{assemble_statement, build_signing_payload};
        let (signing_payload, num_fields) =
            build_signing_payload(current_unix_secs(), None, Some(channel), 0, &[], ciphertext)
                .unwrap();
        let sig = sr25519_sign(peer_kp, &signing_payload);
        assemble_statement(&signing_payload, num_fields, peer_id, &sig)
    }

    // -----------------------------------------------------------------------
    // test_accepts_statement_with_valid_signature (happy path — replaces old test)
    // -----------------------------------------------------------------------

    #[test]
    fn test_accepts_statement_with_valid_signature() {
        // Peer uses a real sr25519 keypair; peer_id is the sr25519 public key.
        let (peer_kp, peer_id) = test_sr25519_keypair(0x02);
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);

        // Derive a P-256 pubkey for the peer (used only for ECDH session setup).
        let peer_p256 = {
            use p256::elliptic_curve::sec1::ToEncodedPoint;
            let mut s = [0x02u8; 32];
            s[0] = 0x01;
            let sk = p256::SecretKey::from_slice(&s).unwrap();
            let pk_bytes: [u8; 65] = sk
                .public_key()
                .to_encoded_point(false)
                .as_bytes()
                .try_into()
                .unwrap();
            pk_bytes
        };

        let sink = RecordingSink::default();
        let messages = Arc::clone(&sink.messages);

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(DummySigner),
            NoopTransport,
            FixedKeyResolver { pubkey: peer_p256 },
            sink,
        );
        manager.open_conversation(peer_id).unwrap();

        let (shared_secret, peer_request_channel) = {
            let sessions = manager.sessions.lock().unwrap_or_else(|e| e.into_inner());
            let s = sessions.get(&peer_id).unwrap();
            (*s.shared_secret, s.peer_request_channel)
        };

        let plaintext = build_message_payload(ContentType::Text, "test-id-001", b"hello world");
        let ciphertext = crypto::encrypt(&shared_secret, &plaintext);

        let encoded =
            build_signed_statement(&peer_kp, &peer_id, &peer_request_channel, &ciphertext);
        manager.handle_incoming_statement(&encoded).unwrap();

        let evts = messages.lock().unwrap_or_else(|e| e.into_inner());
        assert_eq!(evts.len(), 1);
        assert_eq!(evts[0].text.as_deref(), Some("hello world"));
        assert_eq!(evts[0].peer_account_id, peer_id);
    }

    // -----------------------------------------------------------------------
    // test_rejects_statement_with_invalid_signature
    // -----------------------------------------------------------------------

    #[test]
    fn test_rejects_statement_with_invalid_signature() {
        // Same setup as the happy path but the signature bytes are zeroed out.
        let (peer_kp, peer_id) = test_sr25519_keypair(0x02);
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);
        let peer_p256 = {
            use p256::elliptic_curve::sec1::ToEncodedPoint;
            let mut s = [0x02u8; 32];
            s[0] = 0x01;
            let sk = p256::SecretKey::from_slice(&s).unwrap();
            sk.public_key()
                .to_encoded_point(false)
                .as_bytes()
                .try_into()
                .unwrap()
        };

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(DummySigner),
            NoopTransport,
            FixedKeyResolver { pubkey: peer_p256 },
            RecordingSink::default(),
        );
        manager.open_conversation(peer_id).unwrap();

        let (shared_secret, peer_request_channel) = {
            let sessions = manager.sessions.lock().unwrap_or_else(|e| e.into_inner());
            let s = sessions.get(&peer_id).unwrap();
            (*s.shared_secret, s.peer_request_channel)
        };

        let plaintext = build_message_payload(ContentType::Text, "test-id-002", b"injected");
        let ciphertext = crypto::encrypt(&shared_secret, &plaintext);

        // Build statement with the correct peer_id but a zeroed (invalid) signature.
        let (signing_payload, num_fields) = host_encoding::statement_store::build_signing_payload(
            current_unix_secs(),
            None,
            Some(&peer_request_channel),
            0,
            &[],
            &ciphertext,
        )
        .unwrap();
        let bad_sig = [0u8; 64]; // not a valid sr25519 signature
        let encoded = host_encoding::statement_store::assemble_statement(
            &signing_payload,
            num_fields,
            &peer_id,
            &bad_sig,
        );

        let err = manager.handle_incoming_statement(&encoded).unwrap_err();
        assert!(
            matches!(
                err,
                crate::error::ChatV1Error::SignatureVerificationFailed(_)
            ),
            "expected SignatureVerificationFailed, got: {err}"
        );
        let _ = peer_kp; // keep the binding — here only to clarify what peer_id comes from
    }

    // -----------------------------------------------------------------------
    // test_rejects_statement_from_wrong_peer
    // -----------------------------------------------------------------------

    #[test]
    fn test_rejects_statement_from_wrong_peer() {
        // `peer_id` belongs to one sr25519 keypair; the statement is signed
        // by a *different* keypair — the pubkey-mismatch check must catch it.
        let (_expected_kp, peer_id) = test_sr25519_keypair(0x02);
        let (attacker_kp, attacker_id) = test_sr25519_keypair(0x03);
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);
        let peer_p256: [u8; 65] = {
            use p256::elliptic_curve::sec1::ToEncodedPoint;
            let mut s = [0x02u8; 32];
            s[0] = 0x01;
            let sk = p256::SecretKey::from_slice(&s).unwrap();
            sk.public_key()
                .to_encoded_point(false)
                .as_bytes()
                .try_into()
                .unwrap()
        };

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(DummySigner),
            NoopTransport,
            FixedKeyResolver { pubkey: peer_p256 },
            RecordingSink::default(),
        );
        manager.open_conversation(peer_id).unwrap();

        let (shared_secret, peer_request_channel) = {
            let sessions = manager.sessions.lock().unwrap_or_else(|e| e.into_inner());
            let s = sessions.get(&peer_id).unwrap();
            (*s.shared_secret, s.peer_request_channel)
        };

        let plaintext = build_message_payload(ContentType::Text, "test-id-003", b"spoofed");
        let ciphertext = crypto::encrypt(&shared_secret, &plaintext);

        // Statement is signed by `attacker_kp` and carries `attacker_id` as signer.
        // This passes cryptographic validity but fails the peer-identity check.
        let encoded = build_signed_statement(
            &attacker_kp,
            &attacker_id,
            &peer_request_channel,
            &ciphertext,
        );

        let err = manager.handle_incoming_statement(&encoded).unwrap_err();
        assert!(
            matches!(
                err,
                crate::error::ChatV1Error::SignatureVerificationFailed(_)
            ),
            "expected SignatureVerificationFailed, got: {err}"
        );
    }

    #[test]
    fn test_handle_incoming_statement_ignores_unknown_channel() {
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);
        let sink = RecordingSink::default();
        let messages = Arc::clone(&sink.messages);

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(DummySigner),
            NoopTransport,
            FailingKeyResolver,
            sink,
        );

        let unknown_channel = [0xFFu8; 32];
        let (payload, num_fields) = build_signing_payload(
            current_unix_secs(),
            None,
            Some(&unknown_channel),
            0,
            &[],
            b"garbage",
        )
        .unwrap();
        let encoded = assemble_statement(&payload, num_fields, &[0u8; 32], &[0u8; 64]);

        manager.handle_incoming_statement(&encoded).unwrap();

        // No events — unknown channel is silently dropped.
        assert!(messages
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .is_empty());
    }

    // -----------------------------------------------------------------------
    // Replay protection (CHAT-002)
    // -----------------------------------------------------------------------

    /// Build a manager + open a session, returning all the pieces needed for
    /// inbound-statement tests.
    ///
    /// Returns `(manager, peer_kp, peer_id, shared_secret, peer_request_channel, peer_response_channel, messages_arc, deliveries_arc)`.
    #[allow(clippy::type_complexity)]
    fn setup_session() -> (
        ChatManager<NoopTransport, FixedKeyResolver, RecordingSink>,
        schnorrkel::Keypair,
        [u8; 32],
        [u8; 32],
        [u8; 32],
        [u8; 32],
        Arc<Mutex<Vec<ChatMessageEvent>>>,
        Arc<Mutex<Vec<DeliveryStatusEvent>>>,
    ) {
        let (peer_kp, peer_id) = test_sr25519_keypair(0x02);
        let (local_secret, local_pub, local_id) = test_p256_keypair(0x01);
        let peer_p256: [u8; 65] = {
            use p256::elliptic_curve::sec1::ToEncodedPoint;
            let mut s = [0x02u8; 32];
            s[0] = 0x01;
            let sk = p256::SecretKey::from_slice(&s).unwrap();
            sk.public_key()
                .to_encoded_point(false)
                .as_bytes()
                .try_into()
                .unwrap()
        };

        let sink = RecordingSink::default();
        let messages = Arc::clone(&sink.messages);
        let deliveries = Arc::clone(&sink.deliveries);

        let manager = ChatManager::new(
            local_id,
            local_secret,
            local_pub,
            Arc::new(DummySigner),
            NoopTransport,
            FixedKeyResolver { pubkey: peer_p256 },
            sink,
        );
        manager.open_conversation(peer_id).unwrap();

        let (shared_secret, peer_req_ch, peer_resp_ch) = {
            let sessions = manager.sessions.lock().unwrap_or_else(|e| e.into_inner());
            let s = sessions.get(&peer_id).unwrap();
            (
                *s.shared_secret,
                s.peer_request_channel,
                s.peer_response_channel,
            )
        };

        (
            manager,
            peer_kp,
            peer_id,
            shared_secret,
            peer_req_ch,
            peer_resp_ch,
            messages,
            deliveries,
        )
    }

    #[test]
    fn test_rejects_replayed_message_id() {
        let (manager, peer_kp, peer_id, shared_secret, peer_req_ch, _peer_resp_ch, messages, _) =
            setup_session();

        let plaintext = build_message_payload(ContentType::Text, "replay-001", b"hi");
        let ciphertext = crypto::encrypt(&shared_secret, &plaintext);
        let encoded = build_signed_statement(&peer_kp, &peer_id, &peer_req_ch, &ciphertext);

        // First delivery — accepted.
        manager.handle_incoming_statement(&encoded).unwrap();
        // Replay — must be discarded.
        manager.handle_incoming_statement(&encoded).unwrap();

        let evts = messages.lock().unwrap_or_else(|e| e.into_inner());
        assert_eq!(evts.len(), 1, "replayed message must be discarded");
    }

    #[test]
    fn test_accepts_different_message_ids() {
        let (manager, peer_kp, peer_id, shared_secret, peer_req_ch, _peer_resp_ch, messages, _) =
            setup_session();

        let pt1 = build_message_payload(ContentType::Text, "msg-a", b"first");
        let ct1 = crypto::encrypt(&shared_secret, &pt1);
        let enc1 = build_signed_statement(&peer_kp, &peer_id, &peer_req_ch, &ct1);

        let pt2 = build_message_payload(ContentType::Text, "msg-b", b"second");
        let ct2 = crypto::encrypt(&shared_secret, &pt2);
        let enc2 = build_signed_statement(&peer_kp, &peer_id, &peer_req_ch, &ct2);

        manager.handle_incoming_statement(&enc1).unwrap();
        manager.handle_incoming_statement(&enc2).unwrap();

        let evts = messages.lock().unwrap_or_else(|e| e.into_inner());
        assert_eq!(evts.len(), 2, "two distinct IDs must both be delivered");
        assert_eq!(evts[0].message_id, "msg-a");
        assert_eq!(evts[1].message_id, "msg-b");
    }

    #[test]
    fn test_ring_buffer_evicts_oldest() {
        use crate::session::MAX_SEEN_MESSAGE_IDS;

        let (manager, peer_kp, peer_id, shared_secret, peer_req_ch, _peer_resp_ch, messages, _) =
            setup_session();

        // Fill the buffer to capacity with IDs "id-0000" through "id-1023".
        for i in 0..MAX_SEEN_MESSAGE_IDS {
            let msg_id = format!("id-{i:04}");
            let pt = build_message_payload(ContentType::Text, &msg_id, b"x");
            let ct = crypto::encrypt(&shared_secret, &pt);
            let enc = build_signed_statement(&peer_kp, &peer_id, &peer_req_ch, &ct);
            manager.handle_incoming_statement(&enc).unwrap();
        }
        assert_eq!(
            messages.lock().unwrap_or_else(|e| e.into_inner()).len(),
            MAX_SEEN_MESSAGE_IDS,
            "all initial messages must be delivered"
        );

        // "id-0000" was the oldest entry; after filling the buffer it should
        // have been evicted when "id-1024" (the MAX_SEEN_MESSAGE_IDS+1-th) is
        // submitted. First add one more to push the eviction over the edge.
        let overflow_id = format!("id-{MAX_SEEN_MESSAGE_IDS:04}");
        let pt_overflow = build_message_payload(ContentType::Text, &overflow_id, b"overflow");
        let ct_overflow = crypto::encrypt(&shared_secret, &pt_overflow);
        let enc_overflow = build_signed_statement(&peer_kp, &peer_id, &peer_req_ch, &ct_overflow);
        manager.handle_incoming_statement(&enc_overflow).unwrap();

        // Now re-deliver "id-0000". Because it was evicted it must be accepted again.
        let pt_first = build_message_payload(ContentType::Text, "id-0000", b"re-delivered");
        let ct_first = crypto::encrypt(&shared_secret, &pt_first);
        let enc_first = build_signed_statement(&peer_kp, &peer_id, &peer_req_ch, &ct_first);
        manager.handle_incoming_statement(&enc_first).unwrap();

        let evts = messages.lock().unwrap_or_else(|e| e.into_inner());
        // MAX_SEEN_MESSAGE_IDS initial + 1 overflow + 1 re-delivered evicted = MAX + 2.
        assert_eq!(
            evts.len(),
            MAX_SEEN_MESSAGE_IDS + 2,
            "evicted oldest ID must be re-accepted"
        );
    }

    #[test]
    fn test_replayed_ack_is_discarded() {
        let (manager, peer_kp, peer_id, shared_secret, _peer_req_ch, peer_resp_ch, messages, _) =
            setup_session();

        // ACK arrives on the peer_response_channel.
        let ack_pt = build_message_payload(ContentType::ChatAccepted, "ack-001", &[]);
        let ack_ct = crypto::encrypt(&shared_secret, &ack_pt);
        let enc = build_signed_statement(&peer_kp, &peer_id, &peer_resp_ch, &ack_ct);

        // First ACK — accepted.
        manager.handle_incoming_statement(&enc).unwrap();
        // Replayed ACK — must be discarded.
        manager.handle_incoming_statement(&enc).unwrap();

        let evts = messages.lock().unwrap_or_else(|e| e.into_inner());
        assert_eq!(evts.len(), 1, "replayed ACK must be discarded");
        assert_eq!(evts[0].content_type, ContentType::ChatAccepted);
    }

    // -----------------------------------------------------------------------
    // Internal helpers
    // -----------------------------------------------------------------------

    #[test]
    fn test_build_parse_message_payload_roundtrip() {
        let (ct, id, body) = parse_message_payload(&build_message_payload(
            ContentType::Text,
            "uuid-123",
            b"hello",
        ));
        assert_eq!(ct, ContentType::Text);
        assert_eq!(id, "uuid-123");
        assert_eq!(body.as_deref(), Some("hello"));
    }

    #[test]
    fn test_parse_message_payload_empty_body_gives_none() {
        let (ct, id, body) = parse_message_payload(&build_message_payload(
            ContentType::ChatAccepted,
            "ack-id",
            &[],
        ));
        assert_eq!(ct, ContentType::ChatAccepted);
        assert_eq!(id, "ack-id");
        assert_eq!(body, None);
    }

    #[test]
    fn test_parse_message_payload_empty_data_gives_unknown() {
        let (ct, id, _) = parse_message_payload(&[]);
        assert_eq!(ct, ContentType::Unknown(0));
        assert_eq!(id, "");
    }
}
