//! Handler for `mcptest diff`.

use std::path::Path;

use anyhow::{anyhow, Result};
use mcptest_core::diff::{RegenKind, RegenPlan};
use mcptest_core::protocol::ProtocolVersion;

use crate::cli::args::{DiffArgs, DiffFormat};

/// Handle `mcptest diff old.json new.json`.
///
/// Loads two `tools/list` JSON snapshots, runs them through
/// [`mcptest_core::diff::diff_tool_catalogs`], and renders the report in
/// pretty / json / markdown form, followed by regenerated suite drafts
/// when `--suggest-regen` is set. Exit code 1 when `--fail-on-breaking`
/// is set and at least one change is breaking; exit code 1 when a file is
/// missing or malformed. Otherwise exit 0. `--suggest-regen` never alters
/// the exit code.
pub(crate) fn diff_command(args: DiffArgs) -> Result<i32> {
    let Some(old) = load_snapshot(&args.old) else {
        return Ok(1);
    };
    let Some(new) = load_snapshot(&args.new) else {
        return Ok(1);
    };
    let versions = match resolve_versions(&args) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("mcptest diff: {e}");
            return Ok(1);
        }
    };
    let report = match mcptest_core::diff::diff_server_snapshots(&old, &new, versions) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("mcptest diff: {e}");
            return Ok(1);
        }
    };
    let scorecard = if args.scorecard {
        Some(mcptest_core::diff::score_diff(&report))
    } else {
        None
    };
    let regen = if args.suggest_regen {
        let Some(plan) = build_regen(&args, &report, &new) else {
            return Ok(1);
        };
        Some(plan)
    } else {
        None
    };
    render_report(&args, &report, scorecard.as_ref(), regen.as_ref())?;
    if let Some(plan) = &regen {
        render_regen(plan, args.format);
    }
    if args.fail_on_breaking && report.has_breaking {
        Ok(1)
    } else {
        Ok(0)
    }
}

/// Turn the optional `--old-protocol-version` / `--new-protocol-version`
/// flags into the pair [`mcptest_core::diff::diff_server_snapshots`] gates
/// on. `Ok(None)` means neither was given (compare everything); `Ok(Some)`
/// means both parsed; an unparseable date stamp is an error so the user is
/// told which value is bad rather than silently losing the gating. Clap's
/// `requires` already forbids giving only one, but we treat a lone value
/// defensively as an error too.
fn resolve_versions(args: &DiffArgs) -> Result<Option<(ProtocolVersion, ProtocolVersion)>> {
    match (&args.old_protocol_version, &args.new_protocol_version) {
        (None, None) => Ok(None),
        (Some(old), Some(new)) => {
            let old = parse_version("old", old)?;
            let new = parse_version("new", new)?;
            Ok(Some((old, new)))
        }
        _ => Err(anyhow!(
            "--old-protocol-version and --new-protocol-version must be given together"
        )),
    }
}

/// Parse one `YYYY-MM-DD` protocol date stamp, naming the side in the error
/// so the user can tell which flag carried the unsupported value.
fn parse_version(side: &str, raw: &str) -> Result<ProtocolVersion> {
    ProtocolVersion::from_wire(raw)
        .ok_or_else(|| anyhow!("unsupported {side} protocol version `{raw}`"))
}

/// Route the report (plus optional scorecard and regen plan) to the
/// requested format. Split out of [`diff_command`] to keep both inside
/// the function-length budget.
fn render_report(
    args: &DiffArgs,
    report: &mcptest_core::diff::DiffReport,
    scorecard: Option<&mcptest_core::diff::SchemaEvolutionScorecard>,
    regen: Option<&RegenPlan>,
) -> Result<()> {
    match args.format {
        DiffFormat::Pretty => {
            render_diff_pretty(report);
            if let Some(sc) = scorecard {
                render_scorecard_pretty(sc);
            }
        }
        DiffFormat::Json => {
            // Wrap the bodies under one root when more than the bare
            // report is requested, so external tooling gets a single
            // document with stable keys.
            let json = match (scorecard, regen) {
                (None, None) => serde_json::to_string_pretty(report)?,
                _ => {
                    let mut root = serde_json::json!({ "report": report });
                    if let Some(sc) = scorecard {
                        root["scorecard"] = serde_json::to_value(sc)?;
                    }
                    if let Some(plan) = regen {
                        root["regen"] = plan.to_json();
                    }
                    serde_json::to_string_pretty(&root)?
                }
            };
            println!("{json}");
        }
        DiffFormat::Markdown => {
            render_diff_markdown(report);
            if let Some(sc) = scorecard {
                render_scorecard_markdown(sc);
            }
        }
    }
    Ok(())
}

