//! Shared probe helpers used by `probe-host` and `probe-docker`.

use crate::manifest::{PackageEntry, RunnerManifest, ToolEntry};
use crate::model::{
    CapabilityProbeOutcome, CheckStatus, Compatibility, PackageProbeOutcome, ToolProbeOutcome,
};
use std::process::Output;

pub const EXCERPT_LIMIT: usize = 4 * 1024;

#[derive(Debug, Clone)]
pub struct ProbeRequest<'a> {
    pub tools: &'a [String],
    pub packages: &'a [String],
    pub capabilities: &'a [String],
}

#[derive(Debug, Clone, Default)]
pub struct CommandOutcome {
    pub command: String,
    pub exit_code: Option<i32>,
    pub stdout: String,
    pub stderr: String,
    pub success: bool,
    pub spawn_error: Option<String>,
}

impl CommandOutcome {
    pub fn from_output(command: String, output: Output) -> Self {
        let stdout = excerpt(&String::from_utf8_lossy(&output.stdout));
        let stderr = excerpt(&String::from_utf8_lossy(&output.stderr));
        let exit_code = output.status.code();
        let success = output.status.success();
        Self {
            command,
            exit_code,
            stdout,
            stderr,
            success,
            spawn_error: None,
        }
    }

    pub fn from_spawn_error(command: String, err: std::io::Error) -> Self {
        Self {
            command,
            exit_code: None,
            stdout: String::new(),
            stderr: String::new(),
            success: false,
            spawn_error: Some(err.to_string()),
        }
    }
}

pub fn excerpt(text: &str) -> String {
    if text.len() <= EXCERPT_LIMIT {
        return text.to_string();
    }
    let mut out = text[..EXCERPT_LIMIT].to_string();
    out.push_str("…<truncated>");
    out
}

pub fn extract_version(stdout: &str) -> Option<String> {
    // Capture the first dotted version token, optional leading "v".
    let mut chars = stdout.chars().peekable();
    let mut iter = stdout.char_indices().peekable();
    while let Some(&(i, c)) = iter.peek() {
        if c == 'v' || c == 'V' {
            // try consuming 'v' then check next char is digit
            let rest = &stdout[i + 1..];
            if rest.chars().next().is_some_and(|d| d.is_ascii_digit()) {
                return capture_version_at(rest);
            }
        }
        if c.is_ascii_digit() {
            return capture_version_at(&stdout[i..]);
        }
        iter.next();
        chars.next();
    }
    None
}

fn capture_version_at(rest: &str) -> Option<String> {
    let mut end = 0;
    for (i, c) in rest.char_indices() {
        if c.is_ascii_digit() || c == '.' {
            end = i + c.len_utf8();
        } else {
            break;
        }
    }
    if end == 0 {
        return None;
    }
    let v = &rest[..end];
    if !v.contains('.') {
        return None;
    }
    Some(v.trim_end_matches('.').to_string())
}

pub fn classify_versions(
    expected: Option<&str>,
    observed: Option<&str>,
) -> (Compatibility, CheckStatus, Option<String>) {
    match (expected, observed) {
        (Some(exp), Some(obs)) if exp == obs => (Compatibility::Exact, CheckStatus::Pass, None),
        (Some(exp), Some(obs)) if obs.starts_with(exp) || exp.starts_with(obs) => (
            Compatibility::Simulated,
            CheckStatus::Warn,
            Some(format!(
                "expected '{exp}', observed '{obs}' (partial match)"
            )),
        ),
        (Some(exp), Some(obs)) => (
            Compatibility::Simulated,
            CheckStatus::Warn,
            Some(format!("expected '{exp}', observed '{obs}'")),
        ),
        (Some(exp), None) => (
            Compatibility::Unsupported,
            CheckStatus::Fail,
            Some(format!(
                "expected '{exp}', could not parse version from probe"
            )),
        ),
        (None, Some(obs)) => (
            Compatibility::Simulated,
            CheckStatus::Warn,
            Some(format!(
                "manifest does not declare an expected version; observed '{obs}'"
            )),
        ),
        (None, None) => (
            Compatibility::Unsupported,
            CheckStatus::Fail,
            Some("no expected version in manifest and could not parse one from probe".to_string()),
        ),
    }
}

pub fn build_tool_command(tool: &ToolEntry) -> Vec<String> {
    let binary = tool
        .path
        .as_deref()
        .filter(|p| !p.is_empty())
        .map(str::to_owned)
        .unwrap_or_else(|| tool.name.clone());
    let flag = tool
        .version_flag
        .clone()
        .unwrap_or_else(|| "--version".to_string());
    vec![binary, flag]
}

