//! Subprocess bridge to @modelcontextprotocol/conformance.
//!
//! The upstream tool is a Node.js / npm package; we spawn it and map
//! its JSON output into our `CheckResult` shape so reporters and rubric
//! logic stay uniform. When `npx` is not available the bridge degrades
//! gracefully (returns NotInstalled) so mcptest still ships local-only
//! compliance.

use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::process::Command;

// WOR-1307 wired this bridge into `mcptest compliance run --official`
// (see the `mcptest` binary's compliance command). The bridge itself
// stays CLI-free so the data model can evolve without rippling flag
// changes through the binary.

/// Origin of a compliance check result.
///
/// Reporters group results by source so users can tell at a glance which
/// rules come from our local matchers versus the upstream conformance
/// suite.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckSource {
    /// Produced by mcptest's local matchers.
    Local,
    /// Produced by the @modelcontextprotocol/conformance suite.
    Official,
}

/// One compliance check after mapping into mcptest's uniform shape.
///
/// Both local matchers and the upstream subprocess emit results in this
/// form so rubric scoring and reporters stay source-agnostic.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CheckResult {
    /// Stable identifier the rubric scores against.
    pub rule_id: String,
    /// True when the underlying scenario passed.
    pub passed: bool,
    /// Where the result came from.
    pub source: CheckSource,
    /// Optional human-readable detail (failure reason, hint, etc.).
    pub message: Option<String>,
    /// Wall-clock duration the scenario took to run, in milliseconds.
    pub duration_ms: u64,
}

/// Raw scenario result decoded from the upstream JSON output.
///
/// We keep this distinct from `CheckResult` so the bridge can evolve
/// the wire schema independently from our internal rubric type.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OfficialScenarioResult {
    /// Upstream scenario identifier (mapped to a rule_id downstream).
    pub scenario_id: String,
    /// True when the upstream scenario passed.
    pub passed: bool,
    /// Optional failure message from the upstream tool.
    #[serde(default)]
    pub message: Option<String>,
    /// Scenario duration in milliseconds, if reported.
    #[serde(default)]
    pub duration_ms: u64,
}

/// Parsed report from a successful subprocess run.
///
/// Captures every scenario result plus the upstream tool version so
/// reporters can surface "ran against conformance vX.Y.Z" provenance.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OfficialReport {
    /// Per-scenario results returned by the upstream tool.
    pub scenarios: Vec<OfficialScenarioResult>,
    /// Version string reported by the upstream tool, or "unknown" when
    /// the field is absent.
    #[serde(default = "default_tool_version")]
    pub tool_version: String,
}

fn default_tool_version() -> String {
    "unknown".into()
}

/// Errors from spawning or parsing the upstream tool.
#[derive(Debug, thiserror::Error)]
pub enum OfficialBridgeError {
    /// The configured binary was not found on `PATH`.
    #[error("@modelcontextprotocol/conformance not installed; run `npm install -g @modelcontextprotocol/conformance` or use `npx`")]
    NotInstalled,
    /// The OS refused to spawn the subprocess for some reason other than
    /// "not found" (permission denied, fork failure, etc.).
    #[error("failed to spawn subprocess: {0}")]
    Spawn(std::io::Error),
    /// The subprocess ran but exited non-zero.
    #[error("subprocess exited with status {code}: {stderr}")]
    NonZeroExit {
        /// Exit code reported by the subprocess.
        code: i32,
        /// Captured stderr to help the user diagnose the failure.
        stderr: String,
    },
    /// stdout was not parseable as an `OfficialReport`.
    #[error("malformed JSON output: {0}")]
    MalformedOutput(String),
}

/// Bridge to the upstream conformance subprocess.
///
/// Defaults to invoking `npx --yes @modelcontextprotocol/conformance@latest`
/// so users do not need a global install. Override `command` for tests
/// or for users who pinned a local version. WOR-1307 drives this from
/// `mcptest compliance run --official`.
pub struct OfficialBridge {
    /// Argv used to invoke the upstream tool. Element 0 is the binary.
    pub command: Vec<String>,
    /// Working directory the subprocess runs in.
    pub cwd: PathBuf,
}

