use serde_json::{json, Value};
use std::collections::{BTreeSet, HashMap};

use crate::core::engine::shell;
use crate::core::error::{Error, Result};
use crate::core::gate::{HomeboyGateKind, HomeboyGateResult, HomeboyGateStatus};
use crate::core::server::{self, SshClient};

use super::{Runner, RunnerKind, RunnerToolRegistry};

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RunnerCapabilityPreflight {
    pub command: String,
    pub required_tools: Vec<RunnerRequiredTool>,
    pub required_commands: Vec<String>,
    pub required_components: Vec<String>,
    pub required_env: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedLabRunnerCapability {
    pub command: &'static str,
    pub required_tools: Vec<RunnerRequiredTool>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LabRunnerCapabilityContract {
    pub command: &'static str,
    pub required_tools: Vec<RunnerRequiredTool>,
    pub requires_playwright: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LabRunnerGateMode {
    Automatic,
    Explicit,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LabRunnerGateDecision {
    Eligible,
    Missing {
        runner_id: String,
        command: &'static str,
        missing_tools: Vec<RunnerRequiredTool>,
        reason: String,
        remediation: Vec<String>,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum RunnerRequiredTool {
    Homeboy,
    Cargo,
    Git,
    Node,
    Npm,
    Pnpm,
    Php,
    Composer,
    Docker,
    Playwright,
}

pub fn prepare_lab_runner_capability(
    contract: LabRunnerCapabilityContract,
) -> PreparedLabRunnerCapability {
    let mut required_tools = vec![RunnerRequiredTool::Git];
    for tool in contract.required_tools {
        push_unique(&mut required_tools, tool);
    }

    if contract.requires_playwright {
        push_unique(&mut required_tools, RunnerRequiredTool::Playwright);
    }

    PreparedLabRunnerCapability {
        command: contract.command,
        required_tools,
    }
}

pub fn evaluate_lab_runner_capabilities_for_runner(
    runner: &Runner,
    plan: &PreparedLabRunnerCapability,
    mode: LabRunnerGateMode,
) -> Result<LabRunnerGateDecision> {
    let capabilities = RunnerCapabilitySnapshot::from_runner_probe_with_commands(runner, &[])?;
    Ok(evaluate_lab_runner_capabilities(
        &runner.id,
        plan,
        &capabilities,
        mode,
    ))
}

pub(crate) fn runner_capability_snapshot_for_preflight(
    runner: &Runner,
    preflight: &RunnerCapabilityPreflight,
) -> Result<RunnerCapabilitySnapshot> {
    RunnerCapabilitySnapshot::from_runner_probe_with_commands(runner, &preflight.required_commands)
}

pub(crate) fn validate_runner_capability_preflight(
    runner_id: &str,
    preflight: &RunnerCapabilityPreflight,
    capabilities: &RunnerCapabilitySnapshot,
    request_env: &HashMap<String, String>,
) -> Result<()> {
    let missing_tools = preflight
        .required_tools
        .iter()
        .copied()
        .filter(|tool| !capabilities.has_tool(*tool))
        .collect::<Vec<_>>();
    let missing_components = preflight
        .required_components
        .iter()
        .filter(|component| !capabilities.components.contains(component.as_str()))
        .cloned()
        .collect::<Vec<_>>();
    let missing_commands = preflight
        .required_commands
        .iter()
        .filter(|command| !command.trim().is_empty())
        .filter(|command| !capabilities.has_command(command))
        .cloned()
        .collect::<Vec<_>>();
    let missing_env = preflight
        .required_env
        .iter()
        .filter(|name| {
            request_env
                .get(name.as_str())
                .is_none_or(|value| value.is_empty())
        })
        .cloned()
        .collect::<Vec<_>>();

    if missing_tools.is_empty()
        && missing_components.is_empty()
        && missing_commands.is_empty()
        && missing_env.is_empty()
    {
        return Ok(());
    }

    let mut missing = Vec::new();
    if !missing_tools.is_empty() {
        missing.push(format!(
            "tools: {}",
            missing_tools
                .iter()
                .map(|tool| tool.id())
                .collect::<Vec<_>>()
                .join(", ")
        ));
    }
    if !missing_components.is_empty() {
        missing.push(format!("components: {}", missing_components.join(", ")));
    }
    if !missing_commands.is_empty() {
        missing.push(format!("commands: {}", missing_commands.join(", ")));
    }
    if !missing_env.is_empty() {
        missing.push(format!("environment: {}", missing_env.join(", ")));
    }

    let command = if preflight.command.is_empty() {
        "runner command"
    } else {
        preflight.command.as_str()
    };
    let mut remediation = missing_tools
        .iter()
        .map(|tool| tool.remediation().to_string())
        .collect::<Vec<_>>();
    remediation.extend(missing_components.iter().map(|component| {
        format!("Register component '{component}' on the runner capability profile or choose a runner with that component.")
    }));
    remediation.extend(
        missing_commands
            .iter()
            .map(|command| format!("Install '{command}' on the runner and ensure it is on PATH.")),
    );
    remediation.extend(missing_env.iter().map(|name| {
        format!("Set required environment variable '{name}' on the runner or pass it with the runner exec request.")
    }));
    remediation.push(
        "Remote execution was not started; fix the runner capability parity issue and retry."
            .to_string(),
    );

    Err(Error::validation_invalid_argument(
        "runner_capabilities",
        format!(
            "Runner '{runner_id}' is missing required capability parity for `{command}`: {}.",
            missing.join("; ")
        ),
        Some(runner_id.to_string()),
        Some(remediation),
    ))
}

#[derive(Debug, Clone, Default)]
pub(crate) struct RunnerCapabilitySnapshot {
    tools: BTreeSet<RunnerRequiredTool>,
    commands: BTreeSet<String>,
    components: BTreeSet<String>,
}

impl RunnerCapabilitySnapshot {
    fn from_runner_probe_with_commands(
        runner: &Runner,
        required_commands: &[String],
    ) -> Result<Self> {
        if runner.kind == RunnerKind::Local {
            let probe = Self::local_batch_tool_and_command_probe(runner, required_commands)?;
            return Ok(Self {
                tools: probe.tools,
                commands: probe.commands,
                components: configured_runner_components(runner),
            });
        }

        let client = Self::ssh_client_for_runner(runner)?;
        let probe = Self::batch_tool_and_command_probe(runner, &client, required_commands);

        Ok(Self {
            tools: probe.tools,
            commands: probe.commands,
            components: configured_runner_components(runner),
        })
    }

    fn has_tool(&self, tool: RunnerRequiredTool) -> bool {
        self.tools.contains(&tool)
    }

    fn has_command(&self, command: &str) -> bool {
        self.commands.contains(command.trim())
    }

    fn batch_tool_and_command_probe(
        runner: &Runner,
        client: &SshClient,
        required_commands: &[String],
    ) -> RunnerCapabilityProbeResult {
        let tool_commands = RunnerToolRegistry::required_tools()
            .iter()
            .copied()
            .map(|tool| {
                let command = if tool == RunnerRequiredTool::Homeboy {
                    runner.settings.homeboy_path.as_deref().unwrap_or("homeboy")
                } else {
                    RunnerToolRegistry::spec_for_required_tool(tool)
                        .map(|spec| spec.command)
                        .unwrap_or_else(|| tool.id())
                };
                (tool, command.to_string())
            })
            .collect::<Vec<_>>();
        let command_names = normalized_command_names(required_commands);
        let output = client.execute(&batch_probe_script(&tool_commands, &command_names));
        parse_batch_probe_output(&output.stdout, &tool_commands, &command_names)
    }

    fn local_batch_tool_and_command_probe(
        runner: &Runner,
        required_commands: &[String],
    ) -> Result<RunnerCapabilityProbeResult> {
        let tool_commands = RunnerToolRegistry::required_tools()
            .iter()
            .copied()
            .map(|tool| {
                let command = if tool == RunnerRequiredTool::Homeboy {
                    runner.settings.homeboy_path.as_deref().unwrap_or("homeboy")
                } else {
                    RunnerToolRegistry::spec_for_required_tool(tool)
                        .map(|spec| spec.command)
                        .unwrap_or_else(|| tool.id())
                };
                (tool, command.to_string())
            })
            .collect::<Vec<_>>();
        let command_names = normalized_command_names(required_commands);
        let script = batch_probe_script(&tool_commands, &command_names);
        let output = std::process::Command::new("sh")
            .arg("-c")
            .arg(script)
            .envs(runner.env.iter())
            .output()
            .map_err(|err| {
                Error::internal_io(
                    err.to_string(),
                    Some("probe local runner capabilities".to_string()),
                )
            })?;
        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
        Ok(parse_batch_probe_output(
            &stdout,
            &tool_commands,
            &command_names,
        ))
    }

    fn playwright_condition() -> &'static str {
        concat!(
            "command -v playwright >/dev/null 2>&1 && (",
            "for d in \"${PLAYWRIGHT_BROWSERS_PATH:-}\" \"$HOME/Library/Caches/ms-playwright\" \"$HOME/.cache/ms-playwright\"; do ",
            "[ -n \"$d\" ] && [ -d \"$d\" ] && find \"$d\" -mindepth 1 -maxdepth 1 2>/dev/null | grep -q . && exit 0; ",
            "done; exit 1)"
        )
    }

    fn ssh_client_for_runner(runner: &Runner) -> Result<SshClient> {
        let server_id = runner.server_id.as_deref().ok_or_else(|| {
            Error::validation_invalid_argument(
                "server_id",
                "SSH runners require server_id",
                Some(runner.id.clone()),
                None,
            )
        })?;
        let server = server::load(server_id)?;
        let mut client = SshClient::from_server(&server, server_id)?;
        client.env.extend(runner.env.clone());
        Ok(client)
    }
}

#[derive(Debug, Default)]
struct RunnerCapabilityProbeResult {
    tools: BTreeSet<RunnerRequiredTool>,
    commands: BTreeSet<String>,
}

fn batch_probe_script(
    tool_commands: &[(RunnerRequiredTool, String)],
    command_names: &[String],
) -> String {
    let mut lines = Vec::new();
    lines.push("set +e".to_string());
    for (index, (tool, command)) in tool_commands.iter().enumerate() {
        let condition = if *tool == RunnerRequiredTool::Playwright {
            RunnerCapabilitySnapshot::playwright_condition().to_string()
        } else {
            format!("command -v {} >/dev/null 2>&1", shell::quote_arg(command))
        };
        lines.push(format!(
            "if {condition}; then printf 'T\\t{index}\\t1\\n'; else printf 'T\\t{index}\\t0\\n'; fi"
        ));
    }
    for (index, command) in command_names.iter().enumerate() {
        lines.push(format!(
            "if command -v {} >/dev/null 2>&1; then printf 'C\\t{index}\\t1\\n'; else printf 'C\\t{index}\\t0\\n'; fi",
            shell::quote_arg(command)
        ));
    }
    lines.push("exit 0".to_string());
    lines.join("\n")
}

fn parse_batch_probe_output(
    stdout: &str,
    tool_commands: &[(RunnerRequiredTool, String)],
    command_names: &[String],
) -> RunnerCapabilityProbeResult {
    let mut result = RunnerCapabilityProbeResult::default();
    for line in stdout.lines() {
        let mut fields = line.split('\t');
        let Some(kind) = fields.next() else {
            continue;
        };
        let Some(index) = fields.next().and_then(|value| value.parse::<usize>().ok()) else {
            continue;
        };
        if fields.next() != Some("1") {
            continue;
        }
        match kind {
            "T" => {
                if let Some((tool, _)) = tool_commands.get(index) {
                    result.tools.insert(*tool);
                }
            }
            "C" => {
                if let Some(command) = command_names.get(index) {
                    result.commands.insert(command.clone());
                }
            }
            _ => {}
        }
    }
    result
}

fn evaluate_lab_runner_capabilities(
    runner_id: &str,
    plan: &PreparedLabRunnerCapability,
    capabilities: &RunnerCapabilitySnapshot,
    mode: LabRunnerGateMode,
) -> LabRunnerGateDecision {
    let missing_tools = plan
        .required_tools
        .iter()
        .copied()
        .filter(|tool| !capabilities.has_tool(*tool))
        .collect::<Vec<_>>();

    if missing_tools.is_empty() {
        return LabRunnerGateDecision::Eligible;
    }

    let missing = tool_list(&missing_tools);
    let reason = match mode {
        LabRunnerGateMode::Automatic => format!(
            "Local fallback: runner '{runner_id}' is missing required tool(s) for `{}`: {missing}.",
            plan.command
        ),
        LabRunnerGateMode::Explicit => format!(
            "Lab offload runner '{runner_id}' is missing required tool(s) for `{}`: {missing}.",
            plan.command
        ),
    };
    let mut remediation = missing_tools
        .iter()
        .map(|tool| tool.remediation().to_string())
        .collect::<Vec<_>>();
    match mode {
        LabRunnerGateMode::Automatic => remediation.push(
            "Homeboy will run locally instead of auto-offloading to this runner.".to_string(),
        ),
        LabRunnerGateMode::Explicit => remediation.push(
            "Install the missing tool(s) on the runner, choose a capable runner, or omit --runner to run locally.".to_string(),
        ),
    }

    LabRunnerGateDecision::Missing {
        runner_id: runner_id.to_string(),
        command: plan.command,
        missing_tools,
        reason,
        remediation,
    }
}

fn configured_runner_components(runner: &Runner) -> BTreeSet<String> {
    let Some(value) = runner.resources.get("components") else {
        return BTreeSet::new();
    };
    match value {
        Value::Array(items) => items
            .iter()
            .filter_map(|item| {
                item.as_str()
                    .map(str::to_string)
                    .or_else(|| item.get("id").and_then(Value::as_str).map(str::to_string))
            })
            .collect(),
        Value::Object(map) => map.keys().cloned().collect(),
        _ => BTreeSet::new(),
    }
}

fn push_unique<T: PartialEq>(items: &mut Vec<T>, item: T) {
    if !items.contains(&item) {
        items.push(item);
    }
}

fn normalized_command_names(commands: &[String]) -> Vec<String> {
    let mut names = commands
        .iter()
        .map(|command| command.trim())
        .filter(|command| !command.is_empty())
        .map(str::to_string)
        .collect::<Vec<_>>();
    names.sort();
    names.dedup();
    names
}

fn tool_list(tools: &[RunnerRequiredTool]) -> String {
    tools
        .iter()
        .map(|tool| tool.id())
        .collect::<Vec<_>>()
        .join(", ")
}

impl From<PreparedLabRunnerCapability> for RunnerCapabilityPreflight {
    fn from(plan: PreparedLabRunnerCapability) -> Self {
        Self {
            command: plan.command.to_string(),
            required_tools: plan.required_tools,
            required_commands: Vec::new(),
            required_components: Vec::new(),
            required_env: Vec::new(),
        }
    }
}

impl RunnerCapabilityPreflight {
    pub(crate) fn is_empty(&self) -> bool {
        self.required_tools.is_empty()
            && self.required_commands.is_empty()
            && self.required_components.is_empty()
            && self.required_env.is_empty()
    }
}

impl RunnerRequiredTool {
    pub fn id(self) -> &'static str {
        RunnerToolRegistry::spec_for_required_tool(self)
            .map(|spec| spec.capability_id)
            .unwrap_or("unknown")
    }

    pub(crate) fn remediation(self) -> &'static str {
        RunnerToolRegistry::spec_for_required_tool(self)
            .map(|spec| spec.capability_remediation)
            .unwrap_or("Install the missing tool on the runner and ensure it is on PATH.")
    }
}

impl From<LabRunnerGateDecision> for HomeboyGateResult {
    fn from(decision: LabRunnerGateDecision) -> Self {
        match decision {
            LabRunnerGateDecision::Eligible => HomeboyGateResult::new(
                "lab.capability_preflight",
                "lab.capability_preflight",
                HomeboyGateKind::Capability,
                HomeboyGateStatus::Passed,
            )
            .summary("lab runner capability preflight passed")
            .retryable(false)
            .provenance(json!({
                "source_type": "LabRunnerGateDecision",
            })),
            LabRunnerGateDecision::Missing {
                runner_id,
                command,
                missing_tools,
                reason,
                remediation,
            } => HomeboyGateResult::new(
                "lab.capability_preflight",
                "lab.capability_preflight",
                HomeboyGateKind::Capability,
                HomeboyGateStatus::Blocked,
            )
            .summary(reason)
            .evidence(json!({
                "runner_id": runner_id,
                "command": command,
                "missing_tools": missing_tools.iter().map(|tool| tool.id()).collect::<Vec<_>>(),
                "remediation": remediation,
            }))
            .retryable(false)
            .agent_feedback("Select a capable lab runner, install the missing runner tools, or use the configured fallback policy before retrying this command.")
            .provenance(json!({
                "source_type": "LabRunnerGateDecision",
            })),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::gate::HOMEBOY_GATE_RESULT_SCHEMA;
    use crate::core::runner::RunnerKind;
    use crate::core::server::{RunnerPolicy, RunnerSettings};

    fn ssh_runner() -> Runner {
        Runner {
            id: "lab".to_string(),
            kind: RunnerKind::Ssh,
            server_id: Some("srv".to_string()),
            workspace_root: Some("/srv/homeboy".to_string()),
            settings: RunnerSettings {
                daemon: true,
                ..Default::default()
            },
            env: Default::default(),
            secret_env: Default::default(),
            resources: Default::default(),
            policy: RunnerPolicy::default(),
        }
    }

    #[test]
    fn prepare_lab_runner_capability_detects_project_tool_needs() {
        let plan = prepare_lab_runner_capability(LabRunnerCapabilityContract {
            command: "lint",
            required_tools: vec![
                RunnerRequiredTool::Node,
                RunnerRequiredTool::Npm,
                RunnerRequiredTool::Node,
                RunnerRequiredTool::Pnpm,
                RunnerRequiredTool::Php,
                RunnerRequiredTool::Composer,
                RunnerRequiredTool::Docker,
            ],
            requires_playwright: false,
        });

        assert_eq!(plan.command, "lint");
        assert_eq!(
            plan.required_tools,
            vec![
                RunnerRequiredTool::Git,
                RunnerRequiredTool::Node,
                RunnerRequiredTool::Npm,
                RunnerRequiredTool::Pnpm,
                RunnerRequiredTool::Php,
                RunnerRequiredTool::Composer,
                RunnerRequiredTool::Docker,
            ]
        );
    }

    #[test]
    fn prepared_lab_runner_capability_converts_to_preflight() {
        let preflight: RunnerCapabilityPreflight =
            prepare_lab_runner_capability(LabRunnerCapabilityContract {
                command: "test",
                required_tools: vec![RunnerRequiredTool::Node, RunnerRequiredTool::Pnpm],
                requires_playwright: false,
            })
            .into();

        assert_eq!(preflight.command, "test");
        assert_eq!(
            preflight.required_tools,
            vec![
                RunnerRequiredTool::Git,
                RunnerRequiredTool::Node,
                RunnerRequiredTool::Pnpm,
            ]
        );
    }

    #[test]
    fn lab_runner_gate_allows_capable_runner() {
        let plan = PreparedLabRunnerCapability {
            command: "lint",
            required_tools: vec![
                RunnerRequiredTool::Git,
                RunnerRequiredTool::Node,
                RunnerRequiredTool::Pnpm,
            ],
        };
        let capabilities = RunnerCapabilitySnapshot {
            tools: [
                RunnerRequiredTool::Git,
                RunnerRequiredTool::Node,
                RunnerRequiredTool::Pnpm,
            ]
            .into_iter()
            .collect(),
            commands: BTreeSet::new(),
            components: BTreeSet::new(),
        };

        assert_eq!(
            evaluate_lab_runner_capabilities(
                "lab",
                &plan,
                &capabilities,
                LabRunnerGateMode::Explicit,
            ),
            LabRunnerGateDecision::Eligible
        );
    }

    #[test]
    fn lab_runner_gate_reports_missing_tool_for_explicit_runner() {
        let plan = PreparedLabRunnerCapability {
            command: "test",
            required_tools: vec![RunnerRequiredTool::Git, RunnerRequiredTool::Pnpm],
        };
        let capabilities = RunnerCapabilitySnapshot {
            tools: [RunnerRequiredTool::Git].into_iter().collect(),
            commands: BTreeSet::new(),
            components: BTreeSet::new(),
        };

        let decision = evaluate_lab_runner_capabilities(
            "lab",
            &plan,
            &capabilities,
            LabRunnerGateMode::Explicit,
        );

        let LabRunnerGateDecision::Missing {
            missing_tools,
            reason,
            remediation,
            ..
        } = decision
        else {
            panic!("expected missing tool decision");
        };
        assert_eq!(missing_tools, vec![RunnerRequiredTool::Pnpm]);
        assert!(reason.contains("Lab offload runner 'lab'"));
        assert!(reason.contains(concat!("p", "n", "pm")));
        assert!(remediation
            .iter()
            .any(|item| item.contains("omit --runner")));
    }

    #[test]
    fn lab_runner_gate_decision_normalizes_to_homeboy_gate_result() {
        let result: HomeboyGateResult = LabRunnerGateDecision::Missing {
            runner_id: "lab".to_string(),
            command: "test",
            missing_tools: vec![RunnerRequiredTool::Pnpm],
            reason: concat!("lab runner is missing p", "n", "pm").to_string(),
            remediation: vec![concat!("install p", "n", "pm").to_string()],
        }
        .into();

        assert_eq!(result.schema, HOMEBOY_GATE_RESULT_SCHEMA);
        assert_eq!(result.id, "lab.capability_preflight");
        assert_eq!(result.kind, HomeboyGateKind::Capability);
        assert_eq!(result.status, HomeboyGateStatus::Blocked);
        assert_eq!(result.retryable, Some(false));
        assert_eq!(result.evidence["runner_id"], "lab");
        assert_eq!(result.evidence["missing_tools"][0], concat!("p", "n", "pm"));
        assert!(result.agent_feedback.contains("capable lab runner"));
    }

    #[test]
    fn eligible_lab_runner_gate_decision_normalizes_to_passed_gate_result() {
        let result: HomeboyGateResult = LabRunnerGateDecision::Eligible.into();

        assert_eq!(result.schema, HOMEBOY_GATE_RESULT_SCHEMA);
        assert_eq!(result.id, "lab.capability_preflight");
        assert_eq!(result.kind, HomeboyGateKind::Capability);
        assert_eq!(result.status, HomeboyGateStatus::Passed);
        assert_eq!(result.retryable, Some(false));
        assert_eq!(result.provenance["source_type"], "LabRunnerGateDecision");
        assert!(result.summary.contains("preflight passed"));
    }

    #[test]
    fn lab_runner_gate_reports_local_fallback_for_auto_runner() {
        let plan = PreparedLabRunnerCapability {
            command: "trace",
            required_tools: vec![RunnerRequiredTool::Git, RunnerRequiredTool::Playwright],
        };
        let capabilities = RunnerCapabilitySnapshot {
            tools: [RunnerRequiredTool::Git].into_iter().collect(),
            commands: BTreeSet::new(),
            components: BTreeSet::new(),
        };

        let decision = evaluate_lab_runner_capabilities(
            "lab",
            &plan,
            &capabilities,
            LabRunnerGateMode::Automatic,
        );

        let LabRunnerGateDecision::Missing {
            reason,
            remediation,
            ..
        } = decision
        else {
            panic!("expected local fallback decision");
        };
        assert!(reason.contains("Local fallback"));
        assert!(reason.contains("playwright+browsers"));
        assert!(remediation.iter().any(|item| item.contains("run locally")));
    }

    #[test]
    fn runner_capability_preflight_reports_missing_tools_components_and_env() {
        let preflight = RunnerCapabilityPreflight {
            command: "test".to_string(),
            required_tools: vec![RunnerRequiredTool::Git, RunnerRequiredTool::Pnpm],
            required_commands: vec!["zip".to_string()],
            required_components: vec!["fixture-a".to_string(), "fixture-b".to_string()],
            required_env: vec!["HOMEBOY_TOKEN".to_string()],
        };
        let capabilities = RunnerCapabilitySnapshot {
            tools: [RunnerRequiredTool::Git].into_iter().collect(),
            commands: BTreeSet::new(),
            components: ["fixture-a".to_string()].into_iter().collect(),
        };

        let err =
            validate_runner_capability_preflight("lab", &preflight, &capabilities, &HashMap::new())
                .expect_err("missing capability parity");

        assert_eq!(err.code.as_str(), "validation.invalid_argument");
        assert!(err.message.contains(concat!("tools: p", "n", "pm")));
        assert!(err.message.contains("commands: zip"));
        assert!(err.message.contains("components: fixture-b"));
        assert!(err.message.contains("environment: HOMEBOY_TOKEN"));
        let tried = err
            .details
            .get("tried")
            .and_then(Value::as_array)
            .expect("remediation details");
        assert!(tried.iter().any(|hint| hint
            .as_str()
            .is_some_and(|hint| hint.contains("Remote execution was not started"))));
    }

    #[test]
    fn runner_capability_preflight_accepts_matching_requirements() {
        let preflight = RunnerCapabilityPreflight {
            command: "lint".to_string(),
            required_tools: vec![RunnerRequiredTool::Git, RunnerRequiredTool::Node],
            required_commands: vec!["zip".to_string()],
            required_components: vec!["fixture-a".to_string()],
            required_env: vec!["HOMEBOY_TOKEN".to_string()],
        };
        let capabilities = RunnerCapabilitySnapshot {
            tools: [RunnerRequiredTool::Git, RunnerRequiredTool::Node]
                .into_iter()
                .collect(),
            commands: ["zip".to_string()].into_iter().collect(),
            components: ["fixture-a".to_string()].into_iter().collect(),
        };
        let mut env = HashMap::new();
        env.insert("HOMEBOY_TOKEN".to_string(), "present".to_string());

        validate_runner_capability_preflight("lab", &preflight, &capabilities, &env)
            .expect("capability parity passes");
    }

    #[test]
    fn every_required_runner_tool_has_doctor_metadata() {
        for tool in RunnerToolRegistry::required_tools() {
            let spec = RunnerToolRegistry::spec_for_required_tool(*tool)
                .unwrap_or_else(|| panic!("missing doctor metadata for {tool:?}"));

            assert_eq!(spec.tool, Some(*tool));
            assert!(!spec.id.is_empty());
            assert!(!spec.capability_id.is_empty());
            assert!(!spec.check_id.is_empty());
            assert!(!spec.command.is_empty());
            assert!(!spec.remediation.is_empty());
            assert!(!spec.capability_remediation.is_empty());
        }
    }

    #[test]
    fn configured_runner_components_accept_arrays_and_maps() {
        let mut runner = ssh_runner();
        runner.resources.insert(
            "components".to_string(),
            serde_json::json!(["fixture-a", { "id": "fixture-b" }]),
        );
        assert_eq!(
            configured_runner_components(&runner),
            ["fixture-a".to_string(), "fixture-b".to_string()]
                .into_iter()
                .collect()
        );

        runner.resources.insert(
            "components".to_string(),
            serde_json::json!({ "fixture-c": true, "fixture-d": true }),
        );
        assert_eq!(
            configured_runner_components(&runner),
            ["fixture-c".to_string(), "fixture-d".to_string()]
                .into_iter()
                .collect()
        );
    }
}
