//! Manifest install-time validation (Parslee-ai/car#182 phase 3).
//!
//! Three responsibilities, all run *before* the supervisor accepts
//! a manifest into the active set:
//!
//! 1. **`car_min_version` enforcement.** Parse semver, compare to
//!    the runtime's published version, reject manifests whose
//!    floor is higher than what this car build provides.
//! 2. **Capability negotiation.** Compare `manifest.capabilities.required`
//!    against the host's advertisement. Every required capability
//!    must appear in the host's `provides`; fail-closed when even
//!    one is missing. `optional` and `denied` are inspected for
//!    informational purposes (telemetry / future policy
//!    enforcement) but don't gate install.
//! 3. **Per-version resolution.** When two installed manifests
//!    share `<namespace>.<name>`, unqualified addressing
//!    (`car start parslee/ui-improvement`) resolves to the
//!    highest installed semver; explicit `@version` pins.
//!
//! The validator is a free function (`install_check`) called by
//! `Supervisor::install_manifest`. Tests live alongside.

use std::collections::BTreeMap;

use car_bundle::{AgentManifest, CapabilityDeclarations};
use semver::{Version, VersionReq};

use crate::supervisor::SupervisorError;

/// What the runtime claims it can provide to a contributed agent.
/// Keyed by namespace (`inference`, `storage`, `a2ui`, …); each
/// namespace maps to a list of feature identifiers
/// (`text-generation`, `persistent-kv`, `render_report.subscribe`).
///
/// Host code populates this once at boot (or per-session if the
/// scope ever varies) and passes it to `install_check`. The list
/// is intentionally string-typed: the capability vocabulary lives
/// in `docs/agent-bundle-spec.md` + `docs/proposals/contributed-agents.md`,
/// not in code, so vocabulary growth doesn't require recompilation.
#[derive(Debug, Clone, Default)]
pub struct HostCapabilities {
    pub provides: BTreeMap<String, Vec<String>>,
    /// The runtime's published version, used to validate
    /// `manifest.runtime.car_min_version`. Set by callers from
    /// `env!("CARGO_PKG_VERSION")`.
    pub car_version: String,
}

impl HostCapabilities {
    /// Helper for setup: register one or more features under a
    /// capability namespace.
    pub fn provide(
        mut self,
        namespace: impl Into<String>,
        features: impl IntoIterator<Item = impl Into<String>>,
    ) -> Self {
        let entry = self.provides.entry(namespace.into()).or_default();
        for f in features {
            let s = f.into();
            if !entry.contains(&s) {
                entry.push(s);
            }
        }
        self
    }

    /// Returns `true` if this host advertises the given namespace
    /// + feature pair.
    pub fn satisfies(&self, namespace: &str, feature: &str) -> bool {
        self.provides
            .get(namespace)
            .is_some_and(|features| features.iter().any(|f| f == feature))
    }

    /// Default advertisement for a v0.8 car-server daemon. Lists
    /// the capabilities the runtime genuinely provides today; new
    /// features are added here as they ship (and listed in
    /// `docs/agent-bundle-spec.md` + the proposal).
    ///
    /// Callers that need a more restricted advertisement (e.g.,
    /// a daemon running with `--no-inference` or in air-gapped
    /// mode) start from this default and remove namespaces.
    pub fn daemon_default(car_version: impl Into<String>) -> Self {
        Self {
            car_version: car_version.into(),
            provides: BTreeMap::from([
                (
                    "inference".to_string(),
                    vec![
                        "text-generation".to_string(),
                        "embedding".to_string(),
                        "classification".to_string(),
                        "tool-use".to_string(),
                    ],
                ),
                (
                    "storage".to_string(),
                    vec![
                        "persistent-kv".to_string(),
                        "persistent-journal".to_string(),
                        "persistent-graph".to_string(),
                        "temporary".to_string(),
                    ],
                ),
                (
                    "a2ui".to_string(),
                    vec![
                        "render_report.subscribe".to_string(),
                        "render_report.emit".to_string(),
                        "patch_components.emit".to_string(),
                        "surface_subscribe".to_string(),
                    ],
                ),
                (
                    "a2a".to_string(),
                    vec!["message_send".to_string(), "task_subscribe".to_string()],
                ),
            ]),
        }
    }
}