/// Read and JSON-parse one snapshot, reporting failures on stderr.
/// `None` means the caller should exit 1; messages match the historical
/// inline error strings so scripted callers see no change.
fn load_snapshot(path: &Path) -> Option<serde_json::Value> {
    let body = match std::fs::read_to_string(path) {
        Ok(b) => b,
        Err(e) => {
            eprintln!("mcptest diff: cannot read {path:?}: {e}");
            return None;
        }
    };
    match serde_json::from_str(&body) {
        Ok(v) => Some(v),
        Err(e) => {
            eprintln!("mcptest diff: {path:?} is not valid JSON: {e}");
            None
        }
    }
}

/// Build the regen plan for `--suggest-regen`, parsing `--suite` when
/// given. `None` means the suite file could not be read or parsed (the
/// error is already printed) and the command should exit 1.
fn build_regen(
    args: &DiffArgs,
    report: &mcptest_core::diff::DiffReport,
    current: &serde_json::Value,
) -> Option<RegenPlan> {
    let suite = match &args.suite {
        Some(path) => Some(load_suite(path)?),
        None => {
            eprintln!(
                "mcptest diff: no --suite-file given; emitting drafts for added tools only \
                 (no referencing-test analysis)"
            );
            None
        }
    };
    let server = suite
        .as_ref()
        .and_then(first_server_key)
        .unwrap_or_else(|| "server".to_string());
    Some(mcptest_core::diff::build_regen_plan(
        report,
        current,
        suite.as_ref(),
        &server,
    ))
}

/// Read and YAML-parse the suite into a JSON value for the regen
/// builder. The suite is only read, never written.
fn load_suite(path: &Path) -> Option<serde_json::Value> {
    let body = match std::fs::read_to_string(path) {
        Ok(b) => b,
        Err(e) => {
            eprintln!("mcptest diff: cannot read suite {path:?}: {e}");
            return None;
        }
    };
    let yaml: serde_yaml::Value = match serde_yaml::from_str(&body) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("mcptest diff: suite {path:?} is not valid YAML: {e}");
            return None;
        }
    };
    match serde_json::to_value(yaml) {
        Ok(v) => Some(v),
        Err(e) => {
            eprintln!("mcptest diff: suite {path:?} does not convert to JSON: {e}");
            None
        }
    }
}

/// First key of the suite's `servers:` mapping, so drafts reference a
/// server the suite actually defines instead of a placeholder.
fn first_server_key(suite: &serde_json::Value) -> Option<String> {
    suite
        .get("servers")
        .and_then(serde_json::Value::as_object)
        .and_then(|m| m.keys().next())
        .cloned()
}

/// Print the regen plan: per-tool guidance on stderr, the combined draft
/// YAML on stdout after the report. JSON format already embeds the plan
/// in the report document, so only the stderr notes are added there.
fn render_regen(plan: &RegenPlan, format: DiffFormat) {
    if plan.is_empty() {
        eprintln!("mcptest diff: nothing to regenerate");
        return;
    }
    for item in &plan.items {
        eprintln!("{}", regen_note(item));
    }
    if format == DiffFormat::Json {
        return;
    }
    let combined = plan.combined_yaml();
    if !combined.is_empty() {
        println!();
        print!("{combined}");
    }
}

/// One stderr line per plan item: what drifted, which suite tests touch
/// it, and which draft (if any) replaces it.
fn regen_note(item: &mcptest_core::diff::RegenItem) -> String {
    let kind = match item.kind {
        RegenKind::Changed => "changed",
        RegenKind::Removed => "removed",
        RegenKind::Added => "added",
    };
    let mut line = format!("mcptest diff: tool `{}` {kind}", item.tool);
    if !item.referencing_tests.is_empty() {
        line.push_str(&format!(
            "; referencing tests: {}",
            item.referencing_tests.join(", ")
        ));
    }
    if let Some(note) = &item.note {
        line.push_str(&format!("; {note}"));
    }
    if let Some(name) = &item.draft_name {
        line.push_str(&format!("; draft: {name}"));
    }
    line
}

/// Append the  scorecard to pretty output. Kept separate from
/// the diff renderer so a caller that only wants the scorecard can
/// pipe `--format json --scorecard` and pick the object out.
fn render_scorecard_pretty(sc: &mcptest_core::diff::SchemaEvolutionScorecard) {
    println!();
    println!("Scorecard:");
    println!("  grade:             {:?}", sc.grade);
    println!("  total changes:     {}", sc.total_changes);
    println!("  breaking changes:  {}", sc.breaking_changes);
    if !sc.added_tools.is_empty() {
        println!("  added tools:       {}", sc.added_tools.join(", "));
    }
    if !sc.removed_tools.is_empty() {
        println!("  removed tools:     {}", sc.removed_tools.join(", "));
    }
    if !sc.regressed_tools.is_empty() {
        println!("  regressed tools:   {}", sc.regressed_tools.join(", "));
    }
}

