//! CR-13 — catchup snapshot encoding for linking.
//!
//! Three things we want to lock down:
//!   1. CBOR round-trip on the `CatchupSnapshot` type itself (encode → decode → equal).
//!   2. Cap enforcement: hard cap rejects oversize encodes/decodes; the soft cap is a
//!      log warning only (still emits).
//!   3. End-to-end through `MessagingClient::build_linking_ticket` — host-supplied
//!      `last_app_events` flow through to the ticket and decode cleanly on the receiver.

#![cfg(feature = "test-utils")]

use std::sync::Arc;

use futures::executor::block_on;
use parking_lot::Mutex;

use ping_core::{
    storage::memory::MemStorage, transport::DiscoveredDevice, transport::TransportSubscription,
    CatchupAppEventEntry, CatchupConversationEntry, CatchupSnapshot, ClientConfig, ConversationId,
    ConversationMeta, Identity, MessageEnvelope, MessagingClient, Storage, SyncCursor, Transport,
    CATCHUP_SNAPSHOT_HARD_CAP, CATCHUP_SNAPSHOT_VERSION,
};

#[derive(Debug, Default)]
struct CapturingTransport {
    sent: Mutex<Vec<MessageEnvelope>>,
}

impl Transport for CapturingTransport {
    fn send<'a>(
        &'a self,
        envelope: MessageEnvelope,
    ) -> ping_core::transport::TransportFuture<'a, ()> {
        Box::pin(async move {
            self.sent.lock().push(envelope);
            Ok(())
        })
    }
    fn fetch_since<'a>(
        &'a self,
        _conv: ConversationId,
        _cursor: SyncCursor,
        _limit: u32,
    ) -> ping_core::transport::TransportFuture<'a, Vec<MessageEnvelope>> {
        Box::pin(async move { Ok(Vec::new()) })
    }
    fn subscribe<'a>(
        &'a self,
        _cb: Arc<dyn Fn(MessageEnvelope) + Send + Sync>,
    ) -> ping_core::transport::TransportFuture<'a, TransportSubscription> {
        Box::pin(async move { Ok(TransportSubscription::new(|| ())) })
    }
    fn discover_devices<'a>(
        &'a self,
        _user_id: ping_core::UserId,
    ) -> ping_core::transport::TransportFuture<'a, Vec<DiscoveredDevice>> {
        Box::pin(async move { Ok(Vec::new()) })
    }
}

fn make_client(label: &str, transport: Arc<CapturingTransport>) -> Arc<MessagingClient> {
    let cfg = ClientConfig {
        identity: Identity::generate(),
        device_label: label.into(),
        storage: Arc::new(MemStorage::default()) as Arc<dyn Storage>,
        transport,
        now_ms: 1_000,
        storage_backend: ping_core::StorageBackend::Memory,
        device_signing_secret_key: None,
    };
    block_on(MessagingClient::init(cfg)).expect("init")
}

#[test]
fn catchup_snapshot_round_trips() {
    let snap = CatchupSnapshot {
        v: CATCHUP_SNAPSHOT_VERSION,
        conversation_metas: vec![CatchupConversationEntry {
            conversation_id: ConversationId([7u8; 16]),
            meta: ConversationMeta {
                id: ConversationId([7u8; 16]),
                name: Some("test".into()),
                epoch: 3,
                member_count: 2,
                is_device_group: false,
                created_at_ms: 1_730_000_000_000,
            },
            group_state_bytes: Vec::new(), // CR-7 placeholder
        }],
        last_app_events_per_conv: vec![CatchupAppEventEntry {
            conversation_id: ConversationId([7u8; 16]),
            app_event_bytes: b"opaque-host-bytes".to_vec(),
        }],
    };

    let bytes = snap.encode().expect("encode");
    let back = CatchupSnapshot::decode(&bytes).expect("decode");
    assert_eq!(back.v, snap.v);
    assert_eq!(back.conversation_metas.len(), 1);
    assert_eq!(back.last_app_events_per_conv.len(), 1);
    assert_eq!(
        back.last_app_events_per_conv[0].app_event_bytes,
        b"opaque-host-bytes"
    );
    assert_eq!(back.conversation_metas[0].conversation_id.0, [7u8; 16]);
    assert_eq!(back.conversation_metas[0].meta.epoch, 3);
}

#[test]
fn empty_snapshot_round_trips_too() {
    let snap = CatchupSnapshot::empty();
    let bytes = snap.encode().expect("encode empty");
    let back = CatchupSnapshot::decode(&bytes).expect("decode empty");
    assert!(back.conversation_metas.is_empty());
    assert!(back.last_app_events_per_conv.is_empty());
}

#[test]
fn hard_cap_rejects_oversize_encode() {
    // Force one giant app_event_bytes that pushes the encoded size past the hard cap.
    let bloat = vec![0xaa; CATCHUP_SNAPSHOT_HARD_CAP + 1];
    let snap = CatchupSnapshot {
        v: CATCHUP_SNAPSHOT_VERSION,
        conversation_metas: Vec::new(),
        last_app_events_per_conv: vec![CatchupAppEventEntry {
            conversation_id: ConversationId([0u8; 16]),
            app_event_bytes: bloat,
        }],
    };
    let err = snap.encode().unwrap_err();
    assert!(
        format!("{err}").contains("hard cap"),
        "expected hard-cap rejection, got: {err}"
    );
}

#[test]
fn hard_cap_rejects_oversize_decode() {
    let oversize = vec![0u8; CATCHUP_SNAPSHOT_HARD_CAP + 100];
    let err = CatchupSnapshot::decode(&oversize).unwrap_err();
    assert!(
        format!("{err}").contains("hard cap"),
        "expected hard-cap rejection on decode, got: {err}"
    );
}