/// Result of running `install_check` on a manifest. The success
/// path carries the optional list of `optional` capabilities the
/// host couldn't satisfy — informational so the caller can warn
/// the user that those code paths in the agent will degrade.
#[derive(Debug, Clone, Default)]
pub struct InstallCheckReport {
    pub missing_optional: Vec<(String, String)>,
}

/// Validate a manifest against the host's capability + version
/// advertisement. Returns an `InstallCheckReport` on success, or
/// a `SupervisorError::Other` describing the first blocker on
/// failure.
///
/// Returns errors for: missing required capability, version
/// requirement parse failure, version requirement not satisfied
/// by the host's `car_version`.
pub fn install_check(
    manifest: &AgentManifest,
    host: &HostCapabilities,
) -> Result<InstallCheckReport, SupervisorError> {
    if let Some(runtime) = &manifest.runtime {
        if let Some(min) = &runtime.car_min_version {
            check_car_min_version(min, &host.car_version, &manifest.agent.id)?;
        }
    }
    let mut report = InstallCheckReport::default();
    if let Some(caps) = &manifest.capabilities {
        check_required(caps, host, &manifest.agent.id)?;
        for (namespace, features) in &caps.optional {
            for feature in features {
                if !host.satisfies(namespace, feature) {
                    report
                        .missing_optional
                        .push((namespace.clone(), feature.clone()));
                }
            }
        }
    }
    Ok(report)
}

fn check_car_min_version(
    requirement: &str,
    host_version: &str,
    agent_id: &str,
) -> Result<(), SupervisorError> {
    // Accept either a bare semver ("0.8.0") interpreted as a
    // minimum, OR a cargo-style requirement (">=0.8, <0.9").
    // Bare-semver bumps to ">=" automatically.
    let parsed_req = parse_version_req(requirement).map_err(|e| {
        SupervisorError::Other(format!(
            "agent `{agent_id}` has invalid car_min_version `{requirement}`: {e}"
        ))
    })?;
    let host = Version::parse(host_version).map_err(|e| {
        // The host's own version is malformed — that's a build
        // misconfiguration, not the manifest's fault. Surface it
        // clearly.
        SupervisorError::Other(format!(
            "host car_version `{host_version}` is not valid semver: {e}"
        ))
    })?;
    if !parsed_req.matches(&host) {
        return Err(SupervisorError::Other(format!(
            "agent `{agent_id}` requires car `{requirement}` but host runtime is `{host_version}`"
        )));
    }
    Ok(())
}

fn parse_version_req(s: &str) -> Result<VersionReq, semver::Error> {
    if let Ok(v) = Version::parse(s.trim()) {
        // Bare semver → `>=<v>`. Matches cargo's behavior for
        // bare-string version specs in `Cargo.toml`.
        return Ok(VersionReq::parse(&format!(">={v}"))?);
    }
    VersionReq::parse(s)
}

fn check_required(
    caps: &CapabilityDeclarations,
    host: &HostCapabilities,
    agent_id: &str,
) -> Result<(), SupervisorError> {
    for (namespace, features) in &caps.required {
        for feature in features {
            if !host.satisfies(namespace, feature) {
                return Err(SupervisorError::Other(format!(
                    "agent `{agent_id}` requires `{namespace}.{feature}` but host does not provide it"
                )));
            }
        }
    }
    Ok(())
}

// ---------------------------------------------------------------------
// Per-version resolution
// ---------------------------------------------------------------------

