//! CR-13 conformance runner — Rust binding.
//!
//! Loads `protocol/conformance/cr13_catchup_snapshot/scenario.json`, reconstructs the
//! pinned cleartext snapshot from the JSON's fields, encodes it, and asserts the bytes
//! match `pinned_encoded_hex`. Also exercises decode + caps + version-rejection.
//!
//! Set `PING_REGENERATE_CR13_FIXTURE=1` to overwrite `pinned_encoded_hex` from this
//! binding's canonical encoding.

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

use std::path::PathBuf;

use ping_core::{
    CatchupAppEventEntry, CatchupConversationEntry, CatchupSnapshot, ConversationId,
    ConversationMeta, CATCHUP_SNAPSHOT_HARD_CAP, CATCHUP_SNAPSHOT_VERSION,
};

fn fixture_path() -> PathBuf {
    let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    p.push("../../protocol/conformance/cr13_catchup_snapshot/scenario.json");
    p
}

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() {
        out.push((nib(bytes[i]) << 4) | nib(bytes[i + 1]));
        i += 2;
    }
    out
}
fn nib(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 hex_encode(b: &[u8]) -> String {
    let mut s = String::with_capacity(b.len() * 2);
    for x in b {
        s.push_str(&format!("{:02x}", x));
    }
    s
}

fn build_fixture_snapshot() -> CatchupSnapshot {
    // Hand-mirrored from scenario.json `cleartext_snapshot`. Changing scenario.json
    // requires updating this builder — that's intentional: the JSON is the spec, the
    // builder is the bridge into Rust types.
    let conv1 = ConversationId([1u8; 16]);
    let conv2 = ConversationId([2u8; 16]);
    CatchupSnapshot {
        v: CATCHUP_SNAPSHOT_VERSION,
        conversation_metas: vec![
            CatchupConversationEntry {
                conversation_id: conv1,
                meta: ConversationMeta {
                    id: conv1,
                    name: Some("alpha".into()),
                    epoch: 3,
                    member_count: 2,
                    is_device_group: false,
                    created_at_ms: 1_730_000_000_000,
                },
                group_state_bytes: Vec::new(),
            },
            CatchupConversationEntry {
                conversation_id: conv2,
                meta: ConversationMeta {
                    id: conv2,
                    name: None,
                    epoch: 0,
                    member_count: 1,
                    is_device_group: true,
                    created_at_ms: 1_730_000_010_000,
                },
                group_state_bytes: Vec::new(),
            },
        ],
        last_app_events_per_conv: vec![CatchupAppEventEntry {
            conversation_id: conv1,
            app_event_bytes: b"hello-last-event".to_vec(),
        }],
    }
}

fn maybe_regenerate(json: &str, path: &PathBuf) -> String {
    if std::env::var("PING_REGENERATE_CR13_FIXTURE").is_err() {
        return json.to_string();
    }
    let snap = build_fixture_snapshot();
    let bytes = snap.encode().expect("encode");
    let new_hex = hex_encode(&bytes);
    let regenerated = json.replace(
        "\"pinned_encoded_hex\": \"<filled-by-generator>\"",
        &format!("\"pinned_encoded_hex\": \"{new_hex}\""),
    );
    std::fs::write(path, &regenerated).expect("write regenerated fixture");
    regenerated
}

fn find_key_string(json: &str, key: &str) -> Option<String> {
    let needle = format!("\"{}\":", key);
    let pos = json.find(&needle)?;
    let after = &json[pos + needle.len()..];
    let start = after.find('"').map(|i| i + 1)?;
    let rest = &after[start..];
    let end = rest.find('"')?;
    Some(rest[..end].to_string())
}

#[test]
fn cr13_conformance_pinned_encoding() {
    let path = fixture_path();
    let json = std::fs::read_to_string(&path)
        .unwrap_or_else(|e| panic!("read fixture {}: {e}", path.display()));
    let json = maybe_regenerate(&json, &path);

    let pinned_hex = find_key_string(&json, "pinned_encoded_hex").expect("pinned_encoded_hex");
    if pinned_hex == "<filled-by-generator>" {
        panic!(
            "fixture {} has no pinned_encoded_hex — rerun with PING_REGENERATE_CR13_FIXTURE=1",
            path.display()
        );
    }
    let pinned = hex_decode(&pinned_hex);

    // 1) Re-encode the fixture and assert byte equality with the pinned blob.
    let snap = build_fixture_snapshot();
    let fresh = snap.encode().expect("encode fresh");
    assert_eq!(
        fresh, pinned,
        "canonical CBOR drift — fixture vs fresh encode disagree"
    );

    // 2) Decode the pinned blob and assert it round-trips through encode.
    let decoded = CatchupSnapshot::decode(&pinned).expect("decode pinned");
    let re_encoded = decoded.encode().expect("re-encode");
    assert_eq!(re_encoded, pinned, "decode→encode must be byte-identical");

    // 3) Cap rejection — a clearly-oversize blob is refused at the boundary.
    let oversize = vec![0u8; CATCHUP_SNAPSHOT_HARD_CAP + 1];
    assert!(CatchupSnapshot::decode(&oversize).is_err());

    // 4) Unknown version is refused on decode.
    let bogus_v = CatchupSnapshot {
        v: 99,
        conversation_metas: Vec::new(),
        last_app_events_per_conv: Vec::new(),
    };
    let bogus_bytes = bogus_v.encode().expect("encode v=99");
    let err = CatchupSnapshot::decode(&bogus_bytes).unwrap_err();
    assert!(
        format!("{err}").contains("v=99 not supported"),
        "expected unsupported-version, got: {err}"
    );
}
