//! Differential tests against the proven Python backend.
//!
//! The Python responders in `fifa17-recon/tools/` are the behavioural oracle:
//! they are what actually walked a retail FIFA 17 client from Origin login to
//! an opened FUT pack. This crate may only claim to replace them if it produces
//! *the same bytes*, and that claim has to be re-checkable without a FIFA
//! client in the loop.
//!
//! `fixtures/*.jsonl` are generated by `fixtures/generate.py` from that Python
//! code. Each vector carries both the input tree and the bytes the oracle
//! produced. These tests rebuild the tree in Rust, encode it, and require an
//! exact match.
//!
//! Comparison is **byte-for-byte**, not semantic. These are wire formats read
//! by a game binary that hard-freezes on a shape it does not expect, so
//! "equivalent" is not a category that exists here. (Semantic comparison is the
//! right call one layer up, at UTAS/JSON, where key order genuinely does not
//! matter — see `fifa17-recon/tools/test_fut_contract.py`.)
//!
//! Regenerate after any oracle change:  python3 fixtures/generate.py

use openfut_protocol_blaze::fire2::{Frame, Header, MsgType};
use openfut_protocol_blaze::heat2::{self, Struct, Tag, TypeId, Value};
use serde_json::Value as J;

fn load(name: &str) -> Vec<J> {
    let path = format!("{}/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
    let text = std::fs::read_to_string(&path)
        .unwrap_or_else(|e| panic!("cannot read {path}: {e}\nrun: python3 fixtures/generate.py"));
    text.lines()
        .filter(|l| !l.trim().is_empty())
        .map(|l| serde_json::from_str(l).expect("fixture line is valid JSON"))
        .collect()
}

fn unhex(s: &str) -> Vec<u8> {
    assert!(s.len().is_multiple_of(2), "odd-length hex");
    (0..s.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
        .collect()
}

fn hex(b: &[u8]) -> String {
    b.iter().map(|x| format!("{x:02x}")).collect()
}

fn type_of(name: &str) -> TypeId {
    TypeId::from_name(name).unwrap_or_else(|| panic!("unknown type name {name:?}"))
}

/// Rebuild a `Value` from the generator's JSON encoding.
fn to_value(j: &J) -> Value {
    let t = j["t"].as_str().expect("value has a type tag");
    match t {
        "int" => Value::Int(j["v"].as_i64().expect("int fits i64")),
        "string" => Value::String(j["v"].as_str().expect("string").to_string()),
        "blob" => Value::Blob(unhex(j["v"].as_str().expect("blob hex"))),
        "struct" => Value::Struct(to_struct(&j["v"])),
        "list" => Value::List {
            elem: type_of(j["elem"].as_str().expect("elem type")),
            items: j["v"]
                .as_array()
                .expect("list")
                .iter()
                .map(to_value)
                .collect(),
        },
        "map" => Value::Map {
            key: type_of(j["key"].as_str().expect("key type")),
            val: type_of(j["val"].as_str().expect("val type")),
            entries: j["v"]
                .as_array()
                .expect("map entries")
                .iter()
                .map(|pair| {
                    let p = pair.as_array().expect("k/v pair");
                    (to_value(&p[0]), to_value(&p[1]))
                })
                .collect(),
        },
        "union" => Value::Union {
            key: j["key"].as_u64().expect("union key") as u8,
            member: match j.get("member") {
                Some(J::Null) | None => None,
                Some(m) => Some(Box::new((
                    Tag::from_label(m["tag"].as_str().expect("member tag")),
                    to_value(&m["value"]),
                ))),
            },
        },
        "varlist" => Value::VarList(
            j["v"]
                .as_array()
                .expect("varlist")
                .iter()
                .map(|n| n.as_i64().expect("varlist int"))
                .collect(),
        ),
        "objtype" => {
            let a = j["v"].as_array().expect("objtype pair");
            Value::ObjType {
                component: a[0].as_i64().unwrap(),
                ty: a[1].as_i64().unwrap(),
            }
        }
        "objid" => {
            let a = j["v"].as_array().expect("objid triple");
            Value::ObjId {
                component: a[0].as_i64().unwrap(),
                ty: a[1].as_i64().unwrap(),
                id: a[2].as_i64().unwrap(),
            }
        }
        "float" => Value::Float(j["v"].as_f64().expect("float") as f32),
        other => panic!("unhandled fixture type {other:?}"),
    }
}

fn to_struct(j: &J) -> Struct {
    let mut s = Struct::new();
    for entry in j.as_array().expect("struct is a list of [tag, value]") {
        let pair = entry.as_array().expect("[tag, value]");
        s.fields.push((
            Tag::from_label(pair[0].as_str().expect("tag label")),
            to_value(&pair[1]),
        ));
    }
    s
}

// ───────────────────────────────── Heat2 / TDF ─────────────────────────────────

/// Encoding a fixture's tree must reproduce the oracle's bytes exactly.
#[test]
fn tdf_encode_matches_python_oracle_byte_for_byte() {
    let vectors = load("tdf.jsonl");
    assert!(vectors.len() >= 30, "fixture set looks truncated");

    for v in &vectors {
        let name = v["name"].as_str().unwrap();
        let expected = unhex(v["encoded_hex"].as_str().unwrap());
        let actual = heat2::encode(&to_struct(&v["fields"]));
        assert_eq!(
            hex(&actual),
            hex(&expected),
            "\n{name}: Rust encoding differs from the Python oracle"
        );
    }
}

/// Decoding the oracle's bytes and re-encoding must be a fixed point. This is
/// what proves the decoder agrees with the encoder on every field, not just
/// that both are self-consistent.
#[test]
fn tdf_decode_then_reencode_is_byte_identical() {
    for v in load("tdf.jsonl") {
        let name = v["name"].as_str().unwrap();
        let bytes = unhex(v["encoded_hex"].as_str().unwrap());
        let decoded =
            heat2::decode(&bytes).unwrap_or_else(|e| panic!("{name}: decode failed: {e}"));
        assert_eq!(
            hex(&heat2::encode(&decoded)),
            hex(&bytes),
            "\n{name}: decode/re-encode is not a fixed point"
        );
    }
}

/// Sort struct members by packed tag at every depth.
///
/// The recorded tree is in the oracle's source order; the wire is in packed-tag
/// order, and that reordering applies to nested structs too. Normalising both
/// sides compares the content while ignoring the ordering the wire imposes —
/// the byte-level tests above are what pin the ordering itself.
fn normalized(value: &Value) -> Value {
    match value {
        Value::Struct(s) => Value::Struct(normalized_struct(s)),
        Value::List { elem, items } => Value::List {
            elem: *elem,
            items: items.iter().map(normalized).collect(),
        },
        Value::Map { key, val, entries } => Value::Map {
            key: *key,
            val: *val,
            entries: entries
                .iter()
                .map(|(k, v)| (normalized(k), normalized(v)))
                .collect(),
        },
        Value::Union { key, member } => Value::Union {
            key: *key,
            member: member.as_ref().map(|m| Box::new((m.0, normalized(&m.1)))),
        },
        other => other.clone(),
    }
}

fn normalized_struct(s: &Struct) -> Struct {
    let mut fields: Vec<_> = s.iter().map(|(t, v)| (*t, normalized(v))).collect();
    fields.sort_by_key(|(t, _)| *t);
    Struct { fields }
}

/// Decoding must also reproduce the same tree the generator recorded, not just
/// bytes that happen to re-encode the same way.
#[test]
fn tdf_decode_reproduces_the_recorded_tree() {
    for v in load("tdf.jsonl") {
        let name = v["name"].as_str().unwrap();
        let bytes = unhex(v["encoded_hex"].as_str().unwrap());
        let decoded = normalized_struct(&heat2::decode(&bytes).unwrap());
        let expected = normalized_struct(&to_struct(&v["fields"]));

        assert_eq!(
            decoded.len(),
            expected.len(),
            "\n{name}: field count differs"
        );
        for ((gt, gv), (wt, wv)) in decoded.iter().zip(expected.iter()) {
            assert_eq!(gt, wt, "\n{name}: tag mismatch");
            // Over-long labels truncate to the same wire tag, so compare the
            // decoded value, which is what the wire actually carries.
            assert_eq!(gv, wv, "\n{name}: value mismatch under tag {gt}");
        }
    }
}

/// The live vectors are the ones with real evidence behind them; if the
/// generator ever stops emitting them the suite would still pass while proving
/// much less.
#[test]
fn live_vectors_are_present_and_substantial() {
    let vectors = load("tdf.jsonl");
    let live: Vec<_> = vectors.iter().filter(|v| v["origin"] == "live").collect();
    assert!(
        live.len() >= 20,
        "expected the responder's payload builders to be captured; got {}",
        live.len()
    );

    // preAuth is the first RPC FIFA 17 sends and the largest proven payload;
    // it is the single most valuable vector in the set.
    let preauth = vectors
        .iter()
        .find(|v| v["name"] == "live/preauth_response")
        .expect("preauth vector present");
    assert!(
        unhex(preauth["encoded_hex"].as_str().unwrap()).len() > 1000,
        "preauth payload is suspiciously small"
    );
}

/// Vectors whose layout the oracle marks UNVERIFIED must stay marked. This test
/// exists so that "Rust and Python agree" is never quietly read as "this is how
/// EA does it".
#[test]
fn unverified_layouts_remain_flagged() {
    let vectors = load("tdf.jsonl");
    for name in ["synthetic/lists", "synthetic/maps", "synthetic/unions"] {
        let v = vectors
            .iter()
            .find(|v| v["name"] == name)
            .unwrap_or_else(|| panic!("{name} missing"));
        assert_eq!(v["verified"], false, "{name} must stay flagged UNVERIFIED");
    }
    assert!(!TypeId::List.is_verified());
    assert!(!TypeId::Map.is_verified());
    assert!(!TypeId::Union.is_verified());
}

// ─────────────────────────────────── Fire2 ────────────────────────────────────

/// Building a frame must reproduce the oracle's bytes exactly — header layout,
/// field offsets, and the msgType/userIndex packing in byte 13.
#[test]
fn fire2_encode_matches_python_oracle_byte_for_byte() {
    let vectors = load("fire2.jsonl");
    assert!(vectors.len() >= 15, "fixture set looks truncated");

    for v in &vectors {
        let name = v["name"].as_str().unwrap();
        let expected = unhex(v["frame_hex"].as_str().unwrap());

        let frame = Frame::new(
            v["component"].as_u64().unwrap() as u16,
            v["command"].as_u64().unwrap() as u16,
            v["msg_num"].as_u64().unwrap() as u32,
            MsgType::from_bits(v["msg_type"].as_u64().unwrap() as u8),
            unhex(v["payload_hex"].as_str().unwrap()),
        )
        .with_metadata(unhex(v["metadata_hex"].as_str().unwrap()))
        .with_user_index(v["user_index"].as_u64().unwrap() as u8)
        .with_options(v["options"].as_u64().unwrap() as u8);

        let actual = frame.encode();
        assert_eq!(
            actual.len(),
            expected.len(),
            "\n{name}: frame length differs"
        );
        assert_eq!(
            hex(&actual[..16]),
            hex(&expected[..16]),
            "\n{name}: HEADER differs from the oracle"
        );
        assert_eq!(actual, expected, "\n{name}: frame body differs");
    }
}

/// Parsing the oracle's frames must recover every header field.
#[test]
fn fire2_parse_recovers_every_header_field() {
    for v in load("fire2.jsonl") {
        let name = v["name"].as_str().unwrap();
        let bytes = unhex(v["frame_hex"].as_str().unwrap());
        let (frame, used) =
            Frame::parse(&bytes).unwrap_or_else(|e| panic!("{name}: parse failed: {e}"));

        assert_eq!(used, bytes.len(), "\n{name}: consumed length differs");
        let h = &frame.header;
        assert_eq!(
            h.component as u64,
            v["component"].as_u64().unwrap(),
            "{name} component"
        );
        assert_eq!(
            h.command as u64,
            v["command"].as_u64().unwrap(),
            "{name} command"
        );
        assert_eq!(
            h.msg_num as u64,
            v["msg_num"].as_u64().unwrap(),
            "{name} msg_num"
        );
        assert_eq!(
            h.msg_type.as_bits() as u64,
            v["msg_type"].as_u64().unwrap(),
            "{name} msg_type"
        );
        assert_eq!(
            h.user_index as u64,
            v["user_index"].as_u64().unwrap(),
            "{name} user_index"
        );
        assert_eq!(
            h.options as u64,
            v["options"].as_u64().unwrap(),
            "{name} options"
        );
        assert_eq!(
            hex(&frame.payload),
            v["payload_hex"].as_str().unwrap(),
            "{name} payload"
        );
        assert_eq!(
            hex(&frame.metadata),
            v["metadata_hex"].as_str().unwrap(),
            "{name} metadata"
        );
        assert_eq!(frame.encode(), bytes, "\n{name}: re-encode differs");
    }
}

/// The live frames carry real TDF bodies; those must decode and re-encode
/// cleanly. This is the end-to-end check: framing and codec together, on the
/// exact bytes FIFA 17 accepted.
#[test]
fn live_frames_decode_as_tdf_and_survive_a_round_trip() {
    let mut checked = 0;
    for v in load("fire2.jsonl") {
        if v["origin"] != "live" {
            continue;
        }
        let name = v["name"].as_str().unwrap();
        let bytes = unhex(v["frame_hex"].as_str().unwrap());
        let (frame, _) = Frame::parse(&bytes).unwrap();

        let body = heat2::decode(&frame.payload)
            .unwrap_or_else(|e| panic!("{name}: live payload is not valid TDF: {e}"));
        assert!(!body.is_empty(), "{name}: live payload decoded to nothing");
        assert_eq!(
            hex(&heat2::encode(&body)),
            hex(&frame.payload),
            "\n{name}: live payload does not survive a codec round trip"
        );
        checked += 1;
    }
    assert!(
        checked >= 5,
        "expected several live frames, checked {checked}"
    );
}

/// A frame larger than 64 KiB must work. The superseded 12-byte implementation
/// in `fifa-blaze` carried a `u16` length plus a JUMBO escape flag; Fire2's
/// length is a plain `u32`, and the oracle emits no flag. This vector is the
/// direct refutation of that assumption.
#[test]
fn payload_over_64kib_needs_no_jumbo_flag() {
    let vectors = load("fire2.jsonl");
    let v = vectors
        .iter()
        .find(|v| v["name"] == "synthetic/large_payload")
        .expect("large payload vector present");

    let bytes = unhex(v["frame_hex"].as_str().unwrap());
    let header = Header::parse(&bytes).unwrap();
    assert!(header.payload_len > u16::MAX as u32);
    assert_eq!(bytes.len(), 16 + header.payload_len as usize);
    assert_eq!(header.options, 0, "no option flag is set for a large frame");
}

/// Frames arrive coalesced on a real socket; splitting them must work on the
/// oracle's actual bytes, not just synthetic ones.
#[test]
fn coalesced_live_frames_split_correctly() {
    let live: Vec<Vec<u8>> = load("fire2.jsonl")
        .iter()
        .filter(|v| v["origin"] == "live")
        .map(|v| unhex(v["frame_hex"].as_str().unwrap()))
        .collect();

    let stream: Vec<u8> = live.concat();
    let (frames, used) = openfut_protocol_blaze::fire2::parse_all(&stream).unwrap();
    assert_eq!(frames.len(), live.len());
    assert_eq!(used, stream.len());
    for (got, want) in frames.iter().zip(live.iter()) {
        assert_eq!(&got.encode(), want);
    }
}
