use crate::manifest::RunnerManifest;
use crate::model::{
    CapabilityProbeOutcome, Check, CheckEvidence, CheckStatus, Compatibility,
    DockerInspectEvidence, EvidenceKind, PackageProbeOutcome, ProbeDockerReport, ReceiptSummary,
    ToolProbeOutcome,
};
use crate::probe::{
    CommandOutcome, ProbeRequest, capability_outcome_from, capability_probe_argv,
    missing_package_outcome, missing_tool_outcome, package_outcome_from, package_probe_for,
    requested_packages, requested_tools, tool_outcome_from,
};
use serde::Deserialize;
use std::process::Command;

const SKIP_TOOLS: &str = "probe.docker_cli_not_found";
const FAIL_DAEMON: &str = "probe.docker_daemon_unreachable";

pub fn probe_docker(
    manifest: &RunnerManifest,
    image: &str,
    request: &ProbeRequest<'_>,
) -> ProbeDockerReport {
    let mut report = ProbeDockerReport {
        manifest_path: String::new(),
        image_name: manifest.image.name.clone(),
        requested_image: image.to_string(),
        docker_cli_available: false,
        inspect: None,
        tools: Vec::new(),
        packages: Vec::new(),
        capabilities: Vec::new(),
        summary: ReceiptSummary::default(),
        checks: Vec::new(),
    };

    let cli = match which::which("docker") {
        Ok(path) => path,
        Err(err) => {
            report.checks.push(Check::skip(
                SKIP_TOOLS,
                format!(
                    "docker CLI not found on PATH ({err}); skipping every docker probe for image '{image}'"
                ),
            ));
            for name in request.tools {
                report
                    .tools
                    .push(missing_tool_skipped(name, "docker CLI not on PATH"));
            }
            for name in request.packages {
                report
                    .packages
                    .push(missing_package_skipped(name, "docker CLI not on PATH"));
            }
            for name in request.capabilities {
                report.capabilities.push(capability_skipped(
                    name,
                    "probe.docker_cli_not_found: docker CLI not on PATH",
                ));
            }
            report.summary = ReceiptSummary::from_checks(&report.checks);
            return report;
        }
    };
    report.docker_cli_available = true;

    let inspect_outcome = run_with(&cli.to_string_lossy(), &["image", "inspect", image]);
    if let Some(spawn_err) = &inspect_outcome.spawn_error {
        report.checks.push(
            Check::fail(
                FAIL_DAEMON,
                format!("could not invoke docker for image '{image}': {spawn_err}"),
            )
            .with_evidence(CheckEvidence {
                kind: EvidenceKind::DockerInspect,
                command: Some(inspect_outcome.command.clone()),
                exit_code: None,
                stdout: None,
                stderr: None,
                image: Some(image.to_string()),
                subject: Some(format!("image/{image}")),
            }),
        );
    } else if !inspect_outcome.success {
        let reason = if inspect_outcome
            .stderr
            .to_lowercase()
            .contains("cannot connect to the docker daemon")
        {
            FAIL_DAEMON
        } else {
            "probe.docker_image_inspect_failed"
        };
        report.checks.push(
            Check::fail(
                reason,
                format!(
                    "docker image inspect '{image}' exited {}",
                    inspect_outcome.exit_code.unwrap_or(-1)
                ),
            )
            .with_evidence(CheckEvidence {
                kind: EvidenceKind::DockerInspect,
                command: Some(inspect_outcome.command.clone()),
                exit_code: inspect_outcome.exit_code,
                stdout: Some(inspect_outcome.stdout.clone()),
                stderr: Some(inspect_outcome.stderr.clone()),
                image: Some(image.to_string()),
                subject: Some(format!("image/{image}")),
            }),
        );
    } else {
        match parse_inspect(&inspect_outcome.stdout) {
            Some(evidence) => {
                report.checks.push(
                    Check::pass(
                        "probe.docker_image_inspect_ok",
                        format!(
                            "docker image inspect '{image}': digest={} arch={} os={}",
                            evidence.digest.clone().unwrap_or_else(|| "?".to_string()),
                            evidence
                                .architecture
                                .clone()
                                .unwrap_or_else(|| "?".to_string()),
                            evidence.os.clone().unwrap_or_else(|| "?".to_string())
                        ),
                    )
                    .with_evidence(CheckEvidence {
                        kind: EvidenceKind::DockerInspect,
                        command: Some(inspect_outcome.command.clone()),
                        exit_code: inspect_outcome.exit_code,
                        stdout: None,
                        stderr: None,
                        image: Some(image.to_string()),
                        subject: Some(format!("image/{image}")),
                    }),
                );
                report.inspect = Some(evidence);
            }
            None => {
                report.checks.push(Check::warn(
                    "probe.docker_image_inspect_parse_failed",
                    format!("docker image inspect '{image}' returned unparseable output"),
                ));
            }
        }
    }

    // If the inspect step couldn't connect, don't try to run further probes.
    let daemon_ok = report
        .checks
        .iter()
        .all(|c| c.id != FAIL_DAEMON || c.status != CheckStatus::Fail);
    if !daemon_ok {
        for name in request.tools {
            report
                .tools
                .push(missing_tool_skipped(name, "docker daemon unreachable"));
        }
        for name in request.packages {
            report
                .packages
                .push(missing_package_skipped(name, "docker daemon unreachable"));
        }
        for name in request.capabilities {
            report
                .capabilities
                .push(capability_skipped(name, "probe.docker_daemon_unreachable"));
        }
        report.summary = ReceiptSummary::from_checks(&report.checks);
        return report;
    }

    for (name, declared) in requested_tools(request.tools, manifest) {
        match declared {
            Some(entry) => {
                let mut argv: Vec<String> =
                    vec!["run".to_string(), "--rm".to_string(), image.to_string()];
                let binary = entry
                    .path
                    .clone()
                    .filter(|p| !p.is_empty())
                    .unwrap_or_else(|| entry.name.clone());
                argv.push(binary);
                argv.push(
                    entry
                        .version_flag
                        .clone()
                        .unwrap_or_else(|| "--version".to_string()),
                );
                let outcome = run_argv(&cli.to_string_lossy(), &argv);
                let probe_outcome = tool_outcome_from(name, Some(entry.version.as_str()), outcome);
                push_tool_check(&mut report.checks, &probe_outcome, image);
                report.tools.push(probe_outcome);
            }
            None => {
                report.checks.push(Check::fail(
                    "probe.tool_not_in_manifest",
                    format!("tool '{name}' is not declared in the manifest"),
                ));
                report.tools.push(missing_tool_outcome(
                    name,
                    "probe.tool_not_in_manifest: manifest does not declare this tool",
                ));
            }
        }
    }

    for (name, declared) in requested_packages(request.packages, manifest) {
        match declared {
            Some(pkg) => match package_probe_for(pkg) {
                Some(spec) => {
                    let mut argv: Vec<String> =
                        vec!["run".to_string(), "--rm".to_string(), image.to_string()];
                    argv.extend(spec.argv.iter().cloned());
                    let outcome = run_argv(&cli.to_string_lossy(), &argv);
                    let probe_outcome = package_outcome_from(pkg, &spec, outcome);
                    push_package_check(&mut report.checks, &probe_outcome, image);
                    report.packages.push(probe_outcome);
                }
                None => {
                    report.checks.push(Check::skip(
                        "probe.package_manager_unsupported",
                        format!(
                            "package '{name}' uses unsupported manager '{}' for docker probes",
                            pkg.manager
                        ),
                    ));
                    let outcome = PackageProbeOutcome {
                        name: pkg.name.clone(),
                        manager: pkg.manager.clone(),
                        expected_version: Some(pkg.version.clone()),
                        observed_version: None,
                        classification: Compatibility::Simulated,
                        status: CheckStatus::Skip,
                        reason: Some(format!(
                            "probe.package_manager_unsupported: '{}'",
                            pkg.manager
                        )),
                        command: None,
                        exit_code: None,
                        stdout_excerpt: None,
                        stderr_excerpt: None,
                    };
                    report.packages.push(outcome);
                }
            },
            None => {
                report.checks.push(Check::fail(
                    "probe.package_not_in_manifest",
                    format!("package '{name}' is not declared in the manifest"),
                ));
                report.packages.push(missing_package_outcome(
                    name,
                    "probe.package_not_in_manifest: manifest does not declare this package",
                ));
            }
        }
    }

    for name in request.capabilities {
        if !manifest.has_capability(name) {
            report.checks.push(Check::fail(
                "probe.capability_not_in_manifest",
                format!("capability '{name}' is not declared in the manifest"),
            ));
            report.capabilities.push(CapabilityProbeOutcome {
                name: name.clone(),
                classification: Compatibility::Unsupported,
                status: CheckStatus::Fail,
                reason: Some(
                    "probe.capability_not_in_manifest: manifest does not declare this capability"
                        .to_string(),
                ),
                command: None,
                exit_code: None,
                stdout_excerpt: None,
                stderr_excerpt: None,
            });
            continue;
        }
        match capability_probe_argv(name) {
            Some(inner) => {
                let mut argv: Vec<String> =
                    vec!["run".to_string(), "--rm".to_string(), image.to_string()];
                argv.extend(inner);
                let outcome = run_argv(&cli.to_string_lossy(), &argv);
                let probe = capability_outcome_from(name, outcome);
                push_capability_check(&mut report.checks, &probe, image);
                report.capabilities.push(probe);
            }
            None => {
                report.checks.push(Check::skip(
                    "probe.capability_no_docker_probe",
                    format!("no docker probe defined for capability '{name}'"),
                ));
                report.capabilities.push(capability_skipped(
                    name,
                    "probe.capability_no_docker_probe: capability accepted from manifest, no docker probe",
                ));
            }
        }
    }

    report.summary = ReceiptSummary::from_checks(&report.checks);
    report
}

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

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

