//! Compare a current drift capture against a baseline: schema drift plus
//! behavioral (canary) drift, folded into one classified report.

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

use super::state::DriftState;
use crate::diff::{diff_response_fingerprints, diff_tool_catalogs, CatalogChange, DiffError};

/// The combined drift verdict for one scheduled run.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DriftReport {
    /// Schema changes between the baseline and current `tools/list`, each
    /// classified breaking or not by the catalog diff.
    pub schema_changes: Vec<CatalogChange>,
    /// Behavioral changes: canary responses whose value-tolerant fingerprint
    /// drifted (a shape change, or an empty-result regression).
    pub behavioral_changes: Vec<CatalogChange>,
    /// True when any schema change is breaking (a renamed or removed param,
    /// a tightened type, a removed tool).
    pub schema_breaking: bool,
}

impl DriftReport {
    /// True when nothing drifted.
    pub fn is_clean(&self) -> bool {
        self.schema_changes.is_empty() && self.behavioral_changes.is_empty()
    }

    /// True when the run should fail: a breaking schema change or any
    /// behavioral drift. Non-breaking schema changes (a new optional field, a
    /// reworded description) are reported but do not fail the gate.
    pub fn should_fail(&self) -> bool {
        self.schema_breaking || !self.behavioral_changes.is_empty()
    }
}

/// Compare `current` against `baseline`, classifying schema and behavioral
/// drift. Pure: no I/O, no network.
pub fn compare(baseline: &DriftState, current: &DriftState) -> Result<DriftReport, DiffError> {
    let schema = diff_tool_catalogs(&baseline.schema, &current.schema)?;
    let behavioral = diff_response_fingerprints(
        &baseline.canary_fingerprints(),
        &current.canary_fingerprints(),
    );
    Ok(DriftReport {
        schema_breaking: schema.has_breaking,
        schema_changes: schema.changes,
        behavioral_changes: behavioral,
    })
}

/// Flatten the report into a JSON object: the counts and flags plus string
/// details per change. Built by hand rather than via `serde` because the
/// nested [`crate::diff::ChangeKind`]/`SchemaChange` enums are internally
/// tagged and do not serialize directly; the description string carries the
/// human-readable classification a reporter needs.
pub fn report_envelope(report: &DriftReport) -> Value {
    let details = |changes: &[CatalogChange]| -> Vec<Value> {
        changes
            .iter()
            .map(
                |c| json!({ "tool": c.tool, "breaking": c.breaking, "description": c.description }),
            )
            .collect()
    };
    json!({
        "schema_changes": report.schema_changes.len(),
        "schema_breaking_changes": report.schema_changes.iter().filter(|c| c.breaking).count(),
        "behavioral_changes": report.behavioral_changes.len(),
        "schema_breaking": report.schema_breaking,
        "clean": report.is_clean(),
        "should_fail": report.should_fail(),
        "schema_change_details": details(&report.schema_changes),
        "behavioral_change_details": details(&report.behavioral_changes),
    })
}

#[cfg(test)]
mod tests {
    use super::super::state::{CanarySample, DriftState};
    use super::*;

    fn state(schema: Value, canaries: Vec<CanarySample>) -> DriftState {
        DriftState {
            version: "0.1".into(),
            generated_at: String::new(),
            server: "demo".into(),
            schema,
            canaries,
        }
    }

    fn tool(name: &str, required: &[&str]) -> Value {
        json!({
            "name": name,
            "description": "A demo tool that does a thing for the caller.",
            "inputSchema": {
                "type": "object",
                "properties": required.iter().map(|r| (r.to_string(), json!({ "type": "string" }))).collect::<serde_json::Map<_, _>>(),
                "required": required,
            }
        })
    }

    fn canary(tool: &str, response: Value) -> CanarySample {
        CanarySample {
            tool: tool.into(),
            args: json!({}),
            response,
            error: false,
        }
    }

    #[test]
    fn an_unchanged_capture_is_clean() {
        let s = state(
            json!({ "tools": [tool("search", &["query"])] }),
            vec![canary(
                "search",
                json!({ "content": [{ "type": "text", "text": "a" }] }),
            )],
        );
        let report = compare(&s, &s).unwrap();
        assert!(report.is_clean());
        assert!(!report.should_fail());
    }

    #[test]
    fn a_renamed_param_is_a_breaking_schema_drift() {
        let baseline = state(json!({ "tools": [tool("search", &["query"])] }), vec![]);
        let current = state(json!({ "tools": [tool("search", &["q"])] }), vec![]);
        let report = compare(&baseline, &current).unwrap();
        assert!(report.schema_breaking, "{report:?}");
        assert!(report.should_fail());
    }

    #[test]
    fn an_empty_result_regression_is_a_behavioral_drift() {
        let schema = json!({ "tools": [tool("search", &["query"])] });
        let baseline = state(
            schema.clone(),
            vec![canary(
                "search",
                json!({ "content": [{ "type": "text", "text": "row one" }] }),
            )],
        );
        let current = state(schema, vec![canary("search", json!({ "content": [] }))]);
        let report = compare(&baseline, &current).unwrap();
        assert!(report.schema_changes.is_empty(), "schema unchanged");
        assert!(
            !report.behavioral_changes.is_empty(),
            "behavioral drift: {report:?}"
        );
        assert!(report.should_fail());
    }

    #[test]
    fn a_new_optional_tool_is_reported_but_not_failing() {
        let baseline = state(json!({ "tools": [tool("search", &["query"])] }), vec![]);
        let current = state(
            json!({ "tools": [tool("search", &["query"]), tool("browse", &["url"])] }),
            vec![],
        );
        let report = compare(&baseline, &current).unwrap();
        assert!(
            !report.schema_changes.is_empty(),
            "an added tool is reported"
        );
        assert!(!report.schema_breaking, "an added tool is not breaking");
        assert!(!report.should_fail());
    }
}
