//! Replay a recorded Blaze conversation against a LIVE endpoint and emit a
//! normalized trace.
//!
//! This is the A/B instrument. Point it at the Python backend, then at the Rust
//! sidecar, and diff the two traces:
//!
//! ```text
//!   blaze-probe 127.0.0.1:42130 > /tmp/python.trace
//!   blaze-probe 127.0.0.1:42230 > /tmp/rust.trace
//!   diff /tmp/python.trace /tmp/rust.trace
//! ```
//!
//! Byte comparison is impossible across two live servers — session keys and
//! server timestamps differ by design — so the trace masks known-volatile
//! values while preserving their tag, type and length. Everything else must
//! match exactly, including frame counts and notification ordering.
//!
//! The requests come from `openfut-adapter-fifa17/fixtures/blaze_transactions.jsonl`,
//! so this exercises the same conversation the offline parity suite does, but
//! over a real socket against a real server process.
//!
//! Read-only: it opens a client connection and sends recorded requests. It
//! writes nothing and mutates no state beyond the server's own per-connection
//! session.

use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;

use openfut_blaze_host::trace;
use openfut_protocol_blaze::fire2::{Frame, Header, HEADER_LEN};

fn usage() -> ! {
    eprintln!("usage: blaze-probe <host:port> [--verbose] [--session main|fallbacks|locale]");
    eprintln!();
    eprintln!("Replays the recorded Blaze conversation against a live endpoint and");
    eprintln!("prints a normalized, volatile-masked trace on stdout.");
    std::process::exit(2);
}

fn fixtures_path() -> String {
    format!(
        "{}/../openfut-adapter-fifa17/fixtures/blaze_transactions.jsonl",
        env!("CARGO_MANIFEST_DIR")
    )
}

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    if args.is_empty() || args[0].starts_with("--") {
        usage();
    }
    let endpoint = args[0].clone();
    let verbose = args.iter().any(|a| a == "--verbose");
    let want_session = args
        .windows(2)
        .find(|w| w[0] == "--session")
        .map(|w| w[1].clone())
        .unwrap_or_else(|| "main".to_string());

    let path = fixtures_path();
    let text = std::fs::read_to_string(&path).unwrap_or_else(|e| {
        eprintln!("cannot read {path}: {e}");
        std::process::exit(1)
    });

    let records: Vec<serde_json::Value> = text
        .lines()
        .filter(|l| !l.trim().is_empty())
        .map(|l| serde_json::from_str(l).expect("fixture line is valid JSON"))
        .collect();

    let requests: Vec<(String, Vec<u8>)> = records
        .iter()
        .filter(|r| r["kind"] == "tx" && r["session"] == want_session.as_str())
        .map(|r| {
            (
                r["name"].as_str().unwrap().to_string(),
                unhex(r["request_hex"].as_str().unwrap()),
            )
        })
        .collect();

    if requests.is_empty() {
        eprintln!("no transactions for session {want_session:?}");
        std::process::exit(1);
    }

    eprintln!(
        "blaze-probe: {} requests from session {want_session:?} -> {endpoint}",
        requests.len()
    );

    let mut stream = TcpStream::connect(&endpoint).unwrap_or_else(|e| {
        eprintln!("cannot connect to {endpoint}: {e}");
        std::process::exit(1);
    });
    let _ = stream.set_nodelay(true);
    // Generous but finite: a server that answers nothing must not hang the probe.
    let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));

    println!("# blaze-probe conversation: session={want_session}");
    println!("# endpoint intentionally omitted so traces from different hosts diff cleanly");

    let mut buf: Vec<u8> = Vec::new();
    let mut mismatched = 0usize;

    for (n, (name, request)) in requests.iter().enumerate() {
        let req_frame = Frame::parse(request).expect("fixture request parses").0;
        println!("--- {:02} {name}", n + 1);
        println!(
            "{}",
            trace::trace_line(0, "TX", &format!("{}", n + 1), &req_frame)
        );

        if let Err(e) = stream.write_all(request) {
            println!("!! send failed: {e}");
            mismatched += 1;
            break;
        }
        let _ = stream.flush();

        // How many frames to expect is recorded; a server that sends fewer is
        // the failure this probe exists to catch, so read with a timeout rather
        // than blocking forever.
        let expected = records
            .iter()
            .find(|r| r["kind"] == "tx" && r["name"] == name.as_str())
            .and_then(|r| r["responses"].as_array())
            .map(|a| a.len())
            .unwrap_or(0);

        let mut got = 0usize;
        while got < expected {
            match read_frame(&mut stream, &mut buf) {
                Ok(Some(frame)) => {
                    println!(
                        "{}",
                        trace::trace_line(0, "RX", &format!("{}.{}", n + 1, got), &frame)
                    );
                    if verbose && !frame.payload.is_empty() {
                        if let Ok(body) = openfut_protocol_blaze::heat2::decode(&frame.payload) {
                            for line in trace::masked_dump(&body).lines() {
                                println!("    {line}");
                            }
                        }
                    }
                    got += 1;
                }
                Ok(None) => {
                    println!("!! connection closed after {got}/{expected} frame(s)");
                    mismatched += 1;
                    break;
                }
                Err(e) => {
                    println!("!! read failed after {got}/{expected} frame(s): {e}");
                    mismatched += 1;
                    break;
                }
            }
        }
        if got != expected {
            println!("!! frame count {got}, recorded {expected}");
            mismatched += 1;
        }
    }

    println!(
        "# done: {} request(s), {mismatched} anomaly/anomalies",
        requests.len()
    );
    if mismatched > 0 {
        std::process::exit(1);
    }
}

fn read_frame(stream: &mut TcpStream, buf: &mut Vec<u8>) -> std::io::Result<Option<Frame>> {
    let mut chunk = [0u8; 65536];
    while buf.len() < HEADER_LEN {
        match stream.read(&mut chunk)? {
            0 => return Ok(None),
            got => buf.extend_from_slice(&chunk[..got]),
        }
    }
    let header =
        Header::parse(&buf[..HEADER_LEN]).map_err(|e| std::io::Error::other(e.to_string()))?;
    let total = header.frame_len();
    while buf.len() < total {
        match stream.read(&mut chunk)? {
            0 => return Ok(None),
            got => buf.extend_from_slice(&chunk[..got]),
        }
    }
    let (frame, used) =
        Frame::parse(&buf[..total]).map_err(|e| std::io::Error::other(e.to_string()))?;
    buf.drain(..used);
    Ok(Some(frame))
}

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