//! Behavioral fingerprint: a compact signature of what a model did across a
//! baseline, used as the model-compat diff primitive.
//!
//! The detailed [`diff_baselines`](crate::model_compat::diff_baselines) compares
//! every field of every exchange. A fingerprint sits above that: it summarizes the
//! behavior shape (which tools the model called, in what order, with what
//! argument shape, and how each exchange finished) into one comparable value.
//! Two baselines with the same fingerprint behaved the same way even if their
//! text content differs; a changed fingerprint is the headline "behavior moved"
//! signal a reviewer reads before the per-field diff.
//!
//! The digest deliberately ignores argument *values* and response text (those
//! are content variance, not behavior) and keys only on tool identity, call
//! order, argument *keys*, and finish reasons. So `get_weather(city="NYC")` and
//! `get_weather(city="LA")` share a fingerprint, while dropping the `city`
//! argument or calling a different tool changes it.

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use super::model::{BaselineFile, FinishReason};

/// A compact behavioral signature derived from a baseline.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BehavioralFingerprint {
    /// Every tool name the model called, in call order across all exchanges
    /// (exchanges taken in their stable id order). The shape of what the model
    /// chose to do.
    pub tool_sequence: Vec<String>,
    /// The finish reason of each exchange, in exchange-id order.
    pub finish_reasons: Vec<String>,
    /// Hex SHA-256 over the canonical behavior encoding (tool identity, order,
    /// argument keys, and finish reasons). Stable across runs with the same
    /// behavior; changes when the behavior shape changes.
    pub digest: String,
}

impl BehavioralFingerprint {
    /// Derive the fingerprint of a baseline.
    pub fn from_baseline(baseline: &BaselineFile) -> Self {
        // Exchanges are stored sorted by id, but sort defensively so the
        // fingerprint never depends on file order.
        let mut exchanges: Vec<&super::model::CapturedExchange> =
            baseline.assertions.iter().collect();
        exchanges.sort_by(|a, b| a.id.cmp(&b.id));

        let mut tool_sequence = Vec::new();
        let mut finish_reasons = Vec::new();
        let mut hasher = Sha256::new();
        for exchange in exchanges {
            hasher.update(b"exchange:");
            hasher.update(exchange.id.as_bytes());
            finish_reasons.push(finish_reason_str(exchange.finish_reason).to_string());
            hasher.update(b"|finish:");
            hasher.update(finish_reason_str(exchange.finish_reason).as_bytes());
            for call in &exchange.tool_calls {
                tool_sequence.push(call.name.clone());
                hasher.update(b"|tool:");
                hasher.update(call.name.as_bytes());
                hasher.update(b"|args:");
                for key in arg_keys(&call.arguments) {
                    hasher.update(key.as_bytes());
                    hasher.update(b",");
                }
            }
        }
        Self {
            tool_sequence,
            finish_reasons,
            digest: hex(&hasher.finalize()),
        }
    }

    /// Compare two fingerprints into a [`FingerprintDelta`].
    pub fn compare(&self, other: &BehavioralFingerprint) -> FingerprintDelta {
        FingerprintDelta {
            same: self.digest == other.digest,
            tool_sequence_changed: self.tool_sequence != other.tool_sequence,
            finish_reasons_changed: self.finish_reasons != other.finish_reasons,
            distance: self.distance_to(other),
            baseline_digest: self.digest.clone(),
            candidate_digest: other.digest.clone(),
        }
    }

    /// Numeric divergence in `[0.0, 1.0]`: `0.0` when the two behaviors are
    /// identical, larger the further the candidate's trajectory moved. The mean
    /// of the normalized edit distance over the tool-call sequence and over the
    /// per-exchange finish reasons; when those agree but the digest still
    /// differs (a pure argument-shape change), a small floor keeps it non-zero
    /// so a divergent behavior never reports distance `0.0`.
    fn distance_to(&self, other: &BehavioralFingerprint) -> f64 {
        let seq = normalized_edit_distance(&self.tool_sequence, &other.tool_sequence);
        let finish = normalized_edit_distance(&self.finish_reasons, &other.finish_reasons);
        let mut distance = (seq + finish) / 2.0;
        if distance == 0.0 && self.digest != other.digest {
            distance = ARG_SHAPE_DISTANCE;
        }
        distance
    }
}

