//! CR-8 conformance runner — Rust binding.
//!
//! Loads `protocol/conformance/cr8_export_secret/scenario.json`, executes the recipe, and
//! asserts every probe's invariants. Sister runners for WASM/JS and native UniFFI will
//! consume the same JSON file and assert the same invariants; that's what makes this
//! "conformance" — every binding stays on the same behaviour by construction.

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

use std::path::PathBuf;
use std::sync::Arc;

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

use ping_core::{
    storage::memory::MemStorage, transport::DiscoveredDevice, transport::TransportSubscription,
    ClientConfig, Identity, MessageEnvelope, MessagingClient, Storage, SyncCursor, Transport,
};

#[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: ping_core::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 fixture_path() -> PathBuf {
    // Workspace layout: <repo>/core/ping-core/tests/<this file>
    // Target fixture:    <repo>/protocol/conformance/cr8_export_secret/scenario.json
    let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    p.push("../../protocol/conformance/cr8_export_secret/scenario.json");
    p
}

// Minimal hand-rolled JSON probe extractor: returns each probe's input block as the raw
// substring between this probe's `"name":` and the next probe's `"name":` (or end of file).
// The runner then pulls named keys out of each block as needed. Avoids pulling serde_json
// into ping-core's dep tree just to read a fixed-shape fixture.
fn load_probe_blocks(json: &str) -> std::collections::HashMap<String, String> {
    let mut out = std::collections::HashMap::new();
    let mut starts: Vec<(String, usize)> = Vec::new();
    let mut idx = 0;
    while let Some(name_pos) = json[idx..].find("\"name\":") {
        let abs = idx + name_pos;
        let after = &json[abs + "\"name\":".len()..];
        let name = extract_string(after);
        starts.push((name, abs));
        idx = abs + "\"name\":".len();
    }
    for (i, (name, start)) in starts.iter().enumerate() {
        let end = starts.get(i + 1).map(|(_, e)| *e).unwrap_or(json.len());
        out.insert(name.clone(), json[*start..end].to_string());
    }
    out
}

fn extract_string(s: &str) -> String {
    let start = s.find('"').map(|i| i + 1).unwrap_or(0);
    let rest = &s[start..];
    let end = rest.find('"').unwrap_or(rest.len());
    rest[..end].to_string()
}

fn find_key_string(block: &str, key: &str) -> Option<String> {
    let needle = format!("\"{}\":", key);
    let pos = block.find(&needle)?;
    let after = &block[pos + needle.len()..];
    Some(extract_string(after))
}

fn find_key_number(block: &str, key: &str) -> Option<u64> {
    let needle = format!("\"{}\":", key);
    let pos = block.find(&needle)?;
    let rest = block[pos + needle.len()..].trim_start();
    // Read digits up to first non-digit
    let end = rest
        .find(|c: char| !c.is_ascii_digit())
        .unwrap_or(rest.len());
    rest[..end].parse().ok()
}

fn hex_decode(s: &str) -> Vec<u8> {
    let mut out = Vec::with_capacity(s.len() / 2);
    let bytes = s.as_bytes();
    let mut i = 0;
    while i + 1 < bytes.len() {
        let hi = char_to_nibble(bytes[i]);
        let lo = char_to_nibble(bytes[i + 1]);
        out.push((hi << 4) | lo);
        i += 2;
    }
    out
}

fn char_to_nibble(c: u8) -> u8 {
    match c {
        b'0'..=b'9' => c - b'0',
        b'a'..=b'f' => c - b'a' + 10,
        b'A'..=b'F' => c - b'A' + 10,
        _ => 0,
    }
}

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("client init")
}

fn run_scenario() -> (
    Arc<MessagingClient>,
    Arc<MessagingClient>,
    ping_core::ConversationId,
) {
    let transport_a = Arc::new(CapturingTransport::default());
    let transport_b = Arc::new(CapturingTransport::default());
    let client_a = make_client("A", transport_a.clone());
    let client_b = make_client("B", transport_b.clone());

    let b_kp = block_on(client_b.fresh_key_package()).expect("B keypackage");
    let conv = block_on(client_a.create_conversation(Some("cr8-test".into()), 2_000))
        .expect("create conversation");
    block_on(client_a.add_members(conv, vec![(client_b.device_id(), b_kp)], 3_000)).expect("add B");
    let welcome = transport_a
        .sent
        .lock()
        .iter()
        .find(|e| e.kind == ping_core::message::MessageKind::Welcome)
        .cloned()
        .expect("welcome captured");
    block_on(client_b.join_conversation(&welcome, 4_000)).expect("B join");

    (client_a, client_b, conv)
}