/// Append the scorecard as its own Markdown section so a PR comment
/// gets a release grade alongside the per-change table.
fn render_scorecard_markdown(sc: &mcptest_core::diff::SchemaEvolutionScorecard) {
    println!();
    println!("## Scorecard");
    println!();
    println!("| Field | Value |");
    println!("| --- | --- |");
    println!("| grade | {:?} |", sc.grade);
    println!("| total changes | {} |", sc.total_changes);
    println!("| breaking changes | {} |", sc.breaking_changes);
    if !sc.added_tools.is_empty() {
        println!("| added tools | {} |", sc.added_tools.join(", "));
    }
    if !sc.removed_tools.is_empty() {
        println!("| removed tools | {} |", sc.removed_tools.join(", "));
    }
    if !sc.regressed_tools.is_empty() {
        println!("| regressed tools | {} |", sc.regressed_tools.join(", "));
    }
}

/// Render the diff report as grouped pretty text. Each tool gets its own
/// section; per-tool changes are bullet-listed with `(BREAKING)` or
/// `(safe)` markers so reviewers can scan quickly.
fn render_diff_pretty(report: &mcptest_core::diff::DiffReport) {
    use mcptest_core::diff::ChangeKind;
    let total = report.changes.len();
    let breaking = report.changes.iter().filter(|c| c.breaking).count();
    println!("{total} changes, {breaking} breaking");
    println!();
    let mut current_tool = "";
    for change in &report.changes {
        if change.tool != current_tool {
            println!("Tool: {}", change.tool);
            current_tool = &change.tool;
        }
        let marker = if change.breaking {
            "(BREAKING)"
        } else {
            "(safe)"
        };
        let kind = match &change.kind {
            ChangeKind::Added => "added".to_string(),
            ChangeKind::Removed => "removed".to_string(),
            ChangeKind::DescriptionChanged { .. } => "description changed".to_string(),
            ChangeKind::InputSchemaChanged { details } => {
                format!("input schema changed ({} edits)", details.len())
            }
            ChangeKind::OutputSchemaChanged { details } => {
                format!("output schema changed ({} edits)", details.len())
            }
            ChangeKind::AnnotationChanged { field, .. } => {
                format!("annotation {field} changed")
            }
            ChangeKind::TitleChanged { .. } => "title changed".to_string(),
            ChangeKind::InstructionsChanged { .. } => "instructions changed".to_string(),
            ChangeKind::ResponseShapeChanged { .. } => "response shape changed".to_string(),
        };
        println!("  - {kind} {marker}: {}", change.description);
    }
    render_notes_pretty(report);
}

/// Print the report's version-gated skip notes after the change list so a
/// reviewer can see which comparisons were intentionally not made (a field
/// one snapshot's protocol revision could not express). No-op when empty.
fn render_notes_pretty(report: &mcptest_core::diff::DiffReport) {
    if report.notes.is_empty() {
        return;
    }
    println!();
    println!("Not compared (version-gated):");
    for note in &report.notes {
        println!("  - {note}");
    }
}

/// Render the diff report as a GitHub-flavored Markdown table so a PR
/// comment can show it without a custom viewer.
fn render_diff_markdown(report: &mcptest_core::diff::DiffReport) {
    let total = report.changes.len();
    let breaking = report.changes.iter().filter(|c| c.breaking).count();
    println!("# Tool catalog diff");
    println!();
    println!("**{total} changes, {breaking} breaking**");
    println!();
    println!("| Tool | Change | Breaking | Description |");
    println!("| --- | --- | --- | --- |");
    for change in &report.changes {
        let kind = match &change.kind {
            mcptest_core::diff::ChangeKind::Added => "added",
            mcptest_core::diff::ChangeKind::Removed => "removed",
            mcptest_core::diff::ChangeKind::DescriptionChanged { .. } => "description",
            mcptest_core::diff::ChangeKind::InputSchemaChanged { .. } => "inputSchema",
            mcptest_core::diff::ChangeKind::OutputSchemaChanged { .. } => "outputSchema",
            mcptest_core::diff::ChangeKind::AnnotationChanged { .. } => "annotation",
            mcptest_core::diff::ChangeKind::TitleChanged { .. } => "title",
            mcptest_core::diff::ChangeKind::InstructionsChanged { .. } => "instructions",
            mcptest_core::diff::ChangeKind::ResponseShapeChanged { .. } => "response-shape",
        };
        let breaking_cell = if change.breaking { "yes" } else { "no" };
        println!(
            "| `{}` | {} | {} | {} |",
            change.tool, kind, breaking_cell, change.description
        );
    }
    render_notes_markdown(report);
}

/// Append a "Not compared (version-gated)" section to the Markdown report
/// listing each suppressed comparison, so a PR reviewer reading the table
/// knows a field was skipped on purpose rather than overlooked. No-op when
/// there are no notes.
fn render_notes_markdown(report: &mcptest_core::diff::DiffReport) {
    if report.notes.is_empty() {
        return;
    }
    println!();
    println!("## Not compared (version-gated)");
    println!();
    for note in &report.notes {
        println!("- {note}");
    }
}