/// Distance assigned when the behavior digest moved but the tool sequence and
/// finish reasons are identical: an argument-shape-only change. Small, so it
/// ranks below a tool or finish-reason shift, but non-zero so the change shows.
const ARG_SHAPE_DISTANCE: f64 = 0.1;

/// Normalized Levenshtein distance between two token sequences: edit distance
/// divided by the longer length, in `[0.0, 1.0]`. Two empty sequences are
/// identical (`0.0`).
fn normalized_edit_distance(a: &[String], b: &[String]) -> f64 {
    let longer = a.len().max(b.len());
    if longer == 0 {
        return 0.0;
    }
    edit_distance(a, b) as f64 / longer as f64
}

/// Levenshtein edit distance over two token slices (classic row-rolled DP).
fn edit_distance(a: &[String], b: &[String]) -> usize {
    let mut prev: Vec<usize> = (0..=b.len()).collect();
    let mut curr = vec![0usize; b.len() + 1];
    for (i, ai) in a.iter().enumerate() {
        curr[0] = i + 1;
        for (j, bj) in b.iter().enumerate() {
            let cost = if ai == bj { 0 } else { 1 };
            curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
        }
        std::mem::swap(&mut prev, &mut curr);
    }
    prev[b.len()]
}

/// The result of comparing two behavioral fingerprints. Carried on a
/// [`BaselineDiff`](crate::model_compat::BaselineDiff) as the top-line behavior
/// signal.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FingerprintDelta {
    /// True when the two behavior digests match: the model behaved the same.
    pub same: bool,
    /// True when the ordered tool-call sequence differs.
    pub tool_sequence_changed: bool,
    /// True when the per-exchange finish reasons differ.
    pub finish_reasons_changed: bool,
    /// Numeric divergence in `[0.0, 1.0]`: `0.0` identical, larger the further
    /// the behavior moved. Lets a reviewer rank how far a candidate drifted
    /// instead of reading only the same/changed booleans.
    #[serde(default)]
    pub distance: f64,
    /// The baseline (left) behavior digest.
    pub baseline_digest: String,
    /// The candidate (right) behavior digest.
    pub candidate_digest: String,
}

impl Default for FingerprintDelta {
    /// An empty diff (no baselines compared) is "same": two absent behaviors
    /// have not moved, so a default-constructed
    /// [`BaselineDiff`](crate::model_compat::BaselineDiff) reports no behavioral
    /// change.
    fn default() -> Self {
        Self {
            same: true,
            tool_sequence_changed: false,
            finish_reasons_changed: false,
            distance: 0.0,
            baseline_digest: String::new(),
            candidate_digest: String::new(),
        }
    }
}

impl FingerprintDelta {
    /// One-line behavioral-signature summary for the pretty report.
    pub fn render_line(&self) -> String {
        if self.same {
            return format!(
                "behavioral fingerprint: unchanged ({})",
                short(&self.baseline_digest)
            );
        }
        let mut what = Vec::new();
        if self.tool_sequence_changed {
            what.push("tool sequence");
        }
        if self.finish_reasons_changed {
            what.push("finish reasons");
        }
        if what.is_empty() {
            // Digests differ but neither summary axis did: an argument-shape
            // change. Name it so the line is never empty.
            what.push("argument shape");
        }
        format!(
            "behavioral fingerprint: changed ({} -> {}); {} differ; distance {:.2}",
            short(&self.baseline_digest),
            short(&self.candidate_digest),
            what.join(", "),
            self.distance
        )
    }
}

/// Stable lowercase label for a finish reason.
fn finish_reason_str(reason: FinishReason) -> &'static str {
    match reason {
        FinishReason::Stop => "stop",
        FinishReason::ToolUse => "tool_use",
        FinishReason::MaxTokens => "max_tokens",
        FinishReason::Other => "other",
    }
}

