//! Server-cassette extraction for distillation.
//!
//! Why this exists: a server cassette is an ordered list of JSON-RPC
//! exchanges, but only the `tools/call` ones can become tool tests. This
//! module owns the walk that lifts those calls (post-redaction), records
//! what was skipped for the summary, and renders the `servers:` block that
//! points the distilled suite back at the cassette so it replays offline.

use serde_json::{json, Value};

use super::{yaml_quote, Extraction, RecordedCall};
use crate::cassette::{Cassette, META_SOURCE_COMMAND, META_SOURCE_URL};
use crate::redaction::Redactor;

/// Lift every recorded `tools/call` out of a server cassette.
///
/// The whole cassette is run through the [`Redactor`] first so nothing
/// secret-shaped can reach the generated YAML. Non-tool exchanges are
/// noted in the skip summary, and replay-coverage caveats land in the
/// notes.
pub(crate) fn extract(cassette: &Cassette, server_name: &str, cassette_path: &str) -> Extraction {
    let redacted = cassette.redacted(&Redactor::new());
    let mut calls = Vec::new();
    let mut skipped = Vec::new();
    for (index, exchange) in redacted.exchanges.iter().enumerate() {
        let method = exchange.request.method.as_deref().unwrap_or("(no method)");
        if method != "tools/call" {
            skipped.push(format!("#{index} {method}"));
            continue;
        }
        let Some(result) = exchange.response.result.clone() else {
            skipped.push(format!("#{index} tools/call (recorded a JSON-RPC error)"));
            continue;
        };
        let params = exchange.request.params.clone().unwrap_or(Value::Null);
        let Some(tool) = params.get("name").and_then(Value::as_str) else {
            skipped.push(format!("#{index} tools/call (no tool name in params)"));
            continue;
        };
        calls.push(RecordedCall {
            server: server_name.to_string(),
            tool: tool.to_string(),
            args: params
                .get("arguments")
                .cloned()
                .unwrap_or_else(|| json!({})),
            result,
            source_index: index,
        });
    }
    Extraction {
        notes: replay_notes(&redacted, &calls),
        servers_block: servers_block(&redacted, server_name, cassette_path),
        calls,
        skipped,
    }
}

/// Caveats about replay coverage: a missing handshake or exchanges the
/// distilled tests never re-send (a mid-cassette `tools/list`, an error
/// exchange). Multi-call cassettes need no caveat since replay matches
/// requests by content (WOR-1283), not by recorded position.
fn replay_notes(cassette: &Cassette, calls: &[RecordedCall]) -> Vec<String> {
    let mut notes = Vec::new();
    let leading_initialize = cassette
        .exchanges
        .first()
        .is_some_and(|e| e.request.method.as_deref() == Some("initialize"));
    if !leading_initialize {
        notes.push(
            "the cassette does not start with an initialize exchange, so the replay \
             handshake will mismatch; re-record it or run the tests against the live target"
                .to_string(),
        );
    }
    let replayable: Vec<usize> = calls.iter().map(|c| c.source_index).collect();
    let mid_gaps = cassette
        .exchanges
        .iter()
        .enumerate()
        .skip(usize::from(leading_initialize))
        .any(|(i, _)| !replayable.contains(&i));
    if mid_gaps {
        notes.push(
            "the cassette holds exchanges no distilled test re-sends (see the skipped \
             list); content-matched replay leaves them unconsumed, so they are harmless \
             but can be trimmed to keep the recording minimal"
                .to_string(),
        );
    }
    notes
}

