use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_yaml::Value as YamlValue;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::fmt::Write as _;
use std::fs;
use std::io::Write as _;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
use thiserror::Error;

const GENERATED_DIR: &str = ".generated";
const DEFAULT_SGLANGMUX_LISTEN_PORT: u16 = 8100;

/// The subdomain under which all shenron-managed nodes live.
///
/// # Security decision (2026-02-27)
///
/// All Cloudflare DNS operations performed by the shenron CLI are hard-restricted to
/// subdomains of this domain. The constant is compiled into the released binary and cannot
/// be overridden at runtime via flags or environment variables.
///
/// This means a user who obtains a Cloudflare API token scoped to `doubleword.ai` can
/// only ever create, update, or delete records under `*.nodes.doubleword.ai` through
/// this tool — they cannot accidentally (or maliciously) touch other records in the zone.
///
/// To change the base domain, cut a new release. Do **not** expose this as a CLI flag.
///
/// TODO: document this constraint in README.md under a "Security" section.
pub const NODES_BASE_DOMAIN: &str = "nodes.doubleword.ai";

#[derive(Debug, Error)]
pub enum ShenronError {
    #[error("target path does not exist: {0}")]
    MissingTarget(PathBuf),
    #[error("no config file (*.yml or *.yaml) found in {0}")]
    MissingConfig(PathBuf),
    #[error("multiple config files found in {dir}: {files}")]
    AmbiguousConfig { dir: PathBuf, files: String },
    #[error("failed to read {path}: {source}")]
    ReadFile {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error("failed to parse YAML config {path}: {source}")]
    ParseConfig {
        path: PathBuf,
        source: serde_yaml::Error,
    },
    #[error("config validation failed: {0}")]
    Validation(String),
    #[error("failed to write {path}: {source}")]
    WriteFile {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error("failed to serialize JSON: {0}")]
    Json(#[from] serde_json::Error),
    #[error("argument contains unsupported null byte: {0}")]
    Quote(String),
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NodeEndpointMetadata {
    pub fqdn: String,
    pub subdomain_label: String,
    pub base_domain: String,
    pub public_ip: String,
    pub cloudflare_zone_id: String,
    pub cloudflare_record_id: String,
    pub created_at: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ModelConfig {
    pub model_name: String,
    pub api_key: Option<String>,
    #[serde(alias = "vllm_port")]
    pub engine_port: Option<u16>,
    #[serde(alias = "vllm_host")]
    pub engine_host: Option<String>,
    #[serde(alias = "sglang_args")]
    pub engine_args: Option<Vec<YamlValue>>,
    #[serde(alias = "engine_env", alias = "sglang_env")]
    pub engine_envs: Option<Vec<YamlValue>>,
    #[serde(alias = "sglang_use_cuda_ipc_transport")]
    pub engine_use_cuda_ipc_transport: Option<bool>,
}

impl Default for ModelConfig {
    fn default() -> Self {
        Self {
            model_name: String::new(),
            api_key: None,
            engine_port: None,
            engine_host: None,
            engine_args: None,
            engine_envs: None,
            engine_use_cuda_ipc_transport: None,
        }
    }
}

#[derive(Debug, Clone)]
struct ResolvedModelConfig {
    model_name: String,
    api_key: String,
    engine_port: u16,
    engine_host: String,
    engine_args: Vec<YamlValue>,
    engine_envs: Vec<YamlValue>,
    engine_use_cuda_ipc_transport: bool,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ShenronConfig {
    // Core
    pub model_name: String,
    pub cuda_version: u16,
    pub tensor_parallel_size: u16,
    pub shenron_version: String,
    #[serde(default)]
    pub onwards_version: String,
    
    // Utils
    pub api_key: String,
    #[serde(alias = "vllm_port")]
    pub engine_port: u16,
    #[serde(alias = "vllm_host")]
    pub engine_host: String,
    
    // Engine 
    pub engine: Engine,
    pub engine_args: Option<Vec<YamlValue>>,
    #[serde(default, rename = "vllm_args", skip_serializing)]
    legacy_vllm_args: Option<Vec<YamlValue>>,
    #[serde(default, rename = "sglang_args", skip_serializing)]
    legacy_sglang_args: Option<Vec<YamlValue>>,
    #[serde(alias = "sglang_env")]
    pub engine_env: Vec<YamlValue>,
    #[serde(alias = "sglang_use_cuda_ipc_transport")]
    pub engine_use_cuda_ipc_transport: bool,
    pub models: Vec<ModelConfig>,
    #[serde(alias = "sglangmux-listen-port")]
    pub sglangmux_listen_port: Option<u16>,
    #[serde(alias = "sglangmux-host")]
    pub sglangmux_host: Option<String>,
    #[serde(alias = "sglangmux-upstream-timeout-secs")]
    pub sglangmux_upstream_timeout_secs: Option<u32>,
    #[serde(alias = "sglangmux-model-ready-timeout-secs")]
    pub sglangmux_model_ready_timeout_secs: Option<u32>,
    #[serde(alias = "sglangmux-model-switch-timeout-secs")]
    pub sglangmux_model_switch_timeout_secs: Option<u32>,
    #[serde(alias = "sglangmux-log-dir")]
    pub sglangmux_log_dir: Option<String>,

    // Metrics
    pub prometheus_port: u16,
    pub prometheus_version: String,

    // Scouter
    pub scouter_version: String,
    pub scouter_collector_instance: String,
    pub scouter_collector_url: Option<String>,
    pub scouter_reporter_interval: u32,
    pub scouter_ingest_api_key: String,

    // Gateway / Caddy
    /// Deprecated: upstream port is now auto-derived via `effective_upstream_port()`.
    /// Kept for backward compatibility with existing config files.
    #[serde(default, alias = "onwards_port")]
    pub gateway_upstream_port: Option<u16>,
    /// Deprecated: compose always publishes 80 and 443.
    #[serde(default)]
    pub gateway_http_port: Option<u16>,
    /// Deprecated: compose always publishes 80 and 443.
    #[serde(default)]
    pub gateway_https_port: Option<u16>,
    /// Deprecated: not reflected in generated output.
    #[serde(default)]
    pub gateway_enable_http: Option<bool>,
    /// Deprecated: not reflected in generated output.
    #[serde(default)]
    pub gateway_enable_https: Option<bool>,
    pub gateway_auth_header: String,
    pub gateway_upstream_host: String,
    pub caddy_acme_email: Option<String>,
}

#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Engine {
    Vllm,
    Sglang,
}

impl Default for Engine {
    fn default() -> Self {
        Engine::Vllm
    }
}

fn default_vllm_args() -> Vec<YamlValue> {
    let override_value = serde_yaml::to_value(json!({
        "max_new_tokens": 16384,
        "presence_penalty": 1.5,
        "temperature": 0.7,
        "top_p": 0.8,
        "top_k": 20,
        "min_p": 0,
    }))
    .expect("serialize override generation config");

    vec![
        YamlValue::String("--gpu-memory-utilization".to_string()),
        serde_yaml::to_value(0.7).expect("gpu_memory_utilization"),
        YamlValue::String("--limit-mm-per-prompt.video".to_string()),
        serde_yaml::to_value(0).expect("limit_mm_per_prompt_video"),
        YamlValue::String("--scheduling-policy".to_string()),
        YamlValue::String("priority".to_string()),
        YamlValue::String("--tool-call-parser".to_string()),
        YamlValue::String("hermes".to_string()),
        YamlValue::String("--generation-config".to_string()),
        YamlValue::String("auto".to_string()),
        YamlValue::String("--override-generation-config".to_string()),
        override_value,
        YamlValue::String("--trust-remote-code".to_string()),
        YamlValue::String("--async-scheduling".to_string()),
        YamlValue::String("--enable-auto-tool-choice".to_string()),
    ]
}

impl Default for ShenronConfig {
    fn default() -> Self {
        Self {
            model_name: "Qwen/Qwen3-0.6B".to_string(),
            cuda_version: 126,
            tensor_parallel_size: 1,
            shenron_version: "latest".to_string(),
            onwards_version: "latest".to_string(),
            engine: Engine::Vllm,
            engine_args: None,
            legacy_vllm_args: None,
            legacy_sglang_args: None,
            engine_env: Vec::new(),
            engine_use_cuda_ipc_transport: false,
            api_key: "sk-".to_string(),
            engine_port: 8000,
            engine_host: "0.0.0.0".to_string(),
            prometheus_port: 9090,
            prometheus_version: "v2.51.2".to_string(),
            scouter_version: "latest".to_string(),
            scouter_collector_instance: "host.docker.internal".to_string(),
            scouter_collector_url: None,
            scouter_reporter_interval: 10,
            scouter_ingest_api_key: "api-key".to_string(),
            models: Vec::new(),
            sglangmux_listen_port: None,
            sglangmux_host: None,
            sglangmux_upstream_timeout_secs: None,
            sglangmux_model_ready_timeout_secs: None,
            sglangmux_model_switch_timeout_secs: None,
            sglangmux_log_dir: None,
            gateway_upstream_port: None,
            gateway_http_port: None,
            gateway_https_port: None,
            gateway_enable_http: None,
            gateway_enable_https: None,
            gateway_auth_header: "Authorization".to_string(),
            gateway_upstream_host: "vllm".to_string(),
            caddy_acme_email: None,
        }
    }
}

impl ShenronConfig {
    fn validate(&self) -> Result<(), ShenronError> {
        if self.models.is_empty() && self.model_name.trim().is_empty() {
            return Err(ShenronError::Validation(
                "model_name must be set".to_string(),
            ));
        }
        if self.shenron_version.trim().is_empty() {
            return Err(ShenronError::Validation(
                "shenron_version must be set".to_string(),
            ));
        }
        if self.tensor_parallel_size == 0 {
            return Err(ShenronError::Validation(
                "tensor_parallel_size must be greater than 0".to_string(),
            ));
        }

        parse_env_pairs(&self.engine_env, "engine_env")?;

        // Gateway / Caddy validation
        if self.gateway_auth_header.trim().is_empty() {
            return Err(ShenronError::Validation(
                "gateway_auth_header must not be empty".to_string(),
            ));
        }
        if self.gateway_upstream_host.trim().is_empty() {
            return Err(ShenronError::Validation(
                "gateway_upstream_host must not be empty".to_string(),
            ));
        }

        if self.models.is_empty() {
            return Ok(());
        }

        if self.models.len() > 1 && self.engine != Engine::Sglang {
            return Err(ShenronError::Validation(
                "multiple models requires engine=sglang".to_string(),
            ));
        }

        let resolved_models = self.resolve_models()?;
        let mut model_names = HashSet::new();
        let mut model_ports = HashSet::new();
        for model in &resolved_models {
            if !model_names.insert(model.model_name.clone()) {
                return Err(ShenronError::Validation(format!(
                    "duplicate models.model_name value: {}",
                    model.model_name
                )));
            }
            if !model_ports.insert(model.engine_port) {
                return Err(ShenronError::Validation(format!(
                    "duplicate models.engine_port value: {}",
                    model.engine_port
                )));
            }
        }

        if resolved_models.len() > 1 {
            let mux_listen_port = self.sglangmux_listen_port();
            if model_ports.contains(&mux_listen_port) {
                return Err(ShenronError::Validation(format!(
                    "sglangmux_listen_port {} must be different from all models.engine_port values",
                    mux_listen_port
                )));
            }

            if let Some(host) = &self.sglangmux_host {
                if host.trim().is_empty() {
                    return Err(ShenronError::Validation(
                        "sglangmux_host may not be empty".to_string(),
                    ));
                }
            }
            if let Some(log_dir) = &self.sglangmux_log_dir {
                if log_dir.trim().is_empty() {
                    return Err(ShenronError::Validation(
                        "sglangmux_log_dir may not be empty".to_string(),
                    ));
                }
            }
        }
        Ok(())
    }

    fn resolved_engine_args(&self) -> Vec<YamlValue> {
        if let Some(args) = &self.engine_args {
            return args.clone();
        }
        match self.engine {
            Engine::Vllm => self
                .legacy_vllm_args
                .clone()
                .unwrap_or_else(default_vllm_args),
            Engine::Sglang => self
                .legacy_sglang_args
                .clone()
                .or_else(|| self.legacy_vllm_args.clone())
                .unwrap_or_default(),
        }
    }

    fn scouter_collector_url(&self) -> String {
        self.scouter_collector_url
            .clone()
            .unwrap_or_else(|| format!("http://{}:4321", self.scouter_collector_instance))
    }

    fn sglangmux_listen_port(&self) -> u16 {
        self.sglangmux_listen_port
            .unwrap_or(DEFAULT_SGLANGMUX_LISTEN_PORT)
    }

    /// Compute the effective upstream port for the Caddy reverse proxy.
    ///
    /// When models are configured, the engine/mux port is used directly:
    ///   - single model  → that model's `engine_port`
    ///   - multiple models → `sglangmux_listen_port`
    ///
    /// When no models are configured, falls back to `engine_port`.
    fn effective_upstream_port(&self) -> u16 {
        if self.models.len() > 1 {
            return self.sglangmux_listen_port();
        }
        if let Some(model) = self.models.first() {
            return model.engine_port.unwrap_or(self.engine_port);
        }
        self.engine_port
    }

    fn resolve_models(&self) -> Result<Vec<ResolvedModelConfig>, ShenronError> {
        if self.models.is_empty() {
            return Ok(Vec::new());
        }

        self.models
            .iter()
            .enumerate()
            .map(|(idx, model)| {
                let model_index = idx + 1;
                if model.model_name.trim().is_empty() {
                    return Err(ShenronError::Validation(format!(
                        "models[{}].model_name must be set",
                        model_index
                    )));
                }

                let engine_port = model.engine_port.ok_or_else(|| {
                    ShenronError::Validation(format!(
                        "models[{}].engine_port must be set",
                        model_index
                    ))
                })?;

                Ok(ResolvedModelConfig {
                    model_name: model.model_name.clone(),
                    api_key: model
                        .api_key
                        .clone()
                        .unwrap_or_else(|| self.api_key.clone()),
                    engine_port,
                    engine_host: model
                        .engine_host
                        .clone()
                        .unwrap_or_else(|| self.engine_host.clone()),
                    engine_args: model
                        .engine_args
                        .clone()
                        .unwrap_or_else(|| self.resolved_engine_args()),
                    engine_envs: {
                        let env = model
                            .engine_envs
                            .clone()
                            .unwrap_or_else(|| self.engine_env.clone());
                        parse_env_pairs(&env, &format!("models[{}].engine_envs", model_index))?;
                        env
                    },
                    engine_use_cuda_ipc_transport: model
                        .engine_use_cuda_ipc_transport
                        .unwrap_or(self.engine_use_cuda_ipc_transport),
                })
            })
            .collect()
    }
}

pub fn generate_from_target(
    target: &Path,
    output_dir: Option<&Path>,
) -> Result<Vec<PathBuf>, ShenronError> {
    let config_path = resolve_config_path(target)?;
    let config = load_config(&config_path)?;

    let out_dir = output_dir
        .map(Path::to_path_buf)
        .unwrap_or_else(|| default_output_dir(target, &config_path));

    generate(&config, &out_dir)
}

fn default_output_dir(target: &Path, config_path: &Path) -> PathBuf {
    if target.is_dir() {
        target.to_path_buf()
    } else {
        config_path
            .parent()
            .map(Path::to_path_buf)
            .unwrap_or_else(|| PathBuf::from("."))
    }
}

pub fn resolve_config_path(target: &Path) -> Result<PathBuf, ShenronError> {
    if !target.exists() {
        return Err(ShenronError::MissingTarget(target.to_path_buf()));
    }

    if target.is_file() {
        return Ok(target.to_path_buf());
    }

    let mut configs = fs::read_dir(target)
        .map_err(|source| ShenronError::ReadFile {
            path: target.to_path_buf(),
            source,
        })?
        .filter_map(Result::ok)
        .map(|e| e.path())
        .filter(|p| p.is_file())
        .filter(|p| is_config_candidate(p))
        .collect::<Vec<_>>();

    configs.sort();

    if configs.is_empty() {
        return Err(ShenronError::MissingConfig(target.to_path_buf()));
    }

    if configs.len() == 1 {
        return Ok(configs.remove(0));
    }

    for default_name in ["shenron.yml", "shenron.yaml"] {
        let candidate = target.join(default_name);
        if candidate.exists() {
            return Ok(candidate);
        }
    }

    let files = configs
        .iter()
        .filter_map(|p| p.file_name().and_then(OsStr::to_str))
        .collect::<Vec<_>>()
        .join(", ");

    Err(ShenronError::AmbiguousConfig {
        dir: target.to_path_buf(),
        files,
    })
}

fn is_config_candidate(path: &Path) -> bool {
    let is_yaml = path
        .extension()
        .and_then(OsStr::to_str)
        .map(|ext| matches!(ext, "yml" | "yaml"))
        .unwrap_or(false);
    if !is_yaml {
        return false;
    }

    let name = path.file_name().and_then(OsStr::to_str).unwrap_or_default();
    !matches!(name, "docker-compose.yml" | "docker-compose.yaml")
}

pub fn load_config(path: &Path) -> Result<ShenronConfig, ShenronError> {
    let raw = fs::read_to_string(path).map_err(|source| ShenronError::ReadFile {
        path: path.to_path_buf(),
        source,
    })?;

    let config = serde_yaml::from_str::<ShenronConfig>(&raw).map_err(|source| {
        ShenronError::ParseConfig {
            path: path.to_path_buf(),
            source,
        }
    })?;

    config.validate()?;
    Ok(config)
}

fn load_endpoint_metadata(
    output_dir: &Path,
) -> Result<Option<NodeEndpointMetadata>, ShenronError> {
    let metadata_path = output_dir.join(GENERATED_DIR).join("node_endpoint.json");
    if !metadata_path.exists() {
        return Ok(None);
    }

    let raw = fs::read_to_string(&metadata_path).map_err(|source| ShenronError::ReadFile {
        path: metadata_path.clone(),
        source,
    })?;

    let metadata: NodeEndpointMetadata =
        serde_json::from_str(&raw).map_err(|e| {
            ShenronError::Validation(format!(
                "invalid endpoint metadata in {}: {}",
                metadata_path.display(),
                e
            ))
        })?;

    Ok(Some(metadata))
}

pub fn generate(config: &ShenronConfig, output_dir: &Path) -> Result<Vec<PathBuf>, ShenronError> {
    config.validate()?;

    fs::create_dir_all(output_dir).map_err(|source| ShenronError::WriteFile {
        path: output_dir.to_path_buf(),
        source,
    })?;

    let generated_dir = output_dir.join(GENERATED_DIR);
    fs::create_dir_all(&generated_dir).map_err(|source| ShenronError::WriteFile {
        path: generated_dir.clone(),
        source,
    })?;

    let endpoint = load_endpoint_metadata(output_dir)?;
    let caddyfile_path = generated_dir.join("Caddyfile");
    let prometheus_path = generated_dir.join("prometheus.yml");
    let scouter_env_path = generated_dir.join("scouter_reporter.env");
    let compose_path = output_dir.join("docker-compose.yml");
    let mut generated = vec![
        compose_path.clone(),
        caddyfile_path.clone(),
        prometheus_path.clone(),
        scouter_env_path.clone(),
    ];

    if config.models.len() <= 1 {
        let engine_start_path = generated_dir.join("engine_start.sh");
        if let Some(model) = config.resolve_models()?.into_iter().next() {
            let mut single_model_config = config.clone();
            single_model_config.model_name = model.model_name.clone();
            single_model_config.api_key = model.api_key.clone();
            single_model_config.engine_port = model.engine_port;
            single_model_config.engine_host = model.engine_host.clone();
            single_model_config.engine_args = Some(model.engine_args.clone());
            single_model_config.legacy_vllm_args = None;
            single_model_config.legacy_sglang_args = None;
            single_model_config.engine_env = model.engine_envs.clone();
            single_model_config.engine_use_cuda_ipc_transport = model.engine_use_cuda_ipc_transport;
            single_model_config.models = Vec::new();

            write_atomic(
                &caddyfile_path,
                render_caddyfile(&single_model_config, endpoint.as_ref())?.as_bytes(),
                false,
            )?;
            write_atomic(
                &prometheus_path,
                render_prometheus_config(&[single_model_config.engine_port]).as_bytes(),
                false,
            )?;
            write_atomic(
                &scouter_env_path,
                render_scouter_reporter_env(config, &single_model_config.model_name).as_bytes(),
                false,
            )?;
            write_atomic(
                &engine_start_path,
                render_engine_start(&single_model_config)?.as_bytes(),
                true,
            )?;
        } else {
            write_atomic(
                &caddyfile_path,
                render_caddyfile(config, endpoint.as_ref())?.as_bytes(),
                false,
            )?;
            write_atomic(
                &prometheus_path,
                render_prometheus_config(&[config.engine_port]).as_bytes(),
                false,
            )?;
            write_atomic(
                &scouter_env_path,
                render_scouter_reporter_env(config, &config.model_name).as_bytes(),
                false,
            )?;
            write_atomic(
                &engine_start_path,
                render_engine_start(config)?.as_bytes(),
                true,
            )?;
        }
        write_atomic(
            &compose_path,
            render_compose(config, "/generated/engine_start.sh").as_bytes(),
            false,
        )?;
        generated.push(engine_start_path);
        return Ok(generated);
    }

    let models = config.resolve_models()?;
    let model_ports = models.iter().map(|m| m.engine_port).collect::<Vec<_>>();
    let first_model_name = models
        .first()
        .map(|m| m.model_name.as_str())
        .unwrap_or(config.model_name.as_str());

    write_atomic(
        &caddyfile_path,
        render_caddyfile(config, endpoint.as_ref())?.as_bytes(),
        false,
    )?;
    write_atomic(
        &prometheus_path,
        render_prometheus_config(&model_ports).as_bytes(),
        false,
    )?;
    write_atomic(
        &scouter_env_path,
        render_scouter_reporter_env(config, first_model_name).as_bytes(),
        false,
    )?;

    let mut mux_inputs = Vec::with_capacity(models.len());
    for (idx, model) in models.iter().enumerate() {
        let script_name = format!("engine_start_{}.sh", idx + 1);
        let script_path = generated_dir.join(&script_name);
        let script_mount_path = format!("/generated/{}", script_name);

        write_atomic(
            &script_path,
            render_sglang_start_for_model(model)?.as_bytes(),
            true,
        )?;
        mux_inputs.push(script_mount_path);
        generated.push(script_path);
    }

    let mux_start_path = generated_dir.join("sglangmux_start.sh");
    write_atomic(
        &mux_start_path,
        render_sglangmux_start(config, &mux_inputs)?.as_bytes(),
        true,
    )?;
    write_atomic(
        &compose_path,
        render_compose(config, "/generated/sglangmux_start.sh").as_bytes(),
        false,
    )?;
    generated.push(mux_start_path);

    Ok(generated)
}

fn write_atomic(path: &Path, content: &[u8], executable: bool) -> Result<(), ShenronError> {
    let parent = path.parent().unwrap_or_else(|| Path::new("."));

    let mut tmp = NamedTempFile::new_in(parent).map_err(|source| ShenronError::WriteFile {
        path: path.to_path_buf(),
        source,
    })?;

    tmp.write_all(content)
        .map_err(|source| ShenronError::WriteFile {
            path: path.to_path_buf(),
            source,
        })?;

    if executable {
        #[cfg(unix)]
        {
            let mut perms = tmp
                .as_file()
                .metadata()
                .map_err(|source| ShenronError::WriteFile {
                    path: path.to_path_buf(),
                    source,
                })?
                .permissions();
            perms.set_mode(0o755);
            tmp.as_file()
                .set_permissions(perms)
                .map_err(|source| ShenronError::WriteFile {
                    path: path.to_path_buf(),
                    source,
                })?;
        }
    }

    tmp.persist(path)
        .map_err(|persist| ShenronError::WriteFile {
            path: path.to_path_buf(),
            source: persist.error,
        })?;

    Ok(())
}

fn render_caddyfile(
    config: &ShenronConfig,
    endpoint: Option<&NodeEndpointMetadata>,
) -> Result<String, ShenronError> {
    let upstream_port = config.effective_upstream_port();
    let expected_value = if config.gateway_auth_header == "Authorization" {
        format!("Bearer {}", config.api_key)
    } else {
        config.api_key.clone()
    };

    let mut out = String::new();

    if let Some(ep) = endpoint {
        // Security guard: only allow FQDNs under NODES_BASE_DOMAIN.
        if !ep.fqdn.ends_with(&format!(".{}", NODES_BASE_DOMAIN)) {
            return Err(ShenronError::Validation(format!(
                "refusing to generate Caddyfile for FQDN {:?}: \
                 all operations are restricted to *.{}",
                ep.fqdn, NODES_BASE_DOMAIN,
            )));
        }

        // FQDN mode — automatic TLS via ACME
        let _ = writeln!(out, "{{");
        if let Some(email) = &config.caddy_acme_email {
            let _ = writeln!(out, "    email {}", email);
        }
        let _ = writeln!(out, "}}");
        let _ = writeln!(out);
        let _ = writeln!(out, "{} {{", ep.fqdn);
        let _ = writeln!(
            out,
            "    @unauthorized not header {} \"{}\"",
            config.gateway_auth_header, expected_value
        );
        let _ = writeln!(out, "    respond @unauthorized \"Unauthorized\" 401");
        let _ = writeln!(out);
        let _ = writeln!(
            out,
            "    reverse_proxy {}:{}",
            config.gateway_upstream_host, upstream_port
        );
        let _ = writeln!(out, "}}");
    } else {
        // Fallback HTTP-only mode
        let _ = writeln!(out, "{{");
        let _ = writeln!(out, "    auto_https off");
        let _ = writeln!(out, "}}");
        let _ = writeln!(out);
        let _ = writeln!(out, ":80 {{");
        let _ = writeln!(
            out,
            "    @unauthorized not header {} \"{}\"",
            config.gateway_auth_header, expected_value
        );
        let _ = writeln!(out, "    respond @unauthorized \"Unauthorized\" 401");
        let _ = writeln!(out);
        let _ = writeln!(
            out,
            "    reverse_proxy {}:{}",
            config.gateway_upstream_host, upstream_port
        );
        let _ = writeln!(out, "}}");
    }

    Ok(out)
}

fn render_prometheus_config(ports: &[u16]) -> String {
    let targets = ports
        .iter()
        .map(|port| format!("\"vllm:{}\"", port))
        .collect::<Vec<_>>()
        .join(", ");
    format!(
        "global:\n  scrape_interval: 15s\n\nscrape_configs:\n  - job_name: vllm\n    metrics_path: /metrics\n    static_configs:\n      - targets: [{}]\n",
        targets
    )
}

fn render_scouter_reporter_env(config: &ShenronConfig, model_name: &str) -> String {
    format!(
        "SCOUTER_MODE=reporter\nPROMETHEUS_URL=http://prometheus:9090\nCOLLECTOR_URL={}\nREPORTER_INTERVAL={}\nMODEL_NAME={}\nSCOUTER_INGEST_API_KEY={}\n",
        config.scouter_collector_url(),
        config.scouter_reporter_interval,
        model_name,
        config.scouter_ingest_api_key
    )
}

fn render_engine_start(config: &ShenronConfig) -> Result<String, ShenronError> {
    match config.engine {
        Engine::Vllm => render_vllm_start(config),
        Engine::Sglang => render_sglang_start(config),
    }
}

fn render_vllm_start(config: &ShenronConfig) -> Result<String, ShenronError> {
    let mut args = vec![
        "--model".to_string(),
        config.model_name.clone(),
        "--port".to_string(),
        config.engine_port.to_string(),
        "--host".to_string(),
        config.engine_host.clone(),
        "--tensor-parallel-size".to_string(),
        config.tensor_parallel_size.to_string(),
    ];

    args.extend(
        config
            .resolved_engine_args()
            .iter()
            .map(yaml_arg_to_string)
            .collect::<Result<Vec<_>, _>>()?,
    );

    let joined = args
        .iter()
        .map(|a| quote_arg(a))
        .collect::<Result<Vec<_>, _>>()?
        .join(" ");

    let mut script = String::from("#!/usr/bin/env bash\nset -euo pipefail\n\n");
    script.push_str(&render_env_exports(&config.engine_env, "engine_env")?);
    script.push_str(&format!("exec vllm serve {}\n", joined));

    Ok(script)
}

fn render_sglang_start(config: &ShenronConfig) -> Result<String, ShenronError> {
    let mut args = vec![
        "--model".to_string(),
        config.model_name.clone(),
        "--port".to_string(),
        config.engine_port.to_string(),
        "--host".to_string(),
        config.engine_host.clone(),
    ];
    args.extend(
        config
            .resolved_engine_args()
            .iter()
            .map(yaml_arg_to_string)
            .collect::<Result<Vec<_>, _>>()?,
    );

    let joined = args
        .iter()
        .map(|a| quote_arg(a))
        .collect::<Result<Vec<_>, _>>()?
        .join(" ");

    let mut script = String::from("#!/usr/bin/env bash\nset -euo pipefail\n\n");
    if config.engine_use_cuda_ipc_transport {
        script.push_str("export SGLANG_USE_CUDA_IPC_TRANSPORT=1\n");
    }
    script.push_str(&render_env_exports(&config.engine_env, "engine_env")?);
    script.push_str(&format!("exec python -m sglang.launch_server {}\n", joined));

    Ok(script)
}

fn render_sglang_start_for_model(model: &ResolvedModelConfig) -> Result<String, ShenronError> {
    let mut args = vec![
        "--model".to_string(),
        model.model_name.clone(),
        "--port".to_string(),
        model.engine_port.to_string(),
        "--host".to_string(),
        model.engine_host.clone(),
    ];
    args.extend(
        model
            .engine_args
            .iter()
            .map(yaml_arg_to_string)
            .collect::<Result<Vec<_>, _>>()?,
    );

    let joined = args
        .iter()
        .map(|a| quote_arg(a))
        .collect::<Result<Vec<_>, _>>()?
        .join(" ");

    let mut script = String::from("#!/usr/bin/env bash\nset -euo pipefail\n\n");
    if model.engine_use_cuda_ipc_transport {
        script.push_str("export SGLANG_USE_CUDA_IPC_TRANSPORT=1\n");
    }
    script.push_str(&render_env_exports(
        &model.engine_envs,
        "models[].engine_envs",
    )?);
    script.push_str(&format!("exec python -m sglang.launch_server {}\n", joined));

    Ok(script)
}

fn render_sglangmux_start(
    config: &ShenronConfig,
    script_paths: &[String],
) -> Result<String, ShenronError> {
    let mut args = vec![
        "sglangmux".to_string(),
        "--listen-port".to_string(),
        config.sglangmux_listen_port().to_string(),
    ];

    if let Some(host) = &config.sglangmux_host {
        args.push("--host".to_string());
        args.push(host.clone());
    }
    if let Some(timeout) = config.sglangmux_upstream_timeout_secs {
        args.push("--upstream-timeout-secs".to_string());
        args.push(timeout.to_string());
    }
    if let Some(timeout) = config.sglangmux_model_ready_timeout_secs {
        args.push("--model-ready-timeout-secs".to_string());
        args.push(timeout.to_string());
    }
    if let Some(timeout) = config.sglangmux_model_switch_timeout_secs {
        args.push("--model-switch-timeout-secs".to_string());
        args.push(timeout.to_string());
    }
    if let Some(log_dir) = &config.sglangmux_log_dir {
        args.push("--log-dir".to_string());
        args.push(log_dir.clone());
    }

    args.extend(script_paths.iter().cloned());

    let joined = args
        .iter()
        .map(|a| quote_arg(a))
        .collect::<Result<Vec<_>, _>>()?
        .join(" ");

    Ok(format!(
        "#!/usr/bin/env bash\nset -euo pipefail\n\nexec {}\n",
        joined
    ))
}

fn quote_arg(value: &str) -> Result<String, ShenronError> {
    shlex::try_quote(value)
        .map(|quoted| quoted.to_string())
        .map_err(|_| ShenronError::Quote(value.to_string()))
}

fn yaml_arg_to_string(value: &YamlValue) -> Result<String, ShenronError> {
    match value {
        YamlValue::Null => Err(ShenronError::Validation(
            "args entries may not be null".to_string(),
        )),
        YamlValue::Bool(value) => Ok(value.to_string()),
        YamlValue::Number(value) => Ok(value.to_string()),
        YamlValue::String(value) => Ok(value.clone()),
        YamlValue::Sequence(_) | YamlValue::Mapping(_) => {
            serde_json::to_string(value).map_err(ShenronError::Json)
        }
        YamlValue::Tagged(tagged) => yaml_arg_to_string(&tagged.value),
    }
}

fn parse_env_pairs(
    entries: &[YamlValue],
    field_name: &str,
) -> Result<Vec<(String, String)>, ShenronError> {
    if entries.len() % 2 != 0 {
        return Err(ShenronError::Validation(format!(
            "{} must contain KEY VALUE pairs (even number of entries)",
            field_name
        )));
    }

    let mut pairs = Vec::with_capacity(entries.len() / 2);
    for idx in (0..entries.len()).step_by(2) {
        let pair_idx = (idx / 2) + 1;
        let key = yaml_arg_to_string(&entries[idx])?;
        let value = yaml_arg_to_string(&entries[idx + 1])?;

        if key.trim().is_empty() {
            return Err(ShenronError::Validation(format!(
                "{} pair {} has an empty variable name",
                field_name, pair_idx
            )));
        }
        if key.contains('=') {
            return Err(ShenronError::Validation(format!(
                "{} pair {} has invalid variable name '{}': '=' is not allowed",
                field_name, pair_idx, key
            )));
        }
        let mut chars = key.chars();
        let Some(first_char) = chars.next() else {
            return Err(ShenronError::Validation(format!(
                "{} pair {} has an empty variable name",
                field_name, pair_idx
            )));
        };
        if !(first_char == '_' || first_char.is_ascii_alphabetic())
            || chars.any(|c| !(c == '_' || c.is_ascii_alphanumeric()))
        {
            return Err(ShenronError::Validation(format!(
                "{} pair {} has invalid variable name '{}'",
                field_name, pair_idx, key
            )));
        }
        pairs.push((key, value));
    }

    Ok(pairs)
}

fn render_env_exports(entries: &[YamlValue], field_name: &str) -> Result<String, ShenronError> {
    let pairs = parse_env_pairs(entries, field_name)?;
    render_env_exports_from_pairs(&pairs, &[])
}

fn render_env_exports_from_pairs(
    pairs: &[(String, String)],
    excluded_keys: &[&str],
) -> Result<String, ShenronError> {
    let mut out = String::new();
    for (key, value) in pairs {
        if excluded_keys.iter().any(|excluded| key == excluded) {
            continue;
        }
        let quoted = quote_arg(&value)?;
        let _ = writeln!(out, "export {}={}", key, quoted);
    }
    Ok(out)
}

fn render_compose(config: &ShenronConfig, engine_start_script: &str) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "name: shenron-cu{}", config.cuda_version);
    let _ = writeln!(out);
    let _ = writeln!(out, "services:");
    let _ = writeln!(out, "  vllm:");
    let _ = writeln!(
        out,
        "    image: ghcr.io/doublewordai/shenron:{}-cu{}",
        config.shenron_version, config.cuda_version
    );
    let _ = writeln!(out, "    restart: unless-stopped");
    let _ = writeln!(out, "    ulimits:");
    let _ = writeln!(out, "      nofile:");
    let _ = writeln!(out, "        soft: 65535");
    let _ = writeln!(out, "        hard: 524288");
    let _ = writeln!(
        out,
        "    command: [\"bash\",\"-lc\",\"exec bash {}\"]",
        engine_start_script
    );
    let _ = writeln!(out, "    volumes:");
    let _ = writeln!(
        out,
        "      - $HOME/.cache/huggingface:/root/.cache/huggingface"
    );
    let _ = writeln!(out, "      - ./.generated:/generated");
    let _ = writeln!(out, "    deploy:");
    let _ = writeln!(out, "      resources:");
    let _ = writeln!(out, "        reservations:");
    let _ = writeln!(out, "          devices:");
    let _ = writeln!(out, "            - capabilities: [gpu]");
    let _ = writeln!(out);

    let _ = writeln!(out, "  caddy:");
    let _ = writeln!(out, "    image: caddy:2-alpine");
    let _ = writeln!(out, "    restart: unless-stopped");
    let _ = writeln!(out, "    depends_on:");
    let _ = writeln!(out, "      - vllm");
    let _ = writeln!(out, "    volumes:");
    let _ = writeln!(out, "      - ./.generated/Caddyfile:/etc/caddy/Caddyfile:ro");
    let _ = writeln!(out, "      - caddy_data:/data");
    let _ = writeln!(out, "    ports:");
    let _ = writeln!(out, "      - \"80:80\"");
    let _ = writeln!(out, "      - \"443:443\"");
    let _ = writeln!(out);

    let _ = writeln!(out, "  prometheus:");
    let _ = writeln!(
        out,
        "    image: prom/prometheus:{}",
        config.prometheus_version
    );
    let _ = writeln!(out, "    depends_on:");
    let _ = writeln!(out, "      - vllm");
    let _ = writeln!(out, "    volumes:");
    let _ = writeln!(
        out,
        "      - ./.generated/prometheus.yml:/etc/prometheus/prometheus.yml"
    );
    let _ = writeln!(out, "    ports:");
    let _ = writeln!(
        out,
        "      - \"{}:{}\"",
        config.prometheus_port, config.prometheus_port
    );
    let _ = writeln!(out);

    let _ = writeln!(out, "  scouter-reporter:");
    let _ = writeln!(
        out,
        "    image: ghcr.io/doublewordai/scouter:{}",
        config.scouter_version
    );
    let _ = writeln!(out, "    restart: unless-stopped");
    let _ = writeln!(out, "    depends_on:");
    let _ = writeln!(out, "      - prometheus");
    let _ = writeln!(out, "    env_file:");
    let _ = writeln!(out, "      - ./.generated/scouter_reporter.env");
    let _ = writeln!(out, "    extra_hosts:");
    let _ = writeln!(out, "      - \"host.docker.internal:host-gateway\"");
    let _ = writeln!(out);
    let _ = writeln!(out, "volumes:");
    let _ = writeln!(out, "  caddy_data:");

    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn resolves_single_config_file_from_directory() {
        let dir = tempdir().expect("tempdir");
        let cfg = dir.path().join("Qwen06B-cu126-TP1.yml");
        fs::write(&cfg, "model_name: Qwen/Qwen3-0.6B\n").expect("write config");

        let resolved = resolve_config_path(dir.path()).expect("resolve");
        assert_eq!(resolved, cfg);
    }

    #[test]
    fn generates_expected_files() {
        let dir = tempdir().expect("tempdir");
        let output = generate(&ShenronConfig::default(), dir.path()).expect("generate");

        assert_eq!(output.len(), 5);
        assert!(dir.path().join("docker-compose.yml").exists());
        assert!(dir.path().join(".generated/Caddyfile").exists());
        assert!(dir.path().join(".generated/prometheus.yml").exists());
        assert!(dir.path().join(".generated/scouter_reporter.env").exists());
        assert!(dir.path().join(".generated/engine_start.sh").exists());
    }

    #[test]
    fn directory_resolution_ignores_generated_compose_file() {
        let dir = tempdir().expect("tempdir");
        let cfg = dir.path().join("Qwen06B-cu126-TP1.yml");
        fs::write(&cfg, "model_name: Qwen/Qwen3-0.6B\n").expect("write config");
        fs::write(dir.path().join("docker-compose.yml"), "name: test\n").expect("write compose");

        let resolved = resolve_config_path(dir.path()).expect("resolve");
        assert_eq!(resolved, cfg);
    }

    #[test]
    fn compose_contains_concrete_image_tags() {
        let config = ShenronConfig::default();
        let compose = render_compose(&config, "/generated/engine_start.sh");
        assert!(compose.contains("ghcr.io/doublewordai/shenron:latest-cu126"));
        assert!(compose.contains("caddy:2-alpine"));
        assert!(!compose.contains("onwards"));
        assert!(!compose.contains("${SHENRON_VERSION}"));
        assert!(compose.contains("./.generated:/generated"));
        assert!(compose.contains("./.generated/prometheus.yml:/etc/prometheus/prometheus.yml"));
        assert!(!compose.contains(":/generated:ro"));
        assert!(!compose.contains("prometheus.yml:ro"));
    }

    #[test]
    fn vllm_start_includes_expert_parallel() {
        let mut config = ShenronConfig::default();
        config.engine_args = Some(vec![YamlValue::String(
            "--enable-expert-parallel".to_string(),
        )]);
        let script = render_vllm_start(&config).expect("render");
        assert!(script.contains("--enable-expert-parallel"));
    }

    #[test]
    fn sglang_start_uses_sglang_args() {
        let mut config = ShenronConfig::default();
        config.engine = Engine::Sglang;
        config.engine_args = Some(vec![
            YamlValue::String("--tp".to_string()),
            serde_yaml::to_value(2).expect("tp value"),
            YamlValue::String("--enable-dp-attention".to_string()),
        ]);
        let script = render_engine_start(&config).expect("render");
        assert!(script.contains("sglang.launch_server"));
        assert!(script.contains("--tp"));
        assert!(script.contains("--enable-dp-attention"));
    }

    #[test]
    fn sglang_start_can_export_cuda_ipc_transport() {
        let mut config = ShenronConfig::default();
        config.engine = Engine::Sglang;
        config.engine_use_cuda_ipc_transport = true;
        let script = render_engine_start(&config).expect("render");
        assert!(script.contains("export SGLANG_USE_CUDA_IPC_TRANSPORT=1"));
    }

    #[test]
    fn sglang_start_can_export_custom_env_vars() {
        let mut config = ShenronConfig::default();
        config.engine = Engine::Sglang;
        config.engine_env = vec![
            YamlValue::String("VLLM_ENABLE_RESPONSES_API_STORE".to_string()),
            serde_yaml::to_value(-1).expect("env value"),
            YamlValue::String("SGLANG_CUSTOM_JSON".to_string()),
            serde_yaml::to_value(json!({"mode": "strict"})).expect("json env value"),
        ];

        let script = render_engine_start(&config).expect("render");
        assert!(script.contains("export VLLM_ENABLE_RESPONSES_API_STORE=-1"));
        assert!(script.contains("export SGLANG_CUSTOM_JSON="));
        assert!(script.contains("{\"mode\":\"strict\"}"));
    }

    #[test]
    fn sglang_env_requires_even_key_value_entries() {
        let mut config = ShenronConfig::default();
        config.engine_env = vec![YamlValue::String("ONLY_KEY".to_string())];

        let err = config.validate().expect_err("expected validation failure");
        assert!(err.to_string().contains("KEY VALUE pairs"));
    }

    #[test]
    fn model_engine_env_legacy_alias_maps_to_engine_envs() {
        let yaml = r#"
engine: sglang
models:
- model_name: Qwen/Qwen3-0.6B
  engine_port: 8100
  engine_env: [VLLM_ENABLE_RESPONSES_API_STORE, -1]
"#;
        let config: ShenronConfig = serde_yaml::from_str(yaml).expect("parse config");
        let models = config.resolve_models().expect("resolve models");
        let script = render_sglang_start_for_model(&models[0]).expect("render script");
        assert!(script.contains("export VLLM_ENABLE_RESPONSES_API_STORE=-1"));
    }

    #[test]
    fn legacy_vllm_and_sglang_args_prefer_sglang_args_for_sglang_engine() {
        let yaml = r#"
model_name: Qwen/Qwen3-0.6B
engine: sglang
vllm_args: [--ignored-legacy-vllm-arg]
sglang_args: [--tp, 2]
"#;
        let config: ShenronConfig = serde_yaml::from_str(yaml).expect("parse config");
        let script = render_engine_start(&config).expect("render");
        assert!(script.contains("--tp"));
        assert!(script.contains("2"));
        assert!(!script.contains("--ignored-legacy-vllm-arg"));
    }

    #[test]
    fn legacy_vllm_args_are_used_for_vllm_engine() {
        let yaml = r#"
model_name: Qwen/Qwen3-0.6B
engine: vllm
vllm_args: [--enable-expert-parallel]
"#;
        let config: ShenronConfig = serde_yaml::from_str(yaml).expect("parse config");
        let script = render_engine_start(&config).expect("render");
        assert!(script.contains("--enable-expert-parallel"));
    }

    #[test]
    fn vllm_start_only_exports_configured_engine_env_vars() {
        let config = ShenronConfig::default();
        let script = render_vllm_start(&config).expect("render");
        assert!(!script.contains("export VLLM_ENABLE_RESPONSES_API_STORE="));
        assert!(!script.contains("export VLLM_FLASHINFER_MOE_BACKEND="));
    }

    #[test]
    fn vllm_start_can_export_responses_and_flashinfer_from_engine_env() {
        let mut config = ShenronConfig::default();
        config.engine_env = vec![
            YamlValue::String("VLLM_FLASHINFER_MOE_BACKEND".to_string()),
            YamlValue::String("throughput".to_string()),
            YamlValue::String("VLLM_ENABLE_RESPONSES_API_STORE".to_string()),
            serde_yaml::to_value(1).expect("env value"),
        ];
        let script = render_vllm_start(&config).expect("render");
        assert!(script.contains("export VLLM_FLASHINFER_MOE_BACKEND=throughput"));
        assert!(script.contains("export VLLM_ENABLE_RESPONSES_API_STORE=1"));
    }

    #[test]
    fn config_with_models_requires_unique_model_name() {
        let mut config = ShenronConfig::default();
        config.engine = Engine::Sglang;
        config.models = vec![
            ModelConfig {
                model_name: "Qwen/Qwen3-0.6B".to_string(),
                engine_port: Some(8001),
                ..ModelConfig::default()
            },
            ModelConfig {
                model_name: "Qwen/Qwen3-0.6B".to_string(),
                engine_port: Some(8002),
                ..ModelConfig::default()
            },
        ];

        let err = config.validate().expect_err("expected validation failure");
        assert!(err.to_string().contains("duplicate models.model_name"));
    }

    #[test]
    fn config_with_models_requires_unique_port() {
        let mut config = ShenronConfig::default();
        config.engine = Engine::Sglang;
        config.models = vec![
            ModelConfig {
                model_name: "Qwen/Qwen3-0.6B".to_string(),
                engine_port: Some(8001),
                ..ModelConfig::default()
            },
            ModelConfig {
                model_name: "Qwen/Qwen3-30B-A3B".to_string(),
                engine_port: Some(8001),
                ..ModelConfig::default()
            },
        ];

        let err = config.validate().expect_err("expected validation failure");
        assert!(err.to_string().contains("duplicate models.engine_port"));
    }

    #[test]
    fn generates_mux_mode_artifacts() {
        let dir = tempdir().expect("tempdir");
        let mut config = ShenronConfig::default();
        config.engine = Engine::Sglang;
        config.sglangmux_listen_port = Some(8100);
        config.models = vec![
            ModelConfig {
                model_name: "Qwen/Qwen3-0.6B".to_string(),
                api_key: Some("sk-a".to_string()),
                engine_port: Some(8001),
                engine_args: Some(vec![
                    YamlValue::String("--tp".to_string()),
                    serde_yaml::to_value(1).expect("tp value"),
                ]),
                ..ModelConfig::default()
            },
            ModelConfig {
                model_name: "Qwen/Qwen3-30B-A3B".to_string(),
                api_key: Some("sk-b".to_string()),
                engine_port: Some(8002),
                engine_use_cuda_ipc_transport: Some(true),
                engine_args: Some(vec![
                    YamlValue::String("--tp".to_string()),
                    serde_yaml::to_value(2).expect("tp value"),
                ]),
                ..ModelConfig::default()
            },
        ];

        let output = generate(&config, dir.path()).expect("generate");
        assert_eq!(output.len(), 7);
        assert!(dir.path().join("docker-compose.yml").exists());
        assert!(dir.path().join(".generated/Caddyfile").exists());
        assert!(dir.path().join(".generated/prometheus.yml").exists());
        assert!(dir.path().join(".generated/scouter_reporter.env").exists());
        assert!(dir.path().join(".generated/engine_start_1.sh").exists());
        assert!(dir.path().join(".generated/engine_start_2.sh").exists());
        assert!(dir.path().join(".generated/sglangmux_start.sh").exists());

        let mux_script = fs::read_to_string(dir.path().join(".generated/sglangmux_start.sh"))
            .expect("read mux script");
        assert!(mux_script.contains("sglangmux"));
        assert!(mux_script.contains("--listen-port"));
        assert!(mux_script.contains("/generated/engine_start_1.sh"));
        assert!(mux_script.contains("/generated/engine_start_2.sh"));

        let caddyfile = fs::read_to_string(dir.path().join(".generated/Caddyfile"))
            .expect("read caddyfile");
        assert!(caddyfile.contains("reverse_proxy vllm:8100"));
    }

    #[test]
    fn single_model_in_models_generates_single_engine_entrypoint() {
        let dir = tempdir().expect("tempdir");
        let mut config = ShenronConfig::default();
        config.engine = Engine::Sglang;
        config.models = vec![ModelConfig {
            model_name: "Qwen/Qwen3-0.6B".to_string(),
            api_key: Some("sk-a".to_string()),
            engine_port: Some(8100),
            engine_use_cuda_ipc_transport: Some(true),
            engine_envs: Some(vec![
                YamlValue::String("VLLM_ENABLE_RESPONSES_API_STORE".to_string()),
                serde_yaml::to_value(-1).expect("env value"),
            ]),
            engine_args: Some(vec![
                YamlValue::String("--tp".to_string()),
                serde_yaml::to_value(2).expect("tp value"),
            ]),
            ..ModelConfig::default()
        }];

        let output = generate(&config, dir.path()).expect("generate");
        assert_eq!(output.len(), 5);
        assert!(dir.path().join("docker-compose.yml").exists());
        assert!(dir.path().join(".generated/Caddyfile").exists());
        assert!(dir.path().join(".generated/prometheus.yml").exists());
        assert!(dir.path().join(".generated/scouter_reporter.env").exists());
        assert!(dir.path().join(".generated/engine_start.sh").exists());
        assert!(!dir.path().join(".generated/engine_start_1.sh").exists());
        assert!(!dir.path().join(".generated/sglangmux_start.sh").exists());

        let engine =
            fs::read_to_string(dir.path().join(".generated/engine_start.sh")).expect("read engine");
        assert!(engine.contains("sglang.launch_server"));
        assert!(engine.contains("Qwen/Qwen3-0.6B"));
        assert!(engine.contains("--port"));
        assert!(engine.contains("8100"));
        assert!(engine.contains("export SGLANG_USE_CUDA_IPC_TRANSPORT=1"));
        assert!(engine.contains("export VLLM_ENABLE_RESPONSES_API_STORE=-1"));

        let caddyfile = fs::read_to_string(dir.path().join(".generated/Caddyfile"))
            .expect("read caddyfile");
        assert!(caddyfile.contains("Bearer sk-a"));
        assert!(caddyfile.contains("reverse_proxy vllm:8100"));

        let scouter = fs::read_to_string(dir.path().join(".generated/scouter_reporter.env"))
            .expect("read scouter env");
        assert!(scouter.contains("MODEL_NAME=Qwen/Qwen3-0.6B"));

        let compose =
            fs::read_to_string(dir.path().join("docker-compose.yml")).expect("read compose");
        assert!(compose.contains("/generated/engine_start.sh"));
        assert!(!compose.contains("/generated/sglangmux_start.sh"));
    }

    #[test]
    fn caddyfile_http_only_mode_when_no_endpoint() {
        let mut config = ShenronConfig::default();
        config.api_key = "sk-testkey".to_string();
        let cf = render_caddyfile(&config, None).expect("render caddyfile");
        assert!(cf.contains("auto_https off"));
        assert!(cf.contains(":80 {"));
        assert!(cf.contains("reverse_proxy vllm:8000"));
    }

    #[test]
    fn caddyfile_fqdn_mode_when_endpoint_present() {
        let mut config = ShenronConfig::default();
        config.api_key = "sk-testkey".to_string();
        config.caddy_acme_email = Some("admin@example.com".to_string());
        let endpoint = NodeEndpointMetadata {
            fqdn: "node1.nodes.doubleword.ai".to_string(),
            subdomain_label: "node1".to_string(),
            base_domain: NODES_BASE_DOMAIN.to_string(),
            public_ip: "1.2.3.4".to_string(),
            cloudflare_zone_id: "zone123".to_string(),
            cloudflare_record_id: "rec123".to_string(),
            created_at: "2025-01-01T00:00:00Z".to_string(),
        };
        let cf = render_caddyfile(&config, Some(&endpoint)).expect("render caddyfile");
        assert!(!cf.contains("auto_https off"));
        assert!(cf.contains("node1.nodes.doubleword.ai {"));
        assert!(cf.contains("email admin@example.com"));
        assert!(cf.contains("Bearer sk-testkey"));
        assert!(cf.contains("reverse_proxy vllm:8000"));
    }

    #[test]
    fn caddyfile_fqdn_mode_omits_email_when_not_set() {
        let config = ShenronConfig::default();
        let endpoint = NodeEndpointMetadata {
            fqdn: "node1.nodes.doubleword.ai".to_string(),
            subdomain_label: "node1".to_string(),
            base_domain: NODES_BASE_DOMAIN.to_string(),
            public_ip: "1.2.3.4".to_string(),
            cloudflare_zone_id: "zone123".to_string(),
            cloudflare_record_id: "rec123".to_string(),
            created_at: "2025-01-01T00:00:00Z".to_string(),
        };
        let cf = render_caddyfile(&config, Some(&endpoint)).expect("render caddyfile");
        assert!(cf.contains("node1.nodes.doubleword.ai {"));
        assert!(!cf.contains("email"));
    }

    #[test]
    fn caddyfile_custom_auth_header_uses_bare_key() {
        let mut config = ShenronConfig::default();
        config.api_key = "my-token".to_string();
        config.gateway_auth_header = "X-API-Key".to_string();
        let cf = render_caddyfile(&config, None).expect("render caddyfile");
        assert!(cf.contains("not header X-API-Key \"my-token\""));
        assert!(!cf.contains("Bearer"));
    }

    #[test]
    fn gateway_validation_rejects_empty_upstream_host() {
        let mut config = ShenronConfig::default();
        config.gateway_upstream_host = String::new();
        let err = config.validate().expect_err("expected validation failure");
        assert!(err.to_string().contains("gateway_upstream_host must not be empty"));
    }

    #[test]
    fn gateway_validation_rejects_empty_auth_header() {
        let mut config = ShenronConfig::default();
        config.gateway_auth_header = String::new();
        let err = config.validate().expect_err("expected validation failure");
        assert!(err.to_string().contains("gateway_auth_header must not be empty"));
    }

    #[test]
    fn load_endpoint_metadata_returns_none_when_absent() {
        let dir = tempdir().expect("tempdir");
        let result = load_endpoint_metadata(dir.path()).expect("load");
        assert!(result.is_none());
    }

    #[test]
    fn load_endpoint_metadata_returns_metadata_when_present() {
        let dir = tempdir().expect("tempdir");
        let gen_dir = dir.path().join(".generated");
        fs::create_dir_all(&gen_dir).expect("create dir");
        let metadata = NodeEndpointMetadata {
            fqdn: "node1.example.com".to_string(),
            subdomain_label: "node1".to_string(),
            base_domain: "example.com".to_string(),
            public_ip: "1.2.3.4".to_string(),
            cloudflare_zone_id: "zone123".to_string(),
            cloudflare_record_id: "rec123".to_string(),
            created_at: "2025-01-01T00:00:00Z".to_string(),
        };
        let json = serde_json::to_string(&metadata).expect("serialize");
        fs::write(gen_dir.join("node_endpoint.json"), json).expect("write");
        let loaded = load_endpoint_metadata(dir.path())
            .expect("load")
            .expect("Some");
        assert_eq!(loaded.fqdn, "node1.example.com");
        assert_eq!(loaded.public_ip, "1.2.3.4");
    }

    #[test]
    fn generate_with_endpoint_creates_fqdn_caddyfile() {
        let dir = tempdir().expect("tempdir");
        let gen_dir = dir.path().join(".generated");
        fs::create_dir_all(&gen_dir).expect("create dir");
        let metadata = NodeEndpointMetadata {
            fqdn: "node1.nodes.doubleword.ai".to_string(),
            subdomain_label: "node1".to_string(),
            base_domain: NODES_BASE_DOMAIN.to_string(),
            public_ip: "1.2.3.4".to_string(),
            cloudflare_zone_id: "zone123".to_string(),
            cloudflare_record_id: "rec123".to_string(),
            created_at: "2025-01-01T00:00:00Z".to_string(),
        };
        let json = serde_json::to_string(&metadata).expect("serialize");
        fs::write(gen_dir.join("node_endpoint.json"), json).expect("write");
        generate(&ShenronConfig::default(), dir.path()).expect("generate");
        let cf =
            fs::read_to_string(dir.path().join(".generated/Caddyfile")).expect("read caddyfile");
        assert!(cf.contains("node1.nodes.doubleword.ai {"));
        assert!(!cf.contains("auto_https off"));
    }

    #[test]
    fn single_model_in_models_with_vllm_uses_vllm_startup() {
        let dir = tempdir().expect("tempdir");
        let mut config = ShenronConfig::default();
        config.engine = Engine::Vllm;
        config.models = vec![ModelConfig {
            model_name: "Qwen/Qwen3-0.6B".to_string(),
            engine_port: Some(8101),
            engine_args: Some(vec![YamlValue::String(
                "--enable-expert-parallel".to_string(),
            )]),
            ..ModelConfig::default()
        }];

        generate(&config, dir.path()).expect("generate");

        let engine =
            fs::read_to_string(dir.path().join(".generated/engine_start.sh")).expect("read engine");
        assert!(engine.contains("exec vllm serve"));
        assert!(engine.contains("--enable-expert-parallel"));
        assert!(!engine.contains("sglang.launch_server"));

        let caddyfile = fs::read_to_string(dir.path().join(".generated/Caddyfile"))
            .expect("read caddyfile");
        assert!(caddyfile.contains("reverse_proxy vllm:8101"));
    }
}
