//! The normalized deployment manifest the assessor scores, plus adapters that
//! map common external shapes onto it.
//!
//! mcptest does not invent runtime facts. The manifest is whatever the user
//! hands in: its own normalized posture descriptor, a ToolHive-style run
//! profile, or a Kubernetes pod spec. Adapters detect the shape and normalize
//! it so the assessor stays a single pure function over one struct.

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// How a server is deployed. Drives the isolation baseline.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum DeploymentKind {
    /// A bare local stdio subprocess (no container).
    #[default]
    Stdio,
    /// A container (Docker/OCI).
    Container,
    /// A Kubernetes-managed pod.
    Kubernetes,
    /// A managed runtime/gateway (ToolHive and similar).
    Gateway,
}

impl DeploymentKind {
    /// A stable label for the report.
    pub fn label(self) -> &'static str {
        match self {
            DeploymentKind::Stdio => "stdio",
            DeploymentKind::Container => "container",
            DeploymentKind::Kubernetes => "kubernetes",
            DeploymentKind::Gateway => "gateway",
        }
    }
}

/// The normalized posture descriptor. Every field is `None`/empty when the
/// source did not say, so the assessor can mark it unknown rather than guess.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeploymentManifest {
    /// What is being assessed.
    pub kind: DeploymentKind,
    /// Whether the workload runs in a container/sandbox.
    pub containerized: Option<bool>,
    /// Whether the root filesystem is read-only.
    pub read_only_root_fs: Option<bool>,
    /// Whether the process runs as a non-root user.
    pub run_as_non_root: Option<bool>,
    /// Explicit filesystem paths the server is scoped to.
    #[serde(default)]
    pub filesystem_allow: Vec<String>,
    /// Network egress allowlist. An empty (present) list means egress is denied.
    pub egress_allow: Option<Vec<String>>,
    /// How secrets reach the server (`env`, `file`, `vault`, `none`).
    pub secret_source: Option<String>,
    /// Identity/auth mode (`oauth`, `bearer`, `mtls`, `none`).
    pub auth_mode: Option<String>,
    /// Source URL the artifact came from.
    pub registry_source: Option<String>,
    /// Whether the artifact reference is digest-pinned.
    pub registry_pinned: Option<bool>,
    /// Whether the artifact is signed (cosign and similar).
    pub registry_signed: Option<bool>,
    /// An external policy engine gating calls (`opa`, `cedar`, ...).
    pub policy_engine: Option<String>,
    /// Whether OpenTelemetry export is configured.
    pub otel: Option<bool>,
    /// Whether a Prometheus endpoint is exposed.
    pub prometheus: Option<bool>,
    /// Whether audit logging is enabled.
    pub audit_logging: Option<bool>,
}

impl DeploymentManifest {
    /// Synthesize a manifest for a bare stdio launch.
    ///
    /// A raw stdio subprocess is, by construction, unisolated: it runs with the
    /// caller's user, filesystem, and network. We record those as observed
    /// facts so the assessor scores them honestly.
    pub fn for_stdio_command(_command: &str) -> Self {
        DeploymentManifest {
            kind: DeploymentKind::Stdio,
            containerized: Some(false),
            read_only_root_fs: Some(false),
            run_as_non_root: None,
            egress_allow: None,
            ..Default::default()
        }
    }

    /// Parse a manifest from JSON or YAML, auto-detecting the source shape.
    pub fn parse(text: &str) -> Result<Self, ManifestError> {
        let value: Value = parse_json_or_yaml(text)?;
        Ok(adapt(&value))
    }
}

/// Detect the source shape and normalize it.
fn adapt(value: &Value) -> DeploymentManifest {
    if value.get("kind").and_then(Value::as_str) == Some("Pod")
        || value
            .get("spec")
            .and_then(|s| s.get("containers"))
            .is_some()
    {
        return adapt_kubernetes(value);
    }
    if value.get("permission_profile").is_some() || value.get("transport").is_some() {
        return adapt_toolhive(value);
    }
    // Otherwise treat it as mcptest's own normalized descriptor.
    serde_json::from_value(value.clone()).unwrap_or_default()
}