impl OfficialBridge {
    /// Build a bridge with the default `npx` command and a temp cwd.
    pub fn new() -> Self {
        Self {
            command: vec![
                "npx".into(),
                "--yes".into(),
                "@modelcontextprotocol/conformance@latest".into(),
            ],
            cwd: std::env::temp_dir(),
        }
    }

    /// Override the argv used to invoke the upstream tool.
    pub fn with_command(mut self, cmd: Vec<String>) -> Self {
        self.command = cmd;
        self
    }

    /// Override the subprocess working directory.
    pub fn with_cwd(mut self, dir: PathBuf) -> Self {
        self.cwd = dir;
        self
    }

    /// Probe whether the configured binary is callable.
    ///
    /// Returns true when `command[0] --version` exits successfully.
    /// Used so the runner can fall back to local-only checks without
    /// surfacing a spawn error to the user.
    pub fn check_available(&self) -> bool {
        if self.command.is_empty() {
            return false;
        }
        match Command::new(&self.command[0]).arg("--version").output() {
            Ok(out) => out.status.success(),
            Err(_) => false,
        }
    }

    /// Spawn the subprocess against `server_url` and parse its JSON
    /// report from stdout.
    ///
    /// Returns `NotInstalled` when the binary is missing so callers can
    /// degrade to local-only compliance.
    pub fn run(&self, server_url: &str) -> Result<OfficialReport, OfficialBridgeError> {
        if self.command.is_empty() {
            return Err(OfficialBridgeError::NotInstalled);
        }
        let mut cmd = Command::new(&self.command[0]);
        cmd.args(&self.command[1..]);
        cmd.arg("--reporter=json")
            .arg(format!("--server={}", server_url));
        cmd.current_dir(&self.cwd);

        let output = match cmd.output() {
            Ok(o) => o,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                return Err(OfficialBridgeError::NotInstalled);
            }
            Err(e) => return Err(OfficialBridgeError::Spawn(e)),
        };
        if !output.status.success() {
            return Err(OfficialBridgeError::NonZeroExit {
                code: output.status.code().unwrap_or(-1),
                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
            });
        }
        let stdout = String::from_utf8_lossy(&output.stdout);
        serde_json::from_str::<OfficialReport>(&stdout)
            .map_err(|e| OfficialBridgeError::MalformedOutput(e.to_string()))
    }
}

impl Default for OfficialBridge {
    fn default() -> Self {
        Self::new()
    }
}

/// Known upstream scenario_id to mcptest rule_id mappings.
///
/// New entries land here when the upstream suite ships a scenario that
/// overlaps with one of our existing rule families. Anything not listed
/// gets a synthesized `OFFICIAL-<scenario>` id so reports stay complete.
pub const SCENARIO_MAP: &[(&str, &str)] = &[
    ("initialize-handshake", "PROTO-001"),
    ("tools-list-ok", "TOOL-001"),
    ("tools-call-unknown-tool", "TOOL-002"),
    ("resources-list-ok", "RES-001"),
    ("prompts-list-ok", "PROMPT-001"),
];

/// Translate an upstream scenario_id to our rule_id.
///
/// Falls back to `OFFICIAL-<SANITIZED>` (alphanumerics uppercased, every
/// other char becomes `-`) when the upstream id is unknown so unmapped
/// scenarios still show up in reports.
pub fn map_to_rule_id(scenario_id: &str) -> String {
    for (k, v) in SCENARIO_MAP {
        if *k == scenario_id {
            return (*v).into();
        }
    }
    let sanitized: String = scenario_id
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() {
                c.to_ascii_uppercase()
            } else {
                '-'
            }
        })
        .collect();
    format!("OFFICIAL-{}", sanitized)
}