/// Resolve an unqualified `<namespace>/<name>` reference (or
/// `<id>` for legacy entries with no namespace) to the highest
/// installed semver. Returns `None` when no matching agent is
/// installed. Explicit `<namespace>/<name>@<version>` pins are
/// the caller's responsibility — this helper only handles the
/// unqualified case.
pub fn resolve_highest_version<'a>(
    candidates: impl IntoIterator<Item = &'a AgentManifest>,
    namespace: Option<&str>,
    name: &str,
) -> Option<&'a AgentManifest> {
    let mut best: Option<(Version, &AgentManifest)> = None;
    let mut fallback: Option<&AgentManifest> = None;
    for m in candidates {
        let m_name = m.agent.name.as_str();
        let m_namespace = m.agent.namespace.as_deref();
        // Match by name + optional namespace. When the caller
        // doesn't supply a namespace, accept any namespace; this
        // is the legacy-id path.
        let name_matches = m_name == name || m.agent.id == name;
        let namespace_matches = match (namespace, m_namespace) {
            (Some(want), Some(have)) => want == have,
            (None, _) => true,
            _ => false,
        };
        if !(name_matches && namespace_matches) {
            continue;
        }
        match m
            .agent
            .version
            .as_deref()
            .and_then(|v| Version::parse(v).ok())
        {
            Some(parsed) => match best.as_ref() {
                Some((b, _)) if &parsed <= b => {}
                _ => best = Some((parsed, m)),
            },
            None => {
                // Legacy / unversioned entry — only used as fallback
                // when no versioned manifest matched.
                if fallback.is_none() {
                    fallback = Some(m);
                }
            }
        }
    }
    best.map(|(_, m)| m).or(fallback)
}

#[cfg(test)]
mod tests {
    use super::*;
    use car_bundle::{AgentIdentity, ExternalProcessTransport, RuntimeRequirements, TransportSpec};

    fn external_manifest(id: &str, version: Option<&str>) -> AgentManifest {
        AgentManifest {
            agent: AgentIdentity {
                id: id.into(),
                name: id.into(),
                namespace: Some("parslee".into()),
                version: version.map(str::to_string),
                description: None,
                license: None,
                homepage: None,
            },
            publisher: None,
            runtime: None,
            lifecycle: None,
            transport: TransportSpec::ExternalProcess(ExternalProcessTransport {
                command: Some("/usr/local/bin/agent".into()),
                binary_url: None,
                sha256: Some("abc".into()),
                health_url: None,
                args: vec![],
                cwd: None,
                env: BTreeMap::new(),
                restart: car_bundle::RestartPolicy::OnFailure,
                max_restarts: 10,
                backoff_secs: 5,
                auto_start: false,
                token: String::new(),
            }),
            capabilities: None,
        }
    }

    #[test]
    fn install_check_passes_when_no_capabilities_declared() {
        let m = external_manifest("alpha", Some("0.1.0"));
        let host = HostCapabilities {
            car_version: "0.8.0".into(),
            ..Default::default()
        };
        let report = install_check(&m, &host).expect("no requirements means pass");
        assert!(report.missing_optional.is_empty());
    }

    #[test]
    fn install_check_fails_on_missing_required_capability() {
        let mut m = external_manifest("alpha", Some("0.1.0"));
        m.capabilities = Some(CapabilityDeclarations {
            required: BTreeMap::from([("inference".into(), vec!["text-generation".into()])]),
            ..Default::default()
        });
        let host = HostCapabilities {
            car_version: "0.8.0".into(),
            ..Default::default()
        };
        let err = install_check(&m, &host).expect_err("missing capability must fail");
        assert!(
            err.to_string().contains("inference.text-generation"),
            "expected missing-cap reason, got: {err}"
        );
    }