fn capability_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,
    }
}

fn run_with(bin: &str, args: &[&str]) -> CommandOutcome {
    let display = format!("{bin} {}", args.join(" "));
    let mut cmd = Command::new(bin);
    for a in args {
        cmd.arg(a);
    }
    match cmd.output() {
        Ok(o) => CommandOutcome::from_output(display, o),
        Err(e) => CommandOutcome::from_spawn_error(display, e),
    }
}

fn run_argv(bin: &str, args: &[String]) -> CommandOutcome {
    let display = format!("{bin} {}", args.join(" "));
    let mut cmd = Command::new(bin);
    for a in args {
        cmd.arg(a);
    }
    match cmd.output() {
        Ok(o) => CommandOutcome::from_output(display, o),
        Err(e) => CommandOutcome::from_spawn_error(display, e),
    }
}

fn push_tool_check(checks: &mut Vec<Check>, outcome: &ToolProbeOutcome, image: &str) {
    let id = match outcome.status {
        CheckStatus::Pass => "probe.tool_exact",
        CheckStatus::Warn => "probe.tool_drift",
        CheckStatus::Fail => "probe.tool_unsupported",
        CheckStatus::Skip => "probe.tool_skipped",
    };
    let msg = match (&outcome.expected_version, &outcome.observed_version) {
        (Some(exp), Some(obs)) => format!(
            "tool '{}' in {image}: expected {exp} observed {obs}",
            outcome.name
        ),
        (Some(exp), None) => format!(
            "tool '{}' in {image}: expected {exp} (no probe output)",
            outcome.name
        ),
        _ => format!("tool '{}' in {image}: probe inconclusive", outcome.name),
    };
    let evidence = CheckEvidence {
        kind: EvidenceKind::DockerRun,
        command: outcome.command.clone(),
        exit_code: outcome.exit_code,
        stdout: outcome.stdout_excerpt.clone(),
        stderr: outcome.stderr_excerpt.clone(),
        image: Some(image.to_string()),
        subject: Some(format!("tool/{}", outcome.name)),
    };
    let check = match outcome.status {
        CheckStatus::Pass => Check::pass(id, msg),
        CheckStatus::Warn => Check::warn(id, msg),
        CheckStatus::Fail => Check::fail(id, msg),
        CheckStatus::Skip => Check::skip(id, msg),
    };
    checks.push(check.with_evidence(evidence));
}