pub fn missing_tool_outcome(name: &str, reason: &str) -> ToolProbeOutcome {
    ToolProbeOutcome {
        name: name.to_string(),
        expected_version: None,
        observed_version: None,
        classification: Compatibility::Unsupported,
        status: CheckStatus::Fail,
        reason: Some(reason.to_string()),
        command: None,
        exit_code: None,
        stdout_excerpt: None,
        stderr_excerpt: None,
    }
}

pub fn tool_outcome_from(
    name: &str,
    expected: Option<&str>,
    outcome: CommandOutcome,
) -> ToolProbeOutcome {
    if let Some(spawn_err) = outcome.spawn_error {
        return ToolProbeOutcome {
            name: name.to_string(),
            expected_version: expected.map(str::to_owned),
            observed_version: None,
            classification: Compatibility::Unsupported,
            status: CheckStatus::Fail,
            reason: Some(format!("probe.tool_spawn_failed: {spawn_err}")),
            command: Some(outcome.command),
            exit_code: None,
            stdout_excerpt: None,
            stderr_excerpt: None,
        };
    }
    let observed = if outcome.success {
        extract_version(&outcome.stdout).or_else(|| extract_version(&outcome.stderr))
    } else {
        None
    };
    let (classification, status, mut reason) = classify_versions(expected, observed.as_deref());
    if !outcome.success && reason.is_none() {
        reason = Some(format!(
            "probe.tool_exit_nonzero: exit={}",
            outcome.exit_code.unwrap_or(-1)
        ));
    }
    ToolProbeOutcome {
        name: name.to_string(),
        expected_version: expected.map(str::to_owned),
        observed_version: observed,
        classification,
        status: if outcome.success {
            status
        } else {
            CheckStatus::Fail
        },
        reason,
        command: Some(outcome.command),
        exit_code: outcome.exit_code,
        stdout_excerpt: if outcome.stdout.is_empty() {
            None
        } else {
            Some(outcome.stdout)
        },
        stderr_excerpt: if outcome.stderr.is_empty() {
            None
        } else {
            Some(outcome.stderr)
        },
    }
}

#[derive(Debug, Clone)]
pub struct PackageProbeSpec {
    pub manager: String,
    pub argv: Vec<String>,
    pub parse: PackageVersionParser,
}

#[derive(Debug, Clone, Copy)]
pub enum PackageVersionParser {
    DpkgStatus,
    RpmInfo,
    BrewInfo,
    Generic,
}

pub fn package_probe_for(pkg: &PackageEntry) -> Option<PackageProbeSpec> {
    match pkg.manager.as_str() {
        "apt" | "dpkg" => Some(PackageProbeSpec {
            manager: pkg.manager.clone(),
            argv: vec![
                "dpkg-query".to_string(),
                "-W".to_string(),
                "-f=${Version}\n".to_string(),
                pkg.name.clone(),
            ],
            parse: PackageVersionParser::DpkgStatus,
        }),
        "dnf" | "yum" | "rpm" => Some(PackageProbeSpec {
            manager: pkg.manager.clone(),
            argv: vec![
                "rpm".to_string(),
                "-q".to_string(),
                "--queryformat".to_string(),
                "%{VERSION}-%{RELEASE}\n".to_string(),
                pkg.name.clone(),
            ],
            parse: PackageVersionParser::RpmInfo,
        }),
        "brew" => Some(PackageProbeSpec {
            manager: pkg.manager.clone(),
            argv: vec![
                "brew".to_string(),
                "list".to_string(),
                "--versions".to_string(),
                pkg.name.clone(),
            ],
            parse: PackageVersionParser::BrewInfo,
        }),
        "apk" => Some(PackageProbeSpec {
            manager: pkg.manager.clone(),
            argv: vec![
                "apk".to_string(),
                "info".to_string(),
                "-vv".to_string(),
                pkg.name.clone(),
            ],
            parse: PackageVersionParser::Generic,
        }),
        "pacman" => Some(PackageProbeSpec {
            manager: pkg.manager.clone(),
            argv: vec!["pacman".to_string(), "-Q".to_string(), pkg.name.clone()],
            parse: PackageVersionParser::Generic,
        }),
        _ => None,
    }
}

pub fn parse_package_version(
    parser: PackageVersionParser,
    stdout: &str,
    pkg_name: &str,
) -> Option<String> {
    let trimmed = stdout.trim();
    if trimmed.is_empty() {
        return None;
    }
    match parser {
        PackageVersionParser::DpkgStatus => Some(trimmed.to_string()),
        PackageVersionParser::RpmInfo => Some(trimmed.to_string()),
        PackageVersionParser::BrewInfo => {
            let first_line = trimmed.lines().next().unwrap_or(trimmed);
            let parts: Vec<&str> = first_line.split_whitespace().collect();
            if parts.len() >= 2 && parts[0] == pkg_name {
                Some(parts[1].to_string())
            } else {
                None
            }
        }
        PackageVersionParser::Generic => extract_version(trimmed),
    }
}