/// Sorted top-level object keys of an argument bag, or empty for a non-object.
fn arg_keys(arguments: &serde_json::Value) -> Vec<String> {
    match arguments.as_object() {
        Some(map) => {
            let mut keys: Vec<String> = map.keys().cloned().collect();
            keys.sort();
            keys
        }
        None => Vec::new(),
    }
}

/// Lowercase hex of a digest.
fn hex(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        s.push_str(&format!("{byte:02x}"));
    }
    s
}

/// First eight hex chars of a digest, or the whole string when shorter, for a
/// compact display that still distinguishes behaviors.
fn short(digest: &str) -> &str {
    if digest.len() >= 8 {
        &digest[..8]
    } else {
        digest
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model_compat::model::{
        default_invariants, default_variances, CapturedExchange, ConfigFingerprint, ContentBlock,
        ToolCall,
    };
    use serde_json::json;

    fn exchange(
        id: &str,
        tools: &[(&str, serde_json::Value)],
        finish: FinishReason,
    ) -> CapturedExchange {
        CapturedExchange {
            id: id.to_string(),
            content: vec![ContentBlock::Text {
                text: "ignored".into(),
            }],
            tool_calls: tools
                .iter()
                .map(|(name, args)| ToolCall {
                    name: (*name).to_string(),
                    arguments: args.clone(),
                    raw_arguments: None,
                })
                .collect(),
            finish_reason: finish,
            mcp_messages: vec![],
        }
    }

    fn baseline(exchanges: Vec<CapturedExchange>) -> BaselineFile {
        BaselineFile {
            schema: "https://mcptest.sh/schema/v1.json".into(),
            version: 1,
            model_id: "anthropic:claude".into(),
            model_version: "1".into(),
            mcptest_version: "1".into(),
            server_version: "1".into(),
            server_config_hash: "h".into(),
            config_fingerprint: ConfigFingerprint {
                invariants: default_invariants(),
                variances: default_variances(),
            },
            assertions: exchanges,
            invariants: vec![],
        }
    }

    #[test]
    fn identical_behavior_shares_a_fingerprint() {
        let a = baseline(vec![exchange(
            "f::t::0",
            &[("search", json!({ "q": "rust" }))],
            FinishReason::ToolUse,
        )]);
        let b = baseline(vec![exchange(
            "f::t::0",
            // Same tool and arg key, different value: still the same behavior.
            &[("search", json!({ "q": "go" }))],
            FinishReason::ToolUse,
        )]);
        let fa = BehavioralFingerprint::from_baseline(&a);
        let fb = BehavioralFingerprint::from_baseline(&b);
        assert_eq!(fa.digest, fb.digest);
        assert!(fa.compare(&fb).same);
    }

    #[test]
    fn a_different_tool_changes_the_fingerprint_and_sequence() {
        let a = baseline(vec![exchange(
            "f::t::0",
            &[("search", json!({}))],
            FinishReason::ToolUse,
        )]);
        let b = baseline(vec![exchange(
            "f::t::0",
            &[("delete", json!({}))],
            FinishReason::ToolUse,
        )]);
        let delta = BehavioralFingerprint::from_baseline(&a)
            .compare(&BehavioralFingerprint::from_baseline(&b));
        assert!(!delta.same);
        assert!(delta.tool_sequence_changed);
        assert!(delta.render_line().contains("tool sequence"));
    }

    #[test]
    fn a_dropped_argument_changes_the_digest_without_changing_the_sequence() {
        let a = baseline(vec![exchange(
            "f::t::0",
            &[("search", json!({ "q": "x", "limit": 5 }))],
            FinishReason::ToolUse,
        )]);
        let b = baseline(vec![exchange(
            "f::t::0",
            &[("search", json!({ "q": "x" }))],
            FinishReason::ToolUse,
        )]);
        let delta = BehavioralFingerprint::from_baseline(&a)
            .compare(&BehavioralFingerprint::from_baseline(&b));
        assert!(!delta.same);
        assert!(!delta.tool_sequence_changed, "same tool, same order");
        assert!(delta.render_line().contains("argument shape"));
    }

    #[test]
    fn a_changed_finish_reason_is_flagged() {
        let a = baseline(vec![exchange("f::t::0", &[], FinishReason::Stop)]);
        let b = baseline(vec![exchange("f::t::0", &[], FinishReason::MaxTokens)]);
        let delta = BehavioralFingerprint::from_baseline(&a)
            .compare(&BehavioralFingerprint::from_baseline(&b));
        assert!(!delta.same);
        assert!(delta.finish_reasons_changed);
        assert!(delta.render_line().contains("finish reasons"));
    }

    #[test]
    fn the_fingerprint_is_independent_of_exchange_file_order() {
        let one = exchange("f::a::0", &[("search", json!({}))], FinishReason::ToolUse);
        let two = exchange("f::b::0", &[("fetch", json!({}))], FinishReason::Stop);
        let forward = baseline(vec![one.clone(), two.clone()]);
        let reversed = baseline(vec![two, one]);
        assert_eq!(
            BehavioralFingerprint::from_baseline(&forward).digest,
            BehavioralFingerprint::from_baseline(&reversed).digest
        );
    }

    #[test]
    fn the_default_delta_reports_no_change() {
        let delta = FingerprintDelta::default();
        assert!(delta.same);
        assert_eq!(delta.distance, 0.0);
        assert!(delta.render_line().contains("unchanged"));
    }

    #[test]
    fn identical_behavior_has_zero_distance() {
        let a = baseline(vec![exchange(
            "f::t::0",
            &[("search", json!({ "q": "rust" }))],
            FinishReason::ToolUse,
        )]);
        let b = a.clone();
        let delta = BehavioralFingerprint::from_baseline(&a)
            .compare(&BehavioralFingerprint::from_baseline(&b));
        assert_eq!(delta.distance, 0.0);
    }

    #[test]
    fn a_fully_different_single_tool_is_distance_one() {
        let a = baseline(vec![exchange(
            "f::t::0",
            &[("search", json!({}))],
            FinishReason::ToolUse,
        )]);
        let b = baseline(vec![exchange(
            "f::t::0",
            &[("delete", json!({}))],
            FinishReason::ToolUse,
        )]);
        let delta = BehavioralFingerprint::from_baseline(&a)
            .compare(&BehavioralFingerprint::from_baseline(&b));
        // One-element tool sequences that fully differ -> seq distance 1.0;
        // finish reasons identical -> 0.0; mean 0.5.
        assert!(
            (delta.distance - 0.5).abs() < 1e-9,
            "distance {}",
            delta.distance
        );
        assert!(delta.render_line().contains("distance 0.50"));
    }

    #[test]
    fn an_argument_shape_only_change_uses_the_floor_distance() {
        let a = baseline(vec![exchange(
            "f::t::0",
            &[("search", json!({ "q": "x", "limit": 5 }))],
            FinishReason::ToolUse,
        )]);
        let b = baseline(vec![exchange(
            "f::t::0",
            &[("search", json!({ "q": "x" }))],
            FinishReason::ToolUse,
        )]);
        let delta = BehavioralFingerprint::from_baseline(&a)
            .compare(&BehavioralFingerprint::from_baseline(&b));
        assert!(!delta.same);
        // Same tool sequence + finish reasons, only arg keys differ -> floor.
        assert_eq!(delta.distance, ARG_SHAPE_DISTANCE);
    }

    #[test]
    fn a_partial_sequence_change_is_a_fraction() {
        // Three tools, one swapped: edit distance 1 over length 3 -> seq 1/3,
        // finish reasons identical -> 0; mean 1/6.
        let a = baseline(vec![exchange(
            "f::t::0",
            &[("a", json!({})), ("b", json!({})), ("c", json!({}))],
            FinishReason::ToolUse,
        )]);
        let b = baseline(vec![exchange(
            "f::t::0",
            &[("a", json!({})), ("x", json!({})), ("c", json!({}))],
            FinishReason::ToolUse,
        )]);
        let delta = BehavioralFingerprint::from_baseline(&a)
            .compare(&BehavioralFingerprint::from_baseline(&b));
        assert!(
            (delta.distance - (1.0 / 6.0)).abs() < 1e-9,
            "distance {}",
            delta.distance
        );
    }
}
