//! Agent configuration: TOML file + environment overrides.
//!
//! Multi-instance is foundational, not bolted on: one agent supervises N game
//! instances on the host, each declared as an `[[instance]]` block. Connection
//! secrets may come from env so the config file can be world-readable-ish
//! while the token is not.

use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::{Path, PathBuf};

use crate::docker_compose::DockerComposeConfig;
use crate::rcon::RconConfig;
use crate::steamcmd::SteamcmdConfig;

/// Instance ids share the NATS subject namespace with host-level segments.
const RESERVED_INSTANCE_IDS: &[&str] = &["host", "cmd", "files", "update", "agent"];

pub const SUPPORTED_GAMES: &[&str] = &["rust", "conan", "soulmask", "dune"];

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
    pub agent: AgentSection,
    #[serde(default, rename = "instance")]
    pub instances: Vec<InstanceConfig>,
    #[serde(default)]
    pub prober: ProberSection,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentSection {
    pub license_id: Option<String>,
    pub nats_url: Option<String>,
    pub nats_token: Option<String>,
    /// NATS username for per-license auth. Defaults to license_id when a
    /// password is set but no user is given.
    pub nats_user: Option<String>,
    /// NATS password (the per-license token). When set, the agent authenticates
    /// with user+password instead of a bare token.
    pub nats_password: Option<String>,
    #[serde(default = "default_heartbeat_seconds")]
    pub heartbeat_seconds: u64,
    #[serde(default = "default_log_level")]
    pub log_level: String,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstanceConfig {
    /// Short slug, unique per license: becomes a NATS subject segment.
    pub id: String,
    /// One of SUPPORTED_GAMES.
    pub game: String,
    /// Install root for this instance on the host.
    pub root: PathBuf,
    /// Optional human label shown in the panel.
    #[serde(default)]
    pub label: Option<String>,
    /// Game server executable. Relative paths resolve against `root`.
    /// Absent = unmanaged instance (telemetry only, no process control).
    #[serde(default)]
    pub executable: Option<PathBuf>,
    /// Arguments as a proper list — no shell splitting, quoted values survive.
    #[serde(default)]
    pub args: Vec<String>,
    /// Working directory for the process. Defaults to the executable's directory.
    #[serde(default)]
    pub working_dir: Option<PathBuf>,
    /// RCON connection settings for this instance.  Absent = rcon unavailable.
    /// Protocol defaults to WebRcon for rust, Source for conan/soulmask.
    #[serde(default)]
    pub rcon: Option<RconConfig>,
    /// SteamCMD update settings.  Absent = defaults apply (steamcmd on PATH,
    /// validate = false).
    #[serde(default)]
    pub steamcmd: Option<SteamcmdConfig>,
    /// Docker-compose settings for container-managed games (Dune). Absent =
    /// defaults apply (compose file in the instance root, project = instance id).
    #[serde(default)]
    pub docker_compose: Option<DockerComposeConfig>,
}

impl InstanceConfig {
    /// Absolute executable path, if this instance is process-managed.
    pub fn resolved_executable(&self) -> Option<PathBuf> {
        self.executable.as_ref().map(|exe| {
            if exe.is_absolute() {
                exe.clone()
            } else {
                self.root.join(exe)
            }
        })
    }
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProberSection {
    #[serde(default = "default_probe_interval")]
    pub interval_seconds: u64,
    /// Extra TCP targets beyond the built-in defaults.
    #[serde(default, rename = "target")]
    pub targets: Vec<ProbeTargetConfig>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProbeTargetConfig {
    pub name: String,
    pub host: String,
    pub port: u16,
}

fn default_heartbeat_seconds() -> u64 {
    60
}

fn default_probe_interval() -> u64 {
    300
}

fn default_log_level() -> String {
    "info".to_string()
}

/// Fully-resolved settings after merging file + env. Everything required is
/// present and validated.
#[derive(Debug, Clone)]
pub struct Settings {
    pub license_id: String,
    pub nats_url: String,
    pub nats_token: Option<String>,
    pub nats_user: Option<String>,
    pub nats_password: Option<String>,
    pub heartbeat_seconds: u64,
    pub log_level: String,
    pub instances: Vec<InstanceConfig>,
    pub probe_interval_seconds: u64,
    pub probe_targets: Vec<ProbeTargetConfig>,
}

pub fn default_config_path() -> PathBuf {
    #[cfg(windows)]
    {
        PathBuf::from(r"C:\ProgramData\Corrosion\agent.toml")
    }
    #[cfg(not(windows))]
    {
        PathBuf::from("/etc/corrosion/agent.toml")
    }
}

pub fn load(path: &Path) -> Result<Settings> {
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("reading config file {}", path.display()))?;
    let file: ConfigFile = toml::from_str(&raw)
        .with_context(|| format!("parsing config file {}", path.display()))?;
    resolve(file)
}

/// Merge env overrides (env wins) and validate.
fn resolve(file: ConfigFile) -> Result<Settings> {
    let license_id = std::env::var("CORROSION_LICENSE_ID")
        .ok()
        .filter(|v| !v.is_empty())
        .or(file.agent.license_id)
        .context("license_id missing: set [agent].license_id or CORROSION_LICENSE_ID")?;

    let nats_url = std::env::var("CORROSION_NATS_URL")
        .ok()
        .filter(|v| !v.is_empty())
        .or(file.agent.nats_url)
        .context("nats_url missing: set [agent].nats_url or CORROSION_NATS_URL")?;

    let nats_token = std::env::var("CORROSION_NATS_TOKEN")
        .ok()
        .filter(|v| !v.is_empty())
        .or(file.agent.nats_token);

    let nats_user = std::env::var("CORROSION_NATS_USER")
        .ok()
        .filter(|v| !v.is_empty())
        .or(file.agent.nats_user);

    let nats_password = std::env::var("CORROSION_NATS_PASSWORD")
        .ok()
        .filter(|v| !v.is_empty())
        .or(file.agent.nats_password);

    validate_subject_segment("license_id", &license_id)?;

    let mut seen: HashSet<&str> = HashSet::new();
    for inst in &file.instances {
        validate_subject_segment("instance id", &inst.id)?;
        if RESERVED_INSTANCE_IDS.contains(&inst.id.as_str()) {
            bail!("instance id '{}' is reserved", inst.id);
        }
        if !seen.insert(inst.id.as_str()) {
            bail!("duplicate instance id '{}'", inst.id);
        }
        if !SUPPORTED_GAMES.contains(&inst.game.as_str()) {
            bail!(
                "instance '{}': unsupported game '{}' (supported: {})",
                inst.id,
                inst.game,
                SUPPORTED_GAMES.join(", ")
            );
        }
    }

    if file.agent.heartbeat_seconds < 10 {
        bail!("[agent].heartbeat_seconds must be >= 10");
    }

    Ok(Settings {
        license_id,
        nats_url,
        nats_token,
        nats_user,
        nats_password,
        heartbeat_seconds: file.agent.heartbeat_seconds,
        log_level: file.agent.log_level,
        instances: file.instances,
        probe_interval_seconds: file.prober.interval_seconds.max(30),
        probe_targets: file.prober.targets,
    })
}

/// NATS subject segments must not contain '.', '*', '>', whitespace, etc.
/// Keep it strict: lowercase alphanumerics plus '-' and '_', max 64 chars.
fn validate_subject_segment(what: &str, value: &str) -> Result<()> {
    if value.is_empty() || value.len() > 64 {
        bail!("{what} '{value}' must be 1-64 characters");
    }
    if !value
        .chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
    {
        bail!("{what} '{value}' may only contain lowercase letters, digits, '-' and '_'");
    }
    Ok(())
}