pub fn package_outcome_from(
    pkg: &PackageEntry,
    spec: &PackageProbeSpec,
    outcome: CommandOutcome,
) -> PackageProbeOutcome {
    if let Some(spawn_err) = outcome.spawn_error {
        return PackageProbeOutcome {
            name: pkg.name.clone(),
            manager: spec.manager.clone(),
            expected_version: Some(pkg.version.clone()),
            observed_version: None,
            classification: Compatibility::Unsupported,
            status: CheckStatus::Fail,
            reason: Some(format!("probe.package_spawn_failed: {spawn_err}")),
            command: Some(outcome.command),
            exit_code: None,
            stdout_excerpt: None,
            stderr_excerpt: None,
        };
    }
    let observed = if outcome.success {
        parse_package_version(spec.parse, &outcome.stdout, &pkg.name)
    } else {
        None
    };
    let (classification, status, mut reason) =
        classify_versions(Some(pkg.version.as_str()), observed.as_deref());
    if !outcome.success && reason.is_none() {
        reason = Some(format!(
            "probe.package_exit_nonzero: exit={}",
            outcome.exit_code.unwrap_or(-1)
        ));
    }
    PackageProbeOutcome {
        name: pkg.name.clone(),
        manager: spec.manager.clone(),
        expected_version: Some(pkg.version.clone()),
        observed_version: observed,
        classification,
        status: if outcome.success {
            status
        } else {
            CheckStatus::Fail
        },
        reason,
        command: Some(outcome.command),
        exit_code: outcome.exit_code,
        stdout_excerpt: if outcome.stdout.is_empty() {
            None
        } else {
            Some(outcome.stdout)
        },
        stderr_excerpt: if outcome.stderr.is_empty() {
            None
        } else {
            Some(outcome.stderr)
        },
    }
}

pub fn missing_package_outcome(name: &str, reason: &str) -> PackageProbeOutcome {
    PackageProbeOutcome {
        name: name.to_string(),
        manager: "unknown".to_string(),
        expected_version: None,
        observed_version: None,
        classification: Compatibility::Unsupported,
        status: CheckStatus::Fail,
        reason: Some(reason.to_string()),
        command: None,
        exit_code: None,
        stdout_excerpt: None,
        stderr_excerpt: None,
    }
}

pub fn capability_probe_argv(name: &str) -> Option<Vec<String>> {
    match name {
        "docker" => Some(vec!["docker".to_string(), "--version".to_string()]),
        "docker-socket" => Some(vec!["docker".to_string(), "info".to_string()]),
        "podman" => Some(vec!["podman".to_string(), "--version".to_string()]),
        "buildx" => Some(vec![
            "docker".to_string(),
            "buildx".to_string(),
            "version".to_string(),
        ]),
        _ => None,
    }
}

pub fn capability_outcome_skipped(name: &str, reason: &str) -> CapabilityProbeOutcome {
    CapabilityProbeOutcome {
        name: name.to_string(),
        classification: Compatibility::Simulated,
        status: CheckStatus::Skip,
        reason: Some(reason.to_string()),
        command: None,
        exit_code: None,
        stdout_excerpt: None,
        stderr_excerpt: None,
    }
}

pub fn capability_outcome_from(name: &str, outcome: CommandOutcome) -> CapabilityProbeOutcome {
    let success = outcome.success;
    let (status, classification, reason) = if let Some(err) = &outcome.spawn_error {
        (
            CheckStatus::Fail,
            Compatibility::Unsupported,
            Some(format!("capability.probe_spawn_failed: {err}")),
        )
    } else if success {
        (CheckStatus::Pass, Compatibility::Exact, None)
    } else {
        (
            CheckStatus::Fail,
            Compatibility::Unsupported,
            Some(format!(
                "capability.probe_exit_nonzero: exit={}",
                outcome.exit_code.unwrap_or(-1)
            )),
        )
    };
    CapabilityProbeOutcome {
        name: name.to_string(),
        classification,
        status,
        reason,
        command: Some(outcome.command),
        exit_code: outcome.exit_code,
        stdout_excerpt: if outcome.stdout.is_empty() {
            None
        } else {
            Some(outcome.stdout)
        },
        stderr_excerpt: if outcome.stderr.is_empty() {
            None
        } else {
            Some(outcome.stderr)
        },
    }
}

