use anyhow::{Context, Result, bail};
use camino::Utf8Path;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;

use crate::model::SchemaVersion;

/// Runner-image manifest v1. Owned by this crate; ci-forge and other offline
/// runners emit JSON conforming to this schema and pass the path to the CLI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunnerManifest {
    pub schema_version: SchemaVersion,
    #[serde(default)]
    pub tool: Option<ManifestToolInfo>,
    #[serde(default)]
    pub generated_at: Option<String>,
    pub image: ImageIdentity,
    #[serde(default)]
    pub labels: Vec<String>,
    #[serde(default)]
    pub capabilities: Vec<String>,
    #[serde(default)]
    pub toolcache: Option<ToolcacheLayout>,
    #[serde(default)]
    pub tools: Vec<ToolEntry>,
    #[serde(default)]
    pub packages: Vec<PackageEntry>,
    #[serde(default)]
    pub environment: BTreeMap<String, String>,
    #[serde(default)]
    pub evidence: Option<EvidenceBlock>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestToolInfo {
    pub name: String,
    pub version: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageIdentity {
    pub name: String,
    #[serde(default)]
    pub tag: Option<String>,
    #[serde(default)]
    pub digest: Option<String>,
    pub os: OsInfo,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OsInfo {
    pub family: OsFamily,
    #[serde(default)]
    pub distro: Option<String>,
    #[serde(default)]
    pub version: Option<String>,
    pub arch: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OsFamily {
    Linux,
    Macos,
    Windows,
}

impl OsFamily {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Linux => "linux",
            Self::Macos => "macos",
            Self::Windows => "windows",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolcacheLayout {
    pub root: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolEntry {
    pub name: String,
    pub version: String,
    #[serde(default)]
    pub arch: Option<String>,
    #[serde(default)]
    pub path: Option<String>,
    #[serde(default)]
    pub aliases: Vec<String>,
    #[serde(default)]
    pub source: Option<String>,
    #[serde(default)]
    pub digest: Option<String>,
    #[serde(default)]
    pub in_toolcache: bool,
    #[serde(default)]
    pub version_flag: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageEntry {
    pub name: String,
    pub version: String,
    pub manager: String,
    #[serde(default)]
    pub source: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EvidenceBlock {
    #[serde(default)]
    pub source_urls: Vec<String>,
    #[serde(default)]
    pub image_digest: Option<String>,
    #[serde(default)]
    pub probe_records: Vec<serde_json::Value>,
}

impl RunnerManifest {
    pub fn load(path: &Utf8Path) -> Result<Self> {
        let raw = fs::read_to_string(path).with_context(|| format!("reading manifest {path}"))?;
        let value: serde_json::Value =
            serde_json::from_str(&raw).with_context(|| format!("parsing manifest {path}"))?;
        let manifest: RunnerManifest =
            serde_json::from_value(value).with_context(|| format!("loading manifest {path}"))?;
        if manifest.schema_version != crate::model::SCHEMA_VERSION {
            bail!(
                "manifest {path} declares schema_version={}, expected {}",
                manifest.schema_version,
                crate::model::SCHEMA_VERSION
            );
        }
        Ok(manifest)
    }

    pub fn find_tool(&self, name: &str) -> Option<&ToolEntry> {
        self.tools.iter().find(|t| t.name == name)
    }

    pub fn find_package(&self, name: &str) -> Option<&PackageEntry> {
        self.packages.iter().find(|p| p.name == name)
    }

    pub fn has_capability(&self, name: &str) -> bool {
        self.capabilities.iter().any(|c| c == name)
    }

    pub fn toolcache_root(&self) -> Option<&str> {
        self.toolcache.as_ref().map(|t| t.root.as_str())
    }

    /// Resolve a node version spec (`22`, `22.16.0`, `lts/*`) against the
    /// manifest's declared tool entries and aliases. Returns the resolved
    /// concrete entry when a match exists. Pure-offline; never queries network.
    pub fn resolve_node(&self, version_spec: &str, arch: &str) -> Option<&ToolEntry> {
        let want_arch = canonical_arch(arch);
        self.tools.iter().find(|t| {
            t.name == "node"
                && t.arch.as_deref().map(canonical_arch).unwrap_or(want_arch) == want_arch
                && tool_matches_spec(t, version_spec)
        })
    }
}

fn tool_matches_spec(entry: &ToolEntry, spec: &str) -> bool {
    if entry.version == spec {
        return true;
    }
    if entry.aliases.iter().any(|a| a == spec) {
        return true;
    }
    // Accept a bare major like "22" if the entry version starts with "22.".
    if !spec.contains('.') && !spec.contains('/') {
        let prefix = format!("{spec}.");
        if entry.version.starts_with(&prefix) {
            return true;
        }
    }
    false
}

pub fn canonical_arch(arch: &str) -> &'static str {
    match arch.to_lowercase().as_str() {
        "x64" | "x86_64" | "amd64" => "x64",
        "arm64" | "aarch64" => "arm64",
        "x86" | "i386" | "i686" => "x86",
        "arm" | "armv7" | "armv7l" => "arm",
        other => match other {
            "ppc64" => "ppc64",
            "ppc64le" => "ppc64le",
            "s390x" => "s390x",
            _ => "unknown",
        },
    }
}

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

    fn sample() -> RunnerManifest {
        serde_json::from_str(
            r#"{
            "schema_version": 1,
            "image": {
                "name": "ci-forge/ubuntu-22.04",
                "tag": "local",
                "os": { "family": "linux", "distro": "ubuntu", "version": "22.04", "arch": "x64" }
            },
            "labels": ["self-hosted", "linux", "x64", "ubuntu-22.04"],
            "capabilities": ["docker"],
            "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 },
                { "name": "cargo", "version": "1.85.0", "path": "/usr/local/cargo/bin/cargo" }
            ],
            "packages": [
                { "manager": "apt", "name": "docker.io", "version": "27.3.1" }
            ],
            "environment": { "RUNNER_OS": "Linux", "RUNNER_ARCH": "X64" }
        }"#,
        )
        .unwrap()
    }

    #[test]
    fn resolve_node_exact() {
        let m = sample();
        let entry = m.resolve_node("22.11.0", "x64").unwrap();
        assert_eq!(entry.version, "22.11.0");
    }

    #[test]
    fn resolve_node_by_major() {
        let m = sample();
        let entry = m.resolve_node("22", "x64").unwrap();
        assert_eq!(entry.version, "22.11.0");
    }

    #[test]
    fn resolve_node_by_alias() {
        let m = sample();
        let entry = m.resolve_node("lts/jod", "x64").unwrap();
        assert_eq!(entry.version, "22.11.0");
    }

    #[test]
    fn resolve_node_arch_mismatch() {
        let m = sample();
        assert!(m.resolve_node("22", "arm64").is_none());
    }

    #[test]
    fn resolve_node_unknown_alias() {
        let m = sample();
        assert!(m.resolve_node("lts/*", "x64").is_none());
    }

    #[test]
    fn canonical_arch_normalizes() {
        assert_eq!(canonical_arch("X64"), "x64");
        assert_eq!(canonical_arch("amd64"), "x64");
        assert_eq!(canonical_arch("AArch64"), "arm64");
        assert_eq!(canonical_arch("nonsense"), "unknown");
    }
}
