//! Session ledger: a structured, append-only run record of MCP tool calls.
//!
//! The ledger is the artifact behavioral eval queries instead of parsing a
//! transcript. This module owns the typed records ([`LedgerEntry`]), the NDJSON
//! serialization that matches `schemas/session-ledger-v1.json`, the conversion
//! from an agent run envelope, and a structured trajectory [`diff`] that a
//! baseline gate compares against.
//!
//! It works on the agent envelope as plain JSON (not the `mcptest-agent` types)
//! so the core stays free of a dependency cycle.

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// The ledger schema version this module emits.
pub const SCHEMA_VERSION: &str = "v1";

/// One record in a ledger file. Serializes to a single NDJSON line, internally
/// tagged by `type` to match `schemas/session-ledger-v1.json`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LedgerEntry {
    /// First line of a ledger file: session/run metadata.
    Header(Header),
    /// One MCP tool call as it crossed the boundary.
    ToolCall(ToolCall),
}

/// Ledger header record.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Header {
    /// Schema version the file conforms to.
    pub schema_version: String,
    /// Groups every record from one run or user task.
    pub session_id: String,
    /// The mcptest run id the session was derived from.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub run_id: Option<String>,
    /// RFC 3339 start time.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub started_at: Option<String>,
    /// Emitting binary version.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mcptest_version: Option<String>,
    /// Suite file path or name.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub suite: Option<String>,
}

/// Ledger tool-call record. The fields are the structure eval queries.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
    /// Session this call belongs to.
    pub session_id: String,
    /// Which agent made the call (multi-agent runs); `None` for single-agent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agent_id: Option<String>,
    /// Zero-based position of the call in the run.
    pub hop_index: usize,
    /// Bare tool name.
    pub tool_name: String,
    /// MCP server the call routed to.
    pub server: String,
    /// Structured arguments (redacted).
    #[serde(default, skip_serializing_if = "Value::is_null")]
    pub params: Value,
    /// Tool result payload (redacted).
    #[serde(default, skip_serializing_if = "Value::is_null")]
    pub result: Value,
    /// Whether the server flagged the result as an error.
    #[serde(default)]
    pub is_error: bool,
    /// Fingerprint of the context the agent had at the call.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub inputs_digest: Option<String>,
    /// How the call was issued (`direct` / `code_execution`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub caller: Option<String>,
}

/// Metadata for the header record built when emitting a ledger.
#[derive(Debug, Clone, Default)]
pub struct HeaderMeta {
    /// The run id the session derives from.
    pub run_id: Option<String>,
    /// RFC 3339 run start time.
    pub started_at: Option<String>,
    /// Emitting binary version.
    pub mcptest_version: Option<String>,
    /// Suite file path or name.
    pub suite: Option<String>,
}

/// Build a ledger (header + one `ToolCall` per call) from an agent run
/// envelope (the JSON [`mcptest-agent` produces]). Results are joined from the
/// envelope's `tool_results` by call index when present.
pub fn entries_from_envelope(
    envelope: &Value,
    session_id: &str,
    meta: HeaderMeta,
) -> Vec<LedgerEntry> {
    let mut out = vec![LedgerEntry::Header(Header {
        schema_version: SCHEMA_VERSION.to_string(),
        session_id: session_id.to_string(),
        run_id: meta.run_id,
        started_at: meta.started_at,
        mcptest_version: meta.mcptest_version,
        suite: meta.suite,
    })];
    let results = envelope.get("tool_results").and_then(Value::as_array);
    if let Some(calls) = envelope.get("tool_calls").and_then(Value::as_array) {
        for (index, call) in calls.iter().enumerate() {
            let result = results
                .and_then(|rs| rs.get(index))
                .and_then(|r| r.get("result"))
                .cloned()
                .unwrap_or(Value::Null);
            let is_error = results
                .and_then(|rs| rs.get(index))
                .and_then(|r| r.get("is_error"))
                .and_then(Value::as_bool)
                .unwrap_or(false);
            out.push(LedgerEntry::ToolCall(ToolCall {
                session_id: session_id.to_string(),
                agent_id: str_field(call, "agent_id"),
                hop_index: call
                    .get("hop_index")
                    .and_then(Value::as_u64)
                    .map(|n| n as usize)
                    .unwrap_or(index),
                tool_name: str_field(call, "name").unwrap_or_default(),
                server: str_field(call, "server").unwrap_or_default(),
                params: call.get("args").cloned().unwrap_or(Value::Null),
                result,
                is_error,
                inputs_digest: str_field(call, "inputs_digest"),
                caller: call
                    .get("caller")
                    .and_then(|c| c.get("kind"))
                    .and_then(Value::as_str)
                    .map(|_| "code_execution".to_string())
                    .or_else(|| Some("direct".to_string())),
            }));
        }
    }
    out
}