/// Map a Kubernetes pod spec's `securityContext` onto the manifest.
fn adapt_kubernetes(value: &Value) -> DeploymentManifest {
    let container = value
        .pointer("/spec/containers/0")
        .or_else(|| value.get("container"));
    let sec = container.and_then(|c| c.get("securityContext"));
    let pod_sec = value.pointer("/spec/securityContext");
    DeploymentManifest {
        kind: DeploymentKind::Kubernetes,
        containerized: Some(true),
        read_only_root_fs: sec
            .and_then(|s| s.get("readOnlyRootFilesystem"))
            .and_then(Value::as_bool),
        run_as_non_root: sec
            .or(pod_sec)
            .and_then(|s| s.get("runAsNonRoot"))
            .and_then(Value::as_bool),
        registry_source: container
            .and_then(|c| c.get("image"))
            .and_then(Value::as_str)
            .map(str::to_string),
        registry_pinned: container
            .and_then(|c| c.get("image"))
            .and_then(Value::as_str)
            .map(|img| img.contains("@sha256:")),
        ..Default::default()
    }
}

/// Map a ToolHive-style run profile onto the manifest.
fn adapt_toolhive(value: &Value) -> DeploymentManifest {
    let profile = value.get("permission_profile");
    let network = profile.and_then(|p| p.get("network"));
    let egress = network
        .and_then(|n| n.get("outbound"))
        .and_then(|o| o.get("allow_host"))
        .and_then(Value::as_array)
        .map(|hosts| {
            hosts
                .iter()
                .filter_map(Value::as_str)
                .map(str::to_string)
                .collect()
        });
    let filesystem_allow = profile
        .and_then(|p| p.get("read"))
        .and_then(Value::as_array)
        .map(|paths| {
            paths
                .iter()
                .filter_map(Value::as_str)
                .map(str::to_string)
                .collect()
        })
        .unwrap_or_default();
    DeploymentManifest {
        kind: DeploymentKind::Gateway,
        containerized: Some(true),
        read_only_root_fs: Some(true),
        run_as_non_root: Some(true),
        filesystem_allow,
        egress_allow: egress,
        secret_source: value
            .get("secrets")
            .and_then(Value::as_str)
            .map(str::to_string),
        auth_mode: value
            .get("oauth_config")
            .map(|_| "oauth".to_string())
            .or_else(|| {
                value
                    .get("auth")
                    .and_then(Value::as_str)
                    .map(str::to_string)
            }),
        registry_source: value
            .get("image")
            .and_then(Value::as_str)
            .map(str::to_string),
        registry_pinned: value
            .get("image")
            .and_then(Value::as_str)
            .map(|img| img.contains("@sha256:")),
        policy_engine: profile.map(|_| "toolhive-permission-profile".to_string()),
        otel: value
            .pointer("/telemetry/otel")
            .and_then(Value::as_bool)
            .or(Some(true)),
        prometheus: value
            .pointer("/telemetry/prometheus")
            .and_then(Value::as_bool),
        audit_logging: value.get("audit").and_then(Value::as_bool),
        ..Default::default()
    }
}

/// Parse text as JSON, falling back to YAML.
fn parse_json_or_yaml(text: &str) -> Result<Value, ManifestError> {
    if let Ok(v) = serde_json::from_str::<Value>(text) {
        return Ok(v);
    }
    serde_yaml::from_str::<Value>(text).map_err(|e| ManifestError::Parse(e.to_string()))
}

/// Errors raised while loading a deployment manifest.
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
    /// The manifest did not parse as JSON or YAML.
    #[error("failed to parse manifest as JSON or YAML: {0}")]
    Parse(String),
}
