//! Golden structural-parity test — Rust `detect_all` vs the TS `detectAllIssues`
//! oracle (spec §8).
//!
//! For every `tests/fixtures/docs/<name>.json` + `tests/fixtures/golden/<name>.json`
//! pair: deserialize the `PenDocument`, run [`op_design_lint::detect_all`] over
//! its lone root node, and assert **structural** equality in list order on
//! `nodeId` / `category` / `severity` / `property` / `currentValue` /
//! `suggestedValue` against the committed golden.
//!
//! ## `undefined` vs `null` — a documented TS↔Rust wire divergence
//!
//! The TS detectors `detectTextEffect` / `detectExcessiveFrameEffects` (and the
//! structural-no-op `detectTextCornerRadius` / `detectTextStroke`) set
//! `suggestedValue: undefined`. `JSON.stringify` **omits** an `undefined`
//! field entirely, so those golden issues carry **no `suggestedValue` key**.
//! Rust's `Issue.suggested_value` is a non-optional `serde_json::Value` and
//! serializes the same "no suggestion" intent as JSON `null`.
//!
//! `undefined` and `null` are the same *meaning* ("no suggested value — the
//! fix just clears the field") but different *wire bytes*. Spec §8 mandates
//! *structural* parity, not byte parity, so this comparator treats an **absent
//! key as equivalent to `null`**. This is the single Plan-C-known divergence
//! in the parity layer; it is a serialization artifact, not a detector defect,
//! and it is behaviourally inert (`apply_fixes` routes `effects` through
//! `clear_effects`, which ignores the suggested value).
//!
//! The `reason` string is checked by a 12-char prefix only — `toFixed(2)` /
//! float formatting is not byte-stable across runtimes (spec §8, Codex Q4).
//!
//! The golden files are regenerated by `tools/dump-diagnostics-golden.ts` and
//! guarded against TS-side drift by a CI job (spec §8, Risk R4). A failure
//! here is a real parity defect — fix the detector, never the golden.

use jian_ops_schema::PenDocument;
use op_design_lint::detect_all;
use serde_json::Value;

/// Number of `reason`-string prefix chars compared. The full string carries
/// `toFixed(2)` floats whose formatting is not byte-stable across runtimes;
/// the prefix is the detector-identifying portion.
const REASON_PREFIX_LEN: usize = 12;

/// Look up a field on a JSON object, treating an absent key as `Value::Null`.
/// This is what makes TS `undefined` (omitted by `JSON.stringify`) compare
/// equal to Rust `null` — see the module doc comment.
fn field<'a>(obj: &'a Value, key: &str) -> &'a Value {
    obj.get(key).unwrap_or(&Value::Null)
}

/// First `REASON_PREFIX_LEN` chars of a string `Value`, or `""`.
fn reason_prefix(v: &Value) -> String {
    v.as_str()
        .unwrap_or("")
        .chars()
        .take(REASON_PREFIX_LEN)
        .collect()
}

/// Structural JSON equality that compares numbers by *numeric value*, not by
/// JSON representation — so `0` (a TS integer) equals `0.0` (a Rust `f64`
/// field re-serialized from a typed jian struct, e.g. `ShadowBody.offset_x`).
///
/// This is the same float-formatting tolerance spec §8 already grants the
/// `reason` string: `1` vs `1.0` is a representation artifact of typed-struct
/// re-serialization, not a value divergence. Every other JSON type is compared
/// for exact equality (object key sets, array order/length, strings, bools).
fn json_struct_eq(a: &Value, b: &Value) -> bool {
    match (a, b) {
        (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
            (Some(fx), Some(fy)) => fx == fy,
            _ => x == y,
        },
        (Value::Array(xs), Value::Array(ys)) => {
            xs.len() == ys.len() && xs.iter().zip(ys).all(|(x, y)| json_struct_eq(x, y))
        }
        (Value::Object(xs), Value::Object(ys)) => {
            xs.len() == ys.len()
                && xs
                    .iter()
                    .all(|(k, x)| ys.get(k).is_some_and(|y| json_struct_eq(x, y)))
        }
        _ => a == b,
    }
}