#[test]
fn unknown_version_rejected_on_decode() {
    // Build a valid snapshot, then mutate its CBOR-encoded `v` field. Quickest path:
    // serialize a snapshot with v=99 by going around the constructor.
    let weird = CatchupSnapshot {
        v: 99,
        conversation_metas: Vec::new(),
        last_app_events_per_conv: Vec::new(),
    };
    // encode() doesn't validate `v`; only decode does.
    let bytes = weird.encode().expect("encode v=99 succeeds");
    let err = CatchupSnapshot::decode(&bytes).unwrap_err();
    assert!(
        format!("{err}").contains("v=99 not supported"),
        "expected unsupported-version on decode, got: {err}"
    );
}

#[test]
fn build_linking_ticket_populates_catchup_snapshot() {
    // End-to-end: A creates two conversations, then builds a linking ticket for a new
    // device with host-supplied last-app-events. The ticket's catchup_snapshot must
    // decode cleanly and contain both convs' metas + the host events.
    let transport = Arc::new(CapturingTransport::default());
    let inviter = make_client("inviter", transport.clone());

    let conv1 =
        block_on(inviter.create_conversation(Some("alpha".into()), 1_000)).expect("create alpha");
    let conv2 =
        block_on(inviter.create_conversation(Some("beta".into()), 1_100)).expect("create beta");

    // New device generates its own KeyPackage (we just need bytes that parse).
    let new_device = make_client("new", Arc::new(CapturingTransport::default()));
    let kp = block_on(new_device.fresh_key_package()).expect("kp");

    let events = vec![
        (conv1, b"last alpha event".to_vec()),
        (conv2, b"last beta event".to_vec()),
    ];

    let ticket = block_on(inviter.build_linking_ticket(new_device.device_id(), kp, events, 2_000))
        .expect("build_linking_ticket");

    assert!(
        !ticket.catchup_snapshot.is_empty(),
        "catchup_snapshot must be populated when last_app_events is non-empty"
    );
    let snap = CatchupSnapshot::decode(&ticket.catchup_snapshot).expect("decode");
    assert_eq!(snap.v, CATCHUP_SNAPSHOT_VERSION);

    // The DG also got created (build_linking_ticket lazily creates it), so we expect
    // 3 conv metas: alpha, beta, and the DG. We don't care about order.
    assert!(
        snap.conversation_metas.len() >= 2,
        "expected at least alpha + beta in metas, got {}",
        snap.conversation_metas.len()
    );
    let meta_ids: std::collections::HashSet<ConversationId> = snap
        .conversation_metas
        .iter()
        .map(|e| e.conversation_id)
        .collect();
    assert!(meta_ids.contains(&conv1));
    assert!(meta_ids.contains(&conv2));

    // The host's app events are pinned verbatim.
    assert_eq!(snap.last_app_events_per_conv.len(), 2);
    let by_conv: std::collections::HashMap<ConversationId, Vec<u8>> = snap
        .last_app_events_per_conv
        .iter()
        .map(|e| (e.conversation_id, e.app_event_bytes.clone()))
        .collect();
    assert_eq!(
        by_conv.get(&conv1).map(|v| v.as_slice()),
        Some(b"last alpha event".as_ref())
    );
    assert_eq!(
        by_conv.get(&conv2).map(|v| v.as_slice()),
        Some(b"last beta event".as_ref())
    );

    // Post-CR-7: every meta entry carries a non-empty `group_state_bytes` — the
    // exporter's per-group MLS state, ready to be replayed by the receiver via
    // `MessagingClient::import_state_snapshot`.
    for entry in &snap.conversation_metas {
        assert!(
            !entry.group_state_bytes.is_empty(),
            "group_state_bytes must be populated post-CR-7 (was empty for {})",
            entry.conversation_id.as_hex()
        );
        // The bytes decode as a valid GroupStateSnapshot for this conv.
        let gss = ping_core::GroupStateSnapshot::decode(&entry.group_state_bytes)
            .expect("group_state_bytes decode");
        assert_eq!(gss.group_id, entry.conversation_id);
        assert_eq!(gss.v, ping_core::GROUP_SNAPSHOT_VERSION);
    }
}

#[test]
fn empty_last_app_events_keeps_snapshot_empty_when_no_convs() {
    // With no conversations AND no host events, the ticket's catchup_snapshot is empty —
    // the SDK skips the encode round-trip entirely. This is the cheap-path optimisation
    // in build_linking_ticket.
    let transport = Arc::new(CapturingTransport::default());
    let inviter = make_client("inviter", transport);
    let new_device = make_client("new", Arc::new(CapturingTransport::default()));
    let kp = block_on(new_device.fresh_key_package()).expect("kp");

    let ticket =
        block_on(inviter.build_linking_ticket(new_device.device_id(), kp, Vec::new(), 2_000))
            .expect("build_linking_ticket");

    // build_linking_ticket creates the DG under the hood, so `conversations.read()` is
    // NOT empty by the time we encode. So we expect a populated snapshot containing
    // (at minimum) the DG meta — even with empty host events.
    let snap = CatchupSnapshot::decode(&ticket.catchup_snapshot).expect("decode");
    assert!(
        snap.last_app_events_per_conv.is_empty(),
        "no host events → empty last_app_events"
    );
    assert!(
        snap.conversation_metas
            .iter()
            .any(|e| e.meta.is_device_group),
        "DG should be in the snapshot's metas"
    );
}