#[test]
fn cr8_conformance_scenario_invariants() {
    let path = fixture_path();
    let json = std::fs::read_to_string(&path).unwrap_or_else(|e| {
        panic!("failed to read fixture {}: {}", path.display(), e);
    });
    let probes = load_probe_blocks(&json);
    for required in [
        "match_base",
        "different_label_differs",
        "different_context_differs",
        "rejects_length_zero",
        "rejects_length_over_cap",
    ] {
        assert!(
            probes.contains_key(required),
            "fixture missing probe `{}`",
            required
        );
    }

    let (client_a, client_b, conv) = run_scenario();

    // Probe 1: match_base — A and B must derive the same bytes for the given inputs.
    let p = &probes["match_base"];
    let label = find_key_string(p, "label").expect("match_base.label");
    let ctx = hex_decode(&find_key_string(p, "context_hex").unwrap_or_default());
    let len = find_key_number(p, "length").expect("match_base.length") as usize;
    let a = client_a
        .export_conversation_secret(conv, &label, &ctx, len)
        .expect("A export match_base");
    let b = client_b
        .export_conversation_secret(conv, &label, &ctx, len)
        .expect("B export match_base");
    assert_eq!(a.len(), len, "match_base: A length mismatch");
    assert_eq!(b.len(), len, "match_base: B length mismatch");
    assert_eq!(
        a.as_slice(),
        b.as_slice(),
        "match_base: cross-member equality violated — the whole point of CR-8",
    );

    // Probe 2: different_label_differs — same context, two distinct labels.
    let p = &probes["different_label_differs"];
    let base_label = find_key_string(p, "base_label").expect("base_label");
    let other_label = find_key_string(p, "other_label").expect("other_label");
    let ctx2 = hex_decode(&find_key_string(p, "context_hex").unwrap_or_default());
    let len2 = find_key_number(p, "length").expect("length") as usize;
    let s_base = client_a
        .export_conversation_secret(conv, &base_label, &ctx2, len2)
        .expect("A base label");
    let s_other = client_a
        .export_conversation_secret(conv, &other_label, &ctx2, len2)
        .expect("A other label");
    assert_ne!(
        s_base.as_slice(),
        s_other.as_slice(),
        "different_label_differs: labels must produce distinct secrets"
    );

    // Probe 3: different_context_differs — same label, two distinct contexts.
    let p = &probes["different_context_differs"];
    let lbl3 = find_key_string(p, "label").expect("label");
    let base_ctx = hex_decode(&find_key_string(p, "base_context_hex").unwrap_or_default());
    let other_ctx = hex_decode(&find_key_string(p, "other_context_hex").unwrap_or_default());
    let len3 = find_key_number(p, "length").expect("length") as usize;
    let s_b = client_a
        .export_conversation_secret(conv, &lbl3, &base_ctx, len3)
        .expect("A base ctx");
    let s_o = client_a
        .export_conversation_secret(conv, &lbl3, &other_ctx, len3)
        .expect("A other ctx");
    assert_ne!(
        s_b.as_slice(),
        s_o.as_slice(),
        "different_context_differs: contexts must produce distinct secrets"
    );

    // Probe 4: rejects_length_zero.
    let p = &probes["rejects_length_zero"];
    let lbl4 = find_key_string(p, "label").expect("label");
    let len4 = find_key_number(p, "length").expect("length") as usize;
    assert!(
        client_a
            .export_conversation_secret(conv, &lbl4, &[], len4)
            .is_err(),
        "rejects_length_zero: length 0 must error"
    );

    // Probe 5: rejects_length_over_cap.
    let p = &probes["rejects_length_over_cap"];
    let lbl5 = find_key_string(p, "label").expect("label");
    let len5 = find_key_number(p, "length").expect("length") as usize;
    assert!(
        client_a
            .export_conversation_secret(conv, &lbl5, &[], len5)
            .is_err(),
        "rejects_length_over_cap: length > 1024 must error"
    );
}
