//! Slice-3 golden-fixture tests for the tap protocol's new event types.
//!
//! ## Why this file exists (alongside `protocol.rs`'s inline tests)
//!
//! `protocol.rs` already has inline round-trip tests for each new slice-3
//! event kind, but they assert with `String::contains` — a *substring* check.
//! That catches "the right `type` tag is present" but it cannot catch:
//!
//! - a change in **field order** (the `Envelope` `Serialize` impl at
//!   `protocol.rs::Serialize_for_Envelope` pins field order deliberately —
//!   see its doc comment: "field order is pinned to make the wire bytes
//!   deterministic… helps snapshot tests and interop with brains that read
//!   key order for debugging");
//! - a stray extra field;
//! - a missing field;
//! - a change in whitespace or number formatting.
//!
//! These fixtures lock the **exact wire bytes** produced by each `encode_*`
//! function. Any future refactor that alters the on-wire shape — even
//! cosmetically — breaks a test here, forcing a conscious fixture update +
//! a protocol-version bump conversation (spec §3.1: `PROTOCOL_VERSION` is
//! the contract door).
//!
//! ## Forwards-compat (the slice-2 §3.4 "drop + observe" contract)
//!
//! The last test here re-asserts the load-bearing decode-side rule: an
//! unknown `type` string deserializes to `DecodedPayload::Unknown` (via
//! `FrameKind`'s `#[serde(other)]` fallback) rather than erroring. This is
//! the foundation of the forwards-compat posture — a brain speaking a
//! future protocol version's event kind must not crash an older core; it
//! gets logged + counted + dropped (the behavioral side lives in
//! `tap_client::handle_brain_frame` and is exercised end-to-end by the
//! slice-2 / slice-3 integration tests, not here).
//!
//! ## Why string equality (not `serde_json::Value` equality)
//!
//! Comparing parsed `serde_json::Value`s would be *semantic* equality — it
//! would treat `{"a":1,"b":2}` and `{"b":2,"a":1}` as the same. That
//! throws away the field-order pinning the `Envelope` serializer
//! guarantees. Comparing the raw encoded `String` to the fixture's bytes
//! keeps the order contract load-bearing.

use std::fs;

use rutster_tap::{
    DecodedPayload, decode_envelope, encode_function_call, encode_function_call_output,
    encode_speech_started, encode_speech_stopped, encode_tools_update,
};

/// Read a fixture file from `tests/fixtures/<name>`. Uses
/// `CARGO_MANIFEST_DIR` so the path is stable regardless of which
/// directory `cargo test` is invoked from. Trailing newlines are stripped
/// because `serde_json::to_string` (what the encoders call) never emits one
/// — a fixture checked into git shouldn't acquire a phantom newline mismatch.
fn fixture(name: &str) -> String {
    let path = format!("{}/tests/fixtures/{}", env!("CARGO_MANIFEST_DIR"), name);
    let raw =
        fs::read_to_string(&path).unwrap_or_else(|e| panic!("read fixture {name} ({path}): {e}"));
    raw.trim_end().to_string()
}

// ─── speech_started ──────────────────────────────────────────────────────

#[test]
fn golden_speech_started_wire_bytes_match_fixture() {
    let encoded = encode_speech_started(7, 100).unwrap();
    let golden = fixture("speech_started.json");
    assert_eq!(
        encoded, golden,
        "speech_started wire bytes drifted from golden fixture"
    );
}

#[test]
fn speech_started_fixture_decodes_to_speechstarted_variant() {
    let golden = fixture("speech_started.json");
    let decoded = decode_envelope(&golden).unwrap();
    assert_eq!(decoded.seq, 7);
    assert_eq!(decoded.ts, 100);
    assert!(
        matches!(decoded.payload, DecodedPayload::SpeechStarted),
        "speech_started fixture did not decode to SpeechStarted: {:?}",
        decoded.payload
    );
}

// ─── speech_stopped ──────────────────────────────────────────────────────

#[test]
fn golden_speech_stopped_wire_bytes_match_fixture() {
    let encoded = encode_speech_stopped(9, 200).unwrap();
    let golden = fixture("speech_stopped.json");
    assert_eq!(
        encoded, golden,
        "speech_stopped wire bytes drifted from golden fixture"
    );
}

#[test]
fn speech_stopped_fixture_decodes_to_speechstopped_variant() {
    let golden = fixture("speech_stopped.json");
    let decoded = decode_envelope(&golden).unwrap();
    assert_eq!(decoded.seq, 9);
    assert_eq!(decoded.ts, 200);
    assert!(
        matches!(decoded.payload, DecodedPayload::SpeechStopped),
        "speech_stopped fixture did not decode to SpeechStopped: {:?}",
        decoded.payload
    );
}