pub fn requested_tools<'a>(
    request: &'a [String],
    manifest: &'a RunnerManifest,
) -> Vec<(&'a str, Option<&'a ToolEntry>)> {
    request
        .iter()
        .map(|name| (name.as_str(), manifest.find_tool(name)))
        .collect()
}

pub fn requested_packages<'a>(
    request: &'a [String],
    manifest: &'a RunnerManifest,
) -> Vec<(&'a str, Option<&'a PackageEntry>)> {
    request
        .iter()
        .map(|name| (name.as_str(), manifest.find_package(name)))
        .collect()
}

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

    #[test]
    fn extract_version_handles_v_prefix() {
        assert_eq!(extract_version("v22.11.0"), Some("22.11.0".to_string()));
    }

    #[test]
    fn extract_version_handles_node_format() {
        assert_eq!(extract_version("v18.19.1\n"), Some("18.19.1".to_string()));
    }

    #[test]
    fn extract_version_handles_cargo_format() {
        assert_eq!(
            extract_version("cargo 1.85.0 (abc 2026-04-01)\n"),
            Some("1.85.0".to_string())
        );
    }

    #[test]
    fn extract_version_handles_docker_format() {
        assert_eq!(
            extract_version("Docker version 27.3.1, build deadbeef\n"),
            Some("27.3.1".to_string())
        );
    }

    #[test]
    fn extract_version_handles_no_version() {
        assert_eq!(extract_version("hello world"), None);
        assert_eq!(extract_version("no version here at all"), None);
    }

    #[test]
    fn classify_versions_exact_match() {
        let (compat, status, reason) = classify_versions(Some("22.11.0"), Some("22.11.0"));
        assert_eq!(compat, Compatibility::Exact);
        assert_eq!(status, CheckStatus::Pass);
        assert!(reason.is_none());
    }

    #[test]
    fn classify_versions_prefix_match() {
        let (compat, status, reason) = classify_versions(Some("22"), Some("22.11.0"));
        assert_eq!(compat, Compatibility::Simulated);
        assert_eq!(status, CheckStatus::Warn);
        assert!(reason.is_some());
    }

    #[test]
    fn classify_versions_full_mismatch() {
        let (compat, status, _) = classify_versions(Some("22.11.0"), Some("18.19.1"));
        assert_eq!(compat, Compatibility::Simulated);
        assert_eq!(status, CheckStatus::Warn);
    }

    #[test]
    fn classify_versions_missing_observed() {
        let (compat, status, _) = classify_versions(Some("22.11.0"), None);
        assert_eq!(compat, Compatibility::Unsupported);
        assert_eq!(status, CheckStatus::Fail);
    }

    #[test]
    fn build_tool_command_uses_path_when_set() {
        let entry = crate::manifest::ToolEntry {
            name: "node".to_string(),
            version: "22.11.0".to_string(),
            arch: None,
            path: Some("/opt/hostedtoolcache/node/22.11.0/x64/bin/node".to_string()),
            aliases: vec![],
            source: None,
            digest: None,
            in_toolcache: true,
            version_flag: None,
        };
        let cmd = build_tool_command(&entry);
        assert_eq!(cmd[0], "/opt/hostedtoolcache/node/22.11.0/x64/bin/node");
        assert_eq!(cmd[1], "--version");
    }

    #[test]
    fn build_tool_command_uses_name_when_no_path() {
        let entry = crate::manifest::ToolEntry {
            name: "cargo".to_string(),
            version: "1.85.0".to_string(),
            arch: None,
            path: None,
            aliases: vec![],
            source: None,
            digest: None,
            in_toolcache: false,
            version_flag: None,
        };
        let cmd = build_tool_command(&entry);
        assert_eq!(cmd[0], "cargo");
        assert_eq!(cmd[1], "--version");
    }

    #[test]
    fn package_probe_for_apt_uses_dpkg_query() {
        let pkg = crate::manifest::PackageEntry {
            name: "docker.io".to_string(),
            version: "27.3.1".to_string(),
            manager: "apt".to_string(),
            source: None,
        };
        let spec = package_probe_for(&pkg).unwrap();
        assert_eq!(spec.argv[0], "dpkg-query");
        assert!(spec.argv.contains(&"docker.io".to_string()));
    }

    #[test]
    fn package_probe_for_unknown_manager_returns_none() {
        let pkg = crate::manifest::PackageEntry {
            name: "x".to_string(),
            version: "1".to_string(),
            manager: "fakepkg".to_string(),
            source: None,
        };
        assert!(package_probe_for(&pkg).is_none());
    }
}