/// Convert an `OfficialReport` into the uniform `CheckResult` shape.
///
/// Reporters and rubric scoring consume `CheckResult` regardless of
/// source, so the bridge does the translation in one place.
pub fn to_check_results(report: &OfficialReport) -> Vec<CheckResult> {
    report
        .scenarios
        .iter()
        .map(|s| CheckResult {
            rule_id: map_to_rule_id(&s.scenario_id),
            passed: s.passed,
            source: CheckSource::Official,
            message: s.message.clone(),
            duration_ms: s.duration_ms,
        })
        .collect()
}

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

    #[test]
    fn check_available_returns_false_for_bogus_command() {
        let bridge =
            OfficialBridge::new().with_command(vec!["definitely-not-a-binary-xyz123".into()]);
        assert!(!bridge.check_available());
    }

    #[test]
    fn check_available_handles_empty_command() {
        let bridge = OfficialBridge::new().with_command(vec![]);
        assert!(!bridge.check_available());
    }

    #[test]
    fn map_to_rule_id_known_scenario() {
        assert_eq!(map_to_rule_id("initialize-handshake"), "PROTO-001");
        assert_eq!(map_to_rule_id("tools-list-ok"), "TOOL-001");
    }

    #[test]
    fn map_to_rule_id_unknown_scenario() {
        assert_eq!(map_to_rule_id("foo-bar"), "OFFICIAL-FOO-BAR");
    }

    #[test]
    fn map_to_rule_id_sanitizes_special_chars() {
        assert_eq!(
            map_to_rule_id("tools/call:test"),
            "OFFICIAL-TOOLS-CALL-TEST"
        );
    }

    #[test]
    fn to_check_results_preserves_pass_fail() {
        let report = OfficialReport {
            scenarios: vec![
                OfficialScenarioResult {
                    scenario_id: "initialize-handshake".into(),
                    passed: true,
                    message: None,
                    duration_ms: 12,
                },
                OfficialScenarioResult {
                    scenario_id: "tools-list-ok".into(),
                    passed: false,
                    message: Some("missing tools field".into()),
                    duration_ms: 7,
                },
                OfficialScenarioResult {
                    scenario_id: "unknown-thing".into(),
                    passed: true,
                    message: None,
                    duration_ms: 3,
                },
            ],
            tool_version: "1.2.3".into(),
        };
        let results = to_check_results(&report);
        assert_eq!(results.len(), 3);
        let passes = results.iter().filter(|r| r.passed).count();
        let fails = results.iter().filter(|r| !r.passed).count();
        assert_eq!(passes, 2);
        assert_eq!(fails, 1);
        assert_eq!(results[1].message.as_deref(), Some("missing tools field"));
        assert_eq!(results[2].rule_id, "OFFICIAL-UNKNOWN-THING");
    }

    #[test]
    fn to_check_results_sets_source_to_official() {
        let report = OfficialReport {
            scenarios: vec![
                OfficialScenarioResult {
                    scenario_id: "a".into(),
                    passed: true,
                    message: None,
                    duration_ms: 0,
                },
                OfficialScenarioResult {
                    scenario_id: "b".into(),
                    passed: false,
                    message: None,
                    duration_ms: 0,
                },
            ],
            tool_version: "unknown".into(),
        };
        let results = to_check_results(&report);
        assert!(results.iter().all(|r| r.source == CheckSource::Official));
    }

    #[test]
    fn run_returns_not_installed_when_binary_missing() {
        let bridge =
            OfficialBridge::new().with_command(vec!["definitely-not-a-binary-xyz123".into()]);
        let err = bridge.run("http://localhost:9999").unwrap_err();
        assert!(
            matches!(
                err,
                OfficialBridgeError::NotInstalled | OfficialBridgeError::Spawn(_)
            ),
            "expected NotInstalled or Spawn, got {:?}",
            err
        );
    }

    #[test]
    fn default_command_uses_npx() {
        let bridge = OfficialBridge::new();
        assert_eq!(bridge.command[0], "npx");
        assert!(bridge.command.iter().any(|a| a == "--yes"));
        assert!(bridge
            .command
            .iter()
            .any(|a| a.contains("@modelcontextprotocol/conformance")));
    }
}