fn push_package_check(checks: &mut Vec<Check>, outcome: &PackageProbeOutcome, image: &str) {
    let id = match outcome.status {
        CheckStatus::Pass => "probe.package_exact",
        CheckStatus::Warn => "probe.package_drift",
        CheckStatus::Fail => "probe.package_unsupported",
        CheckStatus::Skip => "probe.package_skipped",
    };
    let msg = match (&outcome.expected_version, &outcome.observed_version) {
        (Some(exp), Some(obs)) => format!(
            "package '{}' ({}) in {image}: expected {exp} observed {obs}",
            outcome.name, outcome.manager
        ),
        (Some(exp), None) => format!(
            "package '{}' ({}) in {image}: expected {exp} (no probe output)",
            outcome.name, outcome.manager
        ),
        _ => format!("package '{}' in {image}: probe inconclusive", outcome.name),
    };
    let evidence = CheckEvidence {
        kind: EvidenceKind::DockerRun,
        command: outcome.command.clone(),
        exit_code: outcome.exit_code,
        stdout: outcome.stdout_excerpt.clone(),
        stderr: outcome.stderr_excerpt.clone(),
        image: Some(image.to_string()),
        subject: Some(format!("package/{}", outcome.name)),
    };
    let check = match outcome.status {
        CheckStatus::Pass => Check::pass(id, msg),
        CheckStatus::Warn => Check::warn(id, msg),
        CheckStatus::Fail => Check::fail(id, msg),
        CheckStatus::Skip => Check::skip(id, msg),
    };
    checks.push(check.with_evidence(evidence));
}