// ─── function_call ───────────────────────────────────────────────────────

#[test]
fn golden_function_call_wire_bytes_match_fixture() {
    // `args_json_str="{}"` — the encoder parses it into `serde_json::Value`
    // (an empty object) so the wire field is `"args":{}`, not `"args":"{}"`.
    // This is load-bearing: a brain emitting a function_call with a raw
    // JSON-string args would be a different wire shape (and a translator
    // bug). The fixture pins the parsed-object shape.
    let encoded = encode_function_call("abc-123", "hangup", "{}", 0, 0).unwrap();
    let golden = fixture("function_call.json");
    assert_eq!(
        encoded, golden,
        "function_call wire bytes drifted from golden fixture"
    );
}

#[test]
fn function_call_fixture_decodes_to_functioncall_with_fields() {
    let golden = fixture("function_call.json");
    let decoded = decode_envelope(&golden).unwrap();
    match decoded.payload {
        DecodedPayload::FunctionCall(p) => {
            assert_eq!(p.id, "abc-123");
            assert_eq!(p.name, "hangup");
            assert_eq!(p.args, serde_json::Value::Object(serde_json::Map::new()));
        }
        other => panic!("expected FunctionCall, got {other:?}"),
    }
}

// ─── function_call_output ───────────────────────────────────────────────

#[test]
fn golden_function_call_output_wire_bytes_match_fixture() {
    let encoded =
        encode_function_call_output("abc-123", "ok", r#"{"channel_state":"Closing"}"#, 0, 0)
            .unwrap();
    let golden = fixture("function_call_output.json");
    assert_eq!(
        encoded, golden,
        "function_call_output wire bytes drifted from golden fixture"
    );
}

#[test]
fn function_call_output_fixture_decodes_to_functioncalloutput_with_fields() {
    let golden = fixture("function_call_output.json");
    let decoded = decode_envelope(&golden).unwrap();
    match decoded.payload {
        DecodedPayload::FunctionCallOutput(p) => {
            assert_eq!(p.id, "abc-123");
            assert_eq!(p.status, "ok");
            // The result field is a parsed JSON object, not a string.
            assert_eq!(
                p.result.get("channel_state"),
                Some(&serde_json::json!("Closing"))
            );
        }
        other => panic!("expected FunctionCallOutput, got {other:?}"),
    }
}

// ─── tools.update ───────────────────────────────────────────────────────

#[test]
fn golden_tools_update_wire_bytes_match_fixture() {
    let tools_json = r#"[{"name":"hangup","description":"hang up the call"}]"#;
    let encoded = encode_tools_update(tools_json, 0, 0).unwrap();
    let golden = fixture("tools_update.json");
    assert_eq!(
        encoded, golden,
        "tools.update wire bytes drifted from golden fixture"
    );
}

#[test]
fn tools_update_fixture_decodes_to_toolsupdate_with_array() {
    let golden = fixture("tools_update.json");
    let decoded = decode_envelope(&golden).unwrap();
    match decoded.payload {
        DecodedPayload::ToolsUpdate(p) => {
            let arr = p
                .tools
                .as_array()
                .expect("tools field must be a JSON array");
            assert_eq!(arr.len(), 1);
            assert_eq!(arr[0].get("name"), Some(&serde_json::json!("hangup")));
        }
        other => panic!("expected ToolsUpdate, got {other:?}"),
    }
}

// ─── forwards-compat (slice-2 §3.4 "drop + observe") ────────────────────

/// The load-bearing forwards-compat rule on the decode side: an unknown
/// `type` string deserializes to `DecodedPayload::Unknown` (via
/// `FrameKind`'s `#[serde(other)]`), **not** an error. A future-protocol
/// brain emitting `"type":"future_event"` must not crash an older core;
/// the core logs + counts + drops it (the observe-and-continue posture
/// the eventual fuzz harness will stress). This re-asserts the contract
/// the inline `unknown_type_decodes_to_unknown_variant` test established,
/// here in the external fixture file so it sits next to the golden wire
/// shapes it protects.
#[test]
fn unknown_wire_type_decodes_to_unknown_variant_not_error() {
    let wire = r#"{"v":1,"type":"future_event","seq":0,"ts":0}"#;
    let decoded = decode_envelope(wire).expect("unknown type must not error (forwards-compat)");
    assert!(
        matches!(decoded.payload, DecodedPayload::Unknown),
        "forwards-compat broken: unknown type decoded to {:?}, expected Unknown",
        decoded.payload
    );
}
