use crate::manifest::{RunnerManifest, canonical_arch};
use crate::model::{
    Check, CheckEvidence, Compatibility, EvidenceKind, ReceiptSummary, SetupNodeReport,
};

pub const REASON_VERSION_RESOLUTION_REQUIRES_CATALOG: &str =
    "setup-node.version_resolution_requires_catalog";
pub const REASON_TOOLCACHE_MISSING: &str = "setup-node.toolcache_missing";
pub const REASON_ARCH_UNSUPPORTED: &str = "setup-node.arch_unsupported";
pub const REASON_NODE_NOT_IN_MANIFEST: &str = "setup-node.node_not_in_manifest";

#[derive(Debug, Clone)]
pub struct SetupNodeOptions {
    pub version_spec: String,
    pub arch: String,
    pub check_latest: bool,
}

pub fn classify(manifest: &RunnerManifest, options: &SetupNodeOptions) -> SetupNodeReport {
    let mut checks: Vec<Check> = Vec::new();
    let canonical = canonical_arch(&options.arch);
    if canonical == "unknown" {
        checks.push(Check::fail(
            REASON_ARCH_UNSUPPORTED,
            format!(
                "actions/setup-node arch '{arch}' is not a recognized runner arch",
                arch = options.arch
            ),
        ));
    }

    let any_node = manifest.tools.iter().any(|t| t.name == "node");
    if !any_node {
        checks.push(Check::fail(
            REASON_NODE_NOT_IN_MANIFEST,
            "manifest declares no node tool; actions/setup-node@v4 cannot be satisfied",
        ));
        return finalize(checks, options, None, Compatibility::Unsupported, None);
    }

    let resolved = manifest.resolve_node(&options.version_spec, &options.arch);
    let resolved = match resolved {
        Some(entry) => entry,
        None => {
            let is_broad = is_broad_spec(&options.version_spec);
            let reason = if is_broad {
                REASON_VERSION_RESOLUTION_REQUIRES_CATALOG
            } else {
                "setup-node.version_not_in_manifest"
            };
            let msg = if is_broad {
                format!(
                    "node spec '{}' is broad; offline resolution requires a matching alias \
                     in the manifest. Add an aliases entry like '\"{}\"' or pin a concrete \
                     version",
                    options.version_spec, options.version_spec
                )
            } else {
                format!(
                    "node version '{}' (arch {}) is not declared by any tool entry in the manifest",
                    options.version_spec, options.arch
                )
            };
            checks.push(Check::fail(reason, msg));
            return finalize(checks, options, None, Compatibility::Unsupported, None);
        }
    };

    if options.check_latest {
        checks.push(Check::warn(
            "setup-node.check_latest_requires_online",
            "--check-latest requires online resolution against Node's dist index; the offline \
             classifier returns whatever matches the manifest. The result may differ from \
             what setup-node@v4 would install live.",
        ));
    }

    if !resolved.in_toolcache {
        checks.push(Check::warn(
            "setup-node.tool_not_in_toolcache",
            format!(
                "node '{}' is declared but not marked in_toolcache; setup-node@v4 would \
                 download and stage the tarball at run time",
                resolved.version
            ),
        ));
    }

    let toolcache_path = resolved.path.clone().or_else(|| {
        manifest.toolcache_root().map(|root| {
            format!(
                "{root}/node/{version}/{arch}",
                root = root,
                version = resolved.version,
                arch = canonical_arch(&options.arch)
            )
        })
    });

    if resolved.in_toolcache && toolcache_path.is_none() {
        checks.push(Check::fail(
            REASON_TOOLCACHE_MISSING,
            "node entry is marked in_toolcache but no toolcache.root or tool.path is set",
        ));
    }

    let path_prepend = toolcache_path.as_ref().map(|p| format!("{p}/bin"));

    let exact = resolved.version == options.version_spec
        || resolved.aliases.iter().any(|a| a == &options.version_spec);
    let classification = if exact && resolved.in_toolcache {
        Compatibility::Exact
    } else {
        Compatibility::Simulated
    };

    let evidence = CheckEvidence {
        kind: EvidenceKind::ToolcacheLookup,
        command: None,
        exit_code: None,
        stdout: None,
        stderr: None,
        image: None,
        subject: Some(format!("node@{}/{}", resolved.version, options.arch)),
    };
    let ok_check = match classification {
        Compatibility::Exact => Check::pass(
            "setup-node.exact",
            format!(
                "setup-node@v4 would use toolcache node@{} for spec '{}'",
                resolved.version, options.version_spec
            ),
        ),
        Compatibility::Simulated => Check::warn(
            "setup-node.simulated",
            format!(
                "setup-node@v4 would resolve spec '{}' to node@{} (offline)",
                options.version_spec, resolved.version
            ),
        ),
        Compatibility::Unsupported => Check::fail(
            "setup-node.unsupported",
            format!(
                "setup-node@v4 cannot satisfy spec '{}' from this manifest",
                options.version_spec
            ),
        ),
    };
    checks.push(ok_check.with_evidence(evidence));

    finalize(
        checks,
        options,
        Some(resolved.version.clone()),
        classification,
        path_prepend.or(toolcache_path),
    )
}