/// Load a fixture doc + its golden, run `detect_all`, assert structural parity.
///
/// Both sides are compared as `serde_json::Value` (not the typed `Issue`) so an
/// absent `suggestedValue` key in the golden normalizes to `null` rather than
/// failing deserialization against the non-optional Rust field.
fn assert_fixture_parity(name: &str, doc_json: &str, golden_json: &str) {
    let doc: PenDocument = serde_json::from_str(doc_json)
        .unwrap_or_else(|e| panic!("fixture `{name}` doc must deserialize as PenDocument: {e}"));
    let root = doc
        .children
        .first()
        .unwrap_or_else(|| panic!("fixture `{name}` must have a root node in children[0]"));

    // Serialize the Rust output to JSON so both sides are compared in the same
    // (camelCase, TS-wire) representation.
    let actual_issues = detect_all(root, &doc);
    let actual: Value = serde_json::to_value(&actual_issues)
        .unwrap_or_else(|e| panic!("fixture `{name}`: Rust issues must serialize: {e}"));
    let actual = actual
        .as_array()
        .unwrap_or_else(|| panic!("fixture `{name}`: Rust issue list is not a JSON array"));

    let golden: Value = serde_json::from_str(golden_json)
        .unwrap_or_else(|e| panic!("golden `{name}` must be valid JSON: {e}"));
    let golden = golden
        .as_array()
        .unwrap_or_else(|| panic!("golden `{name}` is not a JSON array"));

    assert_eq!(
        actual.len(),
        golden.len(),
        "fixture `{name}`: issue count mismatch — Rust produced {} issue(s), \
         TS golden has {}.\n  Rust:   {:#?}\n  Golden: {:#?}",
        actual.len(),
        golden.len(),
        actual,
        golden,
    );

    for (i, (got, want)) in actual.iter().zip(golden.iter()).enumerate() {
        let node = field(want, "nodeId").as_str().unwrap_or("?");
        for key in ["nodeId", "category", "severity", "property"] {
            assert_eq!(
                field(got, key),
                field(want, key),
                "fixture `{name}` issue #{i} (node `{node}`): `{key}` mismatch"
            );
        }
        // `currentValue` / `suggestedValue` — absent key normalizes to `null`
        // so TS `undefined` matches Rust `null`; numbers compare by value so
        // `0` matches `0.0` (typed-struct re-serialization artifact).
        for key in ["currentValue", "suggestedValue"] {
            let (got_v, want_v) = (field(got, key), field(want, key));
            assert!(
                json_struct_eq(got_v, want_v),
                "fixture `{name}` issue #{i} (node `{node}`): `{key}` mismatch\n  \
                 Rust:   {got_v}\n  Golden: {want_v}",
            );
        }
        // `reason` — 12-char prefix match (spec §8). A divergence past the
        // prefix is tolerated (float formatting); a divergence inside it is a
        // detector emitting the wrong reason class.
        assert_eq!(
            reason_prefix(field(got, "reason")),
            reason_prefix(field(want, "reason")),
            "fixture `{name}` issue #{i} (node `{node}`): reason prefix mismatch\n  \
             Rust:   {}\n  Golden: {}",
            field(got, "reason"),
            field(want, "reason"),
        );
    }
}

/// One `#[test]` per fixture — `include_str!` bakes the doc + golden into the
/// binary so the test has no filesystem dependency at run time. A new fixture
/// is wired by adding one `parity_case!` line.
macro_rules! parity_case {
    ($test_name:ident, $fixture:literal) => {
        #[test]
        fn $test_name() {
            assert_fixture_parity(
                $fixture,
                include_str!(concat!("fixtures/docs/", $fixture, ".json")),
                include_str!(concat!("fixtures/golden/", $fixture, ".json")),
            );
        }
    };
}

parity_case!(parity_clean, "clean");
parity_case!(parity_contrast_boundary, "contrast-boundary");
parity_case!(parity_edge_section_padding, "edge-section-padding");
parity_case!(parity_empty_path, "empty-path");
parity_case!(parity_excessive_frame_effects, "excessive-frame-effects");
parity_case!(parity_invisible_container, "invisible-container");
parity_case!(
    parity_invisible_container_with_var,
    "invisible-container-with-var"
);
parity_case!(parity_mixed_sibling, "mixed-sibling");
parity_case!(parity_sibling_inconsistency, "sibling-inconsistency");
parity_case!(
    parity_stacked_horizontal_padding,
    "stacked-horizontal-padding"
);
parity_case!(parity_text_bg_contrast, "text-bg-contrast");
parity_case!(parity_text_effect, "text-effect");
parity_case!(parity_text_explicit_height, "text-explicit-height");
parity_case!(parity_unexpected_rotation, "unexpected-rotation");