fn str_field(value: &Value, key: &str) -> Option<String> {
    value.get(key).and_then(Value::as_str).map(String::from)
}

/// Serialize ledger entries to NDJSON (one record per line).
pub fn to_ndjson(entries: &[LedgerEntry]) -> String {
    let mut out = String::new();
    for entry in entries {
        if let Ok(line) = serde_json::to_string(entry) {
            out.push_str(&line);
            out.push('\n');
        }
    }
    out
}

/// Parse NDJSON back into ledger entries, skipping blank lines. Returns an
/// error string naming the first line that fails to parse.
pub fn from_ndjson(text: &str) -> Result<Vec<LedgerEntry>, String> {
    let mut entries = Vec::new();
    for (lineno, line) in text.lines().enumerate() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let entry = serde_json::from_str(trimmed)
            .map_err(|e| format!("ledger line {}: {e}", lineno + 1))?;
        entries.push(entry);
    }
    Ok(entries)
}

/// The tool calls in a ledger, in order (header records dropped).
pub fn tool_calls(entries: &[LedgerEntry]) -> Vec<&ToolCall> {
    entries
        .iter()
        .filter_map(|e| match e {
            LedgerEntry::ToolCall(tc) => Some(tc),
            LedgerEntry::Header(_) => None,
        })
        .collect()
}

/// A structured difference between a baseline ledger and an actual one, scoped
/// per agent so a planner baseline does not conflate with a worker baseline.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct LedgerDiff {
    /// `hop_index: tool_name` present in actual but not the baseline.
    pub added: Vec<String>,
    /// Present in the baseline but missing from actual.
    pub removed: Vec<String>,
    /// Same tool at the same hop, but the params differ.
    pub param_changed: Vec<String>,
}

impl LedgerDiff {
    /// Total number of divergences. The baseline gate fails when this exceeds
    /// the allowed threshold.
    pub fn size(&self) -> usize {
        self.added.len() + self.removed.len() + self.param_changed.len()
    }

    /// Whether the actual run matched the baseline exactly.
    pub fn is_clean(&self) -> bool {
        self.size() == 0
    }
}

/// Diff the tool-call shape of `actual` against `baseline`, position by
/// position within each `agent_id` group. A differing tool name at a hop is a
/// remove + add; a matching name with differing params is a `param_changed`.
pub fn diff(baseline: &[LedgerEntry], actual: &[LedgerEntry]) -> LedgerDiff {
    let mut out = LedgerDiff::default();
    let base = group_by_agent(&tool_calls(baseline));
    let act = group_by_agent(&tool_calls(actual));
    let mut agents: Vec<&Option<String>> = base.keys().chain(act.keys()).collect();
    agents.sort();
    agents.dedup();
    for agent in agents {
        let b = base.get(agent).map(Vec::as_slice).unwrap_or(&[]);
        let a = act.get(agent).map(Vec::as_slice).unwrap_or(&[]);
        for i in 0..b.len().max(a.len()) {
            match (b.get(i), a.get(i)) {
                (Some(bc), Some(ac)) if bc.tool_name == ac.tool_name => {
                    if bc.params != ac.params {
                        out.param_changed.push(label(ac));
                    }
                }
                (Some(bc), Some(ac)) => {
                    out.removed.push(label(bc));
                    out.added.push(label(ac));
                }
                (Some(bc), None) => out.removed.push(label(bc)),
                (None, Some(ac)) => out.added.push(label(ac)),
                (None, None) => {}
            }
        }
    }
    out
}