    #[test]
    fn install_check_records_missing_optional_capabilities() {
        let mut m = external_manifest("alpha", Some("0.1.0"));
        m.capabilities = Some(CapabilityDeclarations {
            required: BTreeMap::from([("inference".into(), vec!["text-generation".into()])]),
            optional: BTreeMap::from([
                ("inference".into(), vec!["embedding".into()]),
                ("a2a".into(), vec!["message_send".into()]),
            ]),
            ..Default::default()
        });
        let host = HostCapabilities::default()
            .provide("inference", ["text-generation"])
            // Note: embedding + message_send NOT advertised.
            ;
        let host = HostCapabilities {
            car_version: "0.8.0".into(),
            ..host
        };
        let report = install_check(&m, &host).expect("required satisfied");
        assert_eq!(report.missing_optional.len(), 2);
        let names: Vec<&str> = report
            .missing_optional
            .iter()
            .map(|(_n, f)| f.as_str())
            .collect();
        assert!(names.contains(&"embedding"));
        assert!(names.contains(&"message_send"));
    }

    #[test]
    fn install_check_rejects_when_car_min_version_too_high() {
        let mut m = external_manifest("alpha", Some("0.1.0"));
        m.runtime = Some(RuntimeRequirements {
            car_min_version: Some("0.9.0".into()),
            bundle_format_version: 1,
        });
        let host = HostCapabilities {
            car_version: "0.8.0".into(),
            ..Default::default()
        };
        let err = install_check(&m, &host).expect_err("min version unmet must fail");
        assert!(err.to_string().contains("0.9.0"));
        assert!(err.to_string().contains("0.8.0"));
    }

    #[test]
    fn install_check_accepts_bare_semver_as_minimum() {
        let mut m = external_manifest("alpha", Some("0.1.0"));
        m.runtime = Some(RuntimeRequirements {
            car_min_version: Some("0.7.0".into()),
            bundle_format_version: 1,
        });
        let host = HostCapabilities {
            car_version: "0.8.0".into(),
            ..Default::default()
        };
        install_check(&m, &host).expect("bare semver is interpreted as `>=`");
    }

    #[test]
    fn install_check_accepts_cargo_style_requirement() {
        let mut m = external_manifest("alpha", Some("0.1.0"));
        m.runtime = Some(RuntimeRequirements {
            car_min_version: Some(">=0.8, <0.9".into()),
            bundle_format_version: 1,
        });
        let host = HostCapabilities {
            car_version: "0.8.5".into(),
            ..Default::default()
        };
        install_check(&m, &host).expect("range requirement satisfied");
    }

    #[test]
    fn resolve_highest_version_picks_max_semver() {
        let v1 = external_manifest("ui", Some("0.1.0"));
        let v2 = external_manifest("ui", Some("0.2.0"));
        let v3 = external_manifest("ui", Some("0.1.5"));
        let all = vec![&v1, &v2, &v3];
        let resolved = resolve_highest_version(all.iter().copied(), Some("parslee"), "ui").unwrap();
        assert_eq!(resolved.agent.version.as_deref(), Some("0.2.0"));
    }

    #[test]
    fn resolve_falls_back_to_unversioned_when_no_versioned_match() {
        let mut legacy = external_manifest("ui", None);
        legacy.agent.namespace = None;
        let all = vec![&legacy];
        let resolved = resolve_highest_version(all.iter().copied(), None, "ui").unwrap();
        assert!(resolved.agent.version.is_none());
    }

    #[test]
    fn resolve_versioned_wins_over_unversioned() {
        let mut legacy = external_manifest("ui", None);
        legacy.agent.namespace = None;
        let versioned = external_manifest("ui", Some("0.1.0"));
        let all = vec![&legacy, &versioned];
        let resolved = resolve_highest_version(all.iter().copied(), Some("parslee"), "ui").unwrap();
        // Versioned wins because it matches the namespace exactly,
        // and a versioned match beats a no-namespace fallback.
        assert_eq!(resolved.agent.version.as_deref(), Some("0.1.0"));
    }
}