fn finalize(
    mut checks: Vec<Check>,
    options: &SetupNodeOptions,
    resolved: Option<String>,
    classification: Compatibility,
    toolcache_or_prepend: Option<String>,
) -> SetupNodeReport {
    // If we already failed something, classification should be Unsupported.
    let any_fail = checks
        .iter()
        .any(|c| c.status == crate::model::CheckStatus::Fail);
    let classification = if any_fail {
        Compatibility::Unsupported
    } else {
        classification
    };
    if !any_fail && classification == Compatibility::Unsupported {
        checks.push(Check::fail(
            "setup-node.unresolved",
            "setup-node@v4 could not be resolved against this manifest",
        ));
    }
    let summary = ReceiptSummary::from_checks(&checks);
    let reason = checks
        .iter()
        .find(|c| c.status == crate::model::CheckStatus::Fail)
        .map(|c| c.id.clone());
    let path_prepend = toolcache_or_prepend.clone();
    SetupNodeReport {
        manifest_path: String::new(),
        image_name: String::new(),
        requested_version: options.version_spec.clone(),
        resolved_version: resolved,
        arch: canonical_arch(&options.arch).to_string(),
        toolcache_path: toolcache_or_prepend.map(|p| p.trim_end_matches("/bin").to_string()),
        path_prepend,
        classification,
        reason,
        check_latest: options.check_latest,
        summary,
        checks,
    }
}

fn is_broad_spec(spec: &str) -> bool {
    if spec.is_empty() {
        return false;
    }
    if spec.contains('*') {
        return true;
    }
    if spec.starts_with("lts/") {
        return true;
    }
    // Bare major like "22" with no dot.
    !spec.contains('.')
}

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

    fn manifest_with_node() -> RunnerManifest {
        serde_json::from_str(
            r#"{
            "schema_version": 1,
            "image": { "name": "img", "os": { "family": "linux", "arch": "x64" } },
            "toolcache": { "root": "/opt/hostedtoolcache" },
            "tools": [
                { "name": "node", "version": "22.11.0", "arch": "x64",
                  "path": "/opt/hostedtoolcache/node/22.11.0/x64",
                  "aliases": ["22", "lts/jod"], "in_toolcache": true }
            ]
        }"#,
        )
        .unwrap()
    }

    fn manifest_no_node() -> RunnerManifest {
        serde_json::from_str(
            r#"{
            "schema_version": 1,
            "image": { "name": "img", "os": { "family": "linux", "arch": "x64" } }
        }"#,
        )
        .unwrap()
    }

    fn opts(spec: &str, arch: &str) -> SetupNodeOptions {
        SetupNodeOptions {
            version_spec: spec.to_string(),
            arch: arch.to_string(),
            check_latest: false,
        }
    }

    #[test]
    fn exact_version_resolves_exact() {
        let m = manifest_with_node();
        let report = classify(&m, &opts("22.11.0", "x64"));
        assert_eq!(report.classification, Compatibility::Exact);
        assert_eq!(report.resolved_version.as_deref(), Some("22.11.0"));
        assert!(report.toolcache_path.is_some());
        assert!(report.path_prepend.as_deref().unwrap().ends_with("/bin"));
    }

    #[test]
    fn alias_resolves_exact() {
        let m = manifest_with_node();
        let report = classify(&m, &opts("lts/jod", "x64"));
        assert_eq!(report.classification, Compatibility::Exact);
        assert_eq!(report.resolved_version.as_deref(), Some("22.11.0"));
    }

    #[test]
    fn bare_major_resolves_exact_when_aliased() {
        let m = manifest_with_node();
        let report = classify(&m, &opts("22", "x64"));
        // "22" is a declared alias, so this is exact.
        assert_eq!(report.classification, Compatibility::Exact);
    }

    #[test]
    fn lts_star_without_alias_fails() {
        let m = manifest_with_node();
        let report = classify(&m, &opts("lts/*", "x64"));
        assert_eq!(report.classification, Compatibility::Unsupported);
        assert!(
            report
                .checks
                .iter()
                .any(|c| c.id == REASON_VERSION_RESOLUTION_REQUIRES_CATALOG)
        );
    }

    #[test]
    fn arch_mismatch_fails() {
        let m = manifest_with_node();
        let report = classify(&m, &opts("22.11.0", "arm64"));
        assert_eq!(report.classification, Compatibility::Unsupported);
    }

    #[test]
    fn no_node_in_manifest_fails() {
        let m = manifest_no_node();
        let report = classify(&m, &opts("22", "x64"));
        assert_eq!(report.classification, Compatibility::Unsupported);
        assert!(
            report
                .checks
                .iter()
                .any(|c| c.id == REASON_NODE_NOT_IN_MANIFEST)
        );
    }

    #[test]
    fn unknown_arch_fails() {
        let m = manifest_with_node();
        let report = classify(&m, &opts("22.11.0", "ppc99"));
        assert_eq!(report.classification, Compatibility::Unsupported);
        assert!(
            report
                .checks
                .iter()
                .any(|c| c.id == REASON_ARCH_UNSUPPORTED)
        );
    }

    #[test]
    fn check_latest_adds_warning() {
        let m = manifest_with_node();
        let mut o = opts("22.11.0", "x64");
        o.check_latest = true;
        let report = classify(&m, &o);
        assert!(
            report
                .checks
                .iter()
                .any(|c| c.id == "setup-node.check_latest_requires_online")
        );
    }

    #[test]
    fn version_specific_but_missing_fails_with_clear_reason() {
        let m = manifest_with_node();
        let report = classify(&m, &opts("20.0.0", "x64"));
        assert_eq!(report.classification, Compatibility::Unsupported);
        assert!(
            report
                .checks
                .iter()
                .any(|c| c.id == "setup-node.version_not_in_manifest")
        );
    }
}