fn push_capability_check(checks: &mut Vec<Check>, outcome: &CapabilityProbeOutcome, image: &str) {
    let id = match outcome.status {
        CheckStatus::Pass => "probe.capability_exact",
        CheckStatus::Warn => "probe.capability_drift",
        CheckStatus::Fail => "probe.capability_unsupported",
        CheckStatus::Skip => "probe.capability_skipped",
    };
    let msg = format!("capability '{}' in {image}", outcome.name);
    let evidence = CheckEvidence {
        kind: EvidenceKind::DockerRun,
        command: outcome.command.clone(),
        exit_code: outcome.exit_code,
        stdout: outcome.stdout_excerpt.clone(),
        stderr: outcome.stderr_excerpt.clone(),
        image: Some(image.to_string()),
        subject: Some(format!("capability/{}", outcome.name)),
    };
    let check = match outcome.status {
        CheckStatus::Pass => Check::pass(id, msg),
        CheckStatus::Warn => Check::warn(id, msg),
        CheckStatus::Fail => Check::fail(id, msg),
        CheckStatus::Skip => Check::skip(id, msg),
    };
    checks.push(check.with_evidence(evidence));
}

#[derive(Deserialize)]
struct InspectEntry {
    #[serde(default, rename = "RepoTags")]
    repo_tags: Vec<String>,
    #[serde(default, rename = "RepoDigests")]
    repo_digests: Vec<String>,
    #[serde(default, rename = "Id")]
    id: Option<String>,
    #[serde(default, rename = "Size")]
    size: Option<i64>,
    #[serde(default, rename = "Created")]
    created: Option<String>,
    #[serde(default, rename = "Os")]
    os: Option<String>,
    #[serde(default, rename = "Architecture")]
    architecture: Option<String>,
}

fn parse_inspect(stdout: &str) -> Option<DockerInspectEvidence> {
    let entries: Vec<InspectEntry> = serde_json::from_str(stdout).ok()?;
    let entry = entries.into_iter().next()?;
    let digest = entry
        .repo_digests
        .first()
        .cloned()
        .or_else(|| entry.id.clone());
    Some(DockerInspectEvidence {
        repo_tags: entry.repo_tags,
        digest,
        size: entry.size,
        created: entry.created,
        os: entry.os,
        architecture: entry.architecture,
    })
}

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

    #[test]
    fn parse_inspect_extracts_metadata() {
        let stdout = r#"[
          {
            "RepoTags": ["ci-forge/mortar:local"],
            "RepoDigests": ["ci-forge/mortar@sha256:deadbeef"],
            "Id": "sha256:cafebabe",
            "Size": 1234567,
            "Created": "2026-05-12T00:00:00Z",
            "Os": "linux",
            "Architecture": "amd64"
          }
        ]"#;
        let evidence = parse_inspect(stdout).unwrap();
        assert_eq!(evidence.repo_tags, vec!["ci-forge/mortar:local"]);
        assert_eq!(
            evidence.digest,
            Some("ci-forge/mortar@sha256:deadbeef".to_string())
        );
        assert_eq!(evidence.architecture.as_deref(), Some("amd64"));
        assert_eq!(evidence.os.as_deref(), Some("linux"));
    }

    #[test]
    fn parse_inspect_empty_returns_none() {
        assert!(parse_inspect("[]").is_none());
        assert!(parse_inspect("not json").is_none());
    }
}