fn group_by_agent<'a>(
    calls: &[&'a ToolCall],
) -> std::collections::BTreeMap<Option<String>, Vec<&'a ToolCall>> {
    let mut map: std::collections::BTreeMap<Option<String>, Vec<&'a ToolCall>> =
        std::collections::BTreeMap::new();
    for call in calls {
        map.entry(call.agent_id.clone()).or_default().push(call);
    }
    map
}

fn label(call: &ToolCall) -> String {
    match &call.agent_id {
        Some(a) => format!("{a}/hop {}: {}", call.hop_index, call.tool_name),
        None => format!("hop {}: {}", call.hop_index, call.tool_name),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn envelope() -> Value {
        json!({
            "tool_calls": [
                {"name": "search", "server": "web", "args": {"q": "rust"}, "hop_index": 0, "inputs_digest": "aaa"},
                {"name": "fetch", "server": "web", "args": {"id": 1}, "hop_index": 1}
            ],
            "tool_results": [
                {"is_error": false, "result": {"ok": true}},
                {"is_error": true, "result": {"err": "x"}}
            ]
        })
    }

    #[test]
    fn entries_from_envelope_builds_header_and_calls_in_order() {
        let entries = entries_from_envelope(&envelope(), "sess-1", HeaderMeta::default());
        assert!(matches!(entries[0], LedgerEntry::Header(_)));
        let calls = tool_calls(&entries);
        assert_eq!(calls.len(), 2);
        assert_eq!(calls[0].tool_name, "search");
        assert_eq!(calls[0].hop_index, 0);
        assert_eq!(calls[0].params, json!({"q": "rust"}));
        assert_eq!(calls[1].tool_name, "fetch");
        assert!(calls[1].is_error, "result join carries is_error");
    }

    #[test]
    fn ndjson_round_trips() {
        let entries = entries_from_envelope(&envelope(), "sess-1", HeaderMeta::default());
        let text = to_ndjson(&entries);
        assert_eq!(text.lines().count(), 3);
        let back = from_ndjson(&text).expect("parse");
        assert_eq!(entries, back);
    }

    #[test]
    fn identical_runs_diff_clean() {
        let base = entries_from_envelope(&envelope(), "a", HeaderMeta::default());
        let act = entries_from_envelope(&envelope(), "b", HeaderMeta::default());
        assert!(diff(&base, &act).is_clean());
    }

    #[test]
    fn changed_tool_at_a_hop_is_a_remove_plus_add() {
        let base = entries_from_envelope(&envelope(), "a", HeaderMeta::default());
        let mut other = envelope();
        other["tool_calls"][1]["name"] = json!("delete");
        let act = entries_from_envelope(&other, "b", HeaderMeta::default());
        let d = diff(&base, &act);
        assert_eq!(d.removed, vec!["hop 1: fetch"]);
        assert_eq!(d.added, vec!["hop 1: delete"]);
        assert_eq!(d.size(), 2);
    }

    #[test]
    fn same_tool_with_changed_params_is_param_changed() {
        let base = entries_from_envelope(&envelope(), "a", HeaderMeta::default());
        let mut other = envelope();
        other["tool_calls"][0]["args"] = json!({"q": "golang"});
        let act = entries_from_envelope(&other, "b", HeaderMeta::default());
        let d = diff(&base, &act);
        assert_eq!(d.param_changed, vec!["hop 0: search"]);
        assert!(d.added.is_empty() && d.removed.is_empty());
        assert_eq!(d.size(), 1);
    }

    #[test]
    fn an_extra_call_in_actual_is_added() {
        let base = entries_from_envelope(&envelope(), "a", HeaderMeta::default());
        let mut other = envelope();
        other["tool_calls"]
            .as_array_mut()
            .unwrap()
            .push(json!({"name": "summarize", "server": "web", "args": {}, "hop_index": 2}));
        let act = entries_from_envelope(&other, "b", HeaderMeta::default());
        let d = diff(&base, &act);
        assert_eq!(d.added, vec!["hop 2: summarize"]);
        assert_eq!(d.size(), 1);
    }
}