/// Render the `servers:` block: the suite replays the cassette itself, and
/// a comment names the live target recorded in the cassette `meta` (when
/// present) so the operator knows where the traffic originally came from.
fn servers_block(cassette: &Cassette, server_name: &str, cassette_path: &str) -> String {
    let mut out = String::from("servers:\n");
    out.push_str(&format!("  {server_name}:\n"));
    out.push_str("    # Replays the recorded exchanges offline; no live server is contacted.\n");
    if let Some(line) = cassette.meta.get(META_SOURCE_COMMAND) {
        out.push_str(&format!(
            "    # Live target recorded in cassette meta ({META_SOURCE_COMMAND}): {line}\n"
        ));
    } else if let Some(url) = cassette.meta.get(META_SOURCE_URL) {
        out.push_str(&format!(
            "    # Live target recorded in cassette meta ({META_SOURCE_URL}): {url}\n"
        ));
    } else {
        out.push_str(&format!(
            "    # The cassette names no live target ({META_SOURCE_COMMAND} / \
             {META_SOURCE_URL} in its meta block).\n"
        ));
    }
    out.push_str(&format!("    cassette: {}\n", yaml_quote(cassette_path)));
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cassette::Exchange;
    use crate::protocol::{JsonRpcId, JsonRpcMessage};

    fn exchange(id: i64, method: &str, params: Value, result: Value) -> Exchange {
        Exchange {
            request: JsonRpcMessage::request(JsonRpcId::Number(id), method, params),
            response: JsonRpcMessage::response(JsonRpcId::Number(id), result),
        }
    }

    fn cassette_with(exchanges: Vec<Exchange>) -> Cassette {
        let mut c = Cassette::new();
        for e in exchanges {
            c.push(e);
        }
        c
    }

    #[test]
    fn lifts_tool_calls_and_skips_handshake_and_listing_exchanges() {
        let c = cassette_with(vec![
            exchange(1, "initialize", json!({}), json!({"capabilities": {}})),
            exchange(2, "tools/list", json!({}), json!({"tools": []})),
            exchange(
                3,
                "tools/call",
                json!({"name": "echo", "arguments": {"msg": "hi"}}),
                json!({"content": [{"type": "text", "text": "hi"}]}),
            ),
        ]);
        let ex = extract(&c, "recorded", "./c.json");
        assert_eq!(ex.calls.len(), 1);
        assert_eq!(ex.calls[0].tool, "echo");
        assert_eq!(ex.calls[0].args, json!({"msg": "hi"}));
        assert_eq!(ex.calls[0].source_index, 2);
        assert_eq!(ex.skipped, vec!["#0 initialize", "#1 tools/list"]);
        // The mid-cassette tools/list is never re-sent; the note says it
        // stays unconsumed under content-matched replay.
        assert!(ex.notes.iter().any(|n| n.contains("unconsumed")));
    }

    #[test]
    fn redacts_secret_shaped_argument_values_before_distilling() {
        let c = cassette_with(vec![exchange(
            1,
            "tools/call",
            json!({"name": "login", "arguments": {"api_key": "sk-abc1234567890DEF"}}),
            json!({"content": []}),
        )]);
        let ex = extract(&c, "recorded", "./c.json");
        let key = ex.calls[0].args["api_key"].as_str().expect("string arg");
        assert!(key.starts_with("[REDACTED:"), "got {key}");
    }

    #[test]
    fn missing_leading_initialize_is_called_out() {
        let c = cassette_with(vec![exchange(
            1,
            "tools/call",
            json!({"name": "echo", "arguments": {}}),
            json!({"content": []}),
        )]);
        let ex = extract(&c, "recorded", "./c.json");
        assert!(ex
            .notes
            .iter()
            .any(|n| n.contains("does not start with an initialize")));
    }

    #[test]
    fn servers_block_names_the_recorded_live_target_in_a_comment() {
        let mut c = cassette_with(vec![exchange(
            1,
            "initialize",
            json!({}),
            json!({"capabilities": {}}),
        )]);
        c.meta
            .insert(META_SOURCE_COMMAND.to_string(), "python server.py".into());
        let ex = extract(&c, "recorded", "./tape.json");
        assert!(ex.servers_block.contains("cassette: \"./tape.json\""));
        assert!(ex.servers_block.contains(
            "# Live target recorded in cassette meta (source_command): python server.py"
        ));
    }

    #[test]
    fn tool_call_with_error_response_is_skipped_with_a_reason() {
        let mut c = Cassette::new();
        c.push(exchange(1, "initialize", json!({}), json!({})));
        c.push(Exchange {
            request: JsonRpcMessage::request(
                JsonRpcId::Number(2),
                "tools/call",
                json!({"name": "boom", "arguments": {}}),
            ),
            response: JsonRpcMessage::error_response(
                JsonRpcId::Number(2),
                crate::protocol::JsonRpcErrorBody {
                    code: -32000,
                    message: "kaput".into(),
                    data: None,
                },
            ),
        });
        let ex = extract(&c, "recorded", "./c.json");
        assert!(ex.calls.is_empty());
        assert!(ex
            .skipped
            .iter()
            .any(|s| s.contains("recorded a JSON-RPC error")));
    }
}
