//! Host configuration, entirely from the environment.
//!
//! Two rules carried over from the Python deployment:
//!
//! * **No silent loopback.** `OPENFUT_ADVERTISE` is required, exactly as the
//!   Python entrypoint requires it. A backend that guesses its own reachable
//!   address is the bug the client/server split removed.
//! * **No default port.** The sidecar runs beside the working Python container
//!   and must never collide with it, so the listen port is explicit. There is
//!   no "test port" constant anywhere in this crate.

use std::env;
use std::fmt;

use openfut_adapter_fifa17::blaze::config as adapter_config;
use openfut_adapter_fifa17::blaze::{AdapterConfig, Endpoints, Identity};

#[derive(Debug)]
pub struct ConfigError(String);

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::error::Error for ConfigError {}

#[derive(Debug, Clone)]
pub struct HostConfig {
    /// Address the listener binds.
    pub listen_addr: String,
    /// Port the listener binds. Required; no default.
    pub listen_port: u16,
    /// Seconds of inactivity before a connection is dropped. Matches the
    /// oracle's 300s socket timeout.
    pub idle_timeout_secs: u64,
    /// Reject a frame claiming a larger payload than this, as the oracle does.
    pub max_payload_bytes: u32,
    /// Optional path for the normalized structural trace.
    pub trace_path: Option<String>,
    /// Optional path for the raw frame capture. Opt-in; forensic evidence.
    pub capture_path: Option<String>,
    /// What the adapter answers with.
    pub adapter: AdapterConfig,
}

fn required(key: &str, why: &str) -> Result<String, ConfigError> {
    match env::var(key) {
        Ok(v) if !v.trim().is_empty() => Ok(v),
        _ => Err(ConfigError(format!("{key} must be set — {why}"))),
    }
}

fn optional_opt(key: &str) -> Option<String> {
    env::var(key).ok().filter(|v| !v.trim().is_empty())
}

/// An optional numeric port. A present-but-invalid value is an ERROR, not a
/// silent fallback — a typo must not quietly leave the previous port in place.
fn optional_port(key: &str) -> Result<Option<u16>, ConfigError> {
    match optional_opt(key) {
        None => Ok(None),
        Some(v) => v
            .trim()
            .parse()
            .map(Some)
            .map_err(|_| ConfigError(format!("{key} is not a valid port: {v:?}"))),
    }
}

fn optional(key: &str, default: &str) -> String {
    env::var(key)
        .ok()
        .filter(|v| !v.trim().is_empty())
        .unwrap_or_else(|| default.to_string())
}

impl HostConfig {
    pub fn from_env() -> Result<HostConfig, ConfigError> {
        // The address handed to the CLIENT for every next hop.
        let advertise = required(
            "OPENFUT_ADVERTISE",
            "it is the address the game machine uses to reach this host; \
             there is no loopback fallback in remote mode",
        )?;

        // NOTE: this is the *advertised-config* bind, not the listener bind.
        // The adapter derives nucleusConnect from it, reproducing the oracle
        // (see the adapter's config docs and the vault's known-issue entry), so
        // it must mirror whatever the Python container runs with if the two are
        // to be compared. The listener has its own setting below.
        let config_bind = optional("OPENFUT_BIND", "127.0.0.1");

        let listen_port_raw = required(
            "OPENFUT_BLAZE_HOST_PORT",
            "the sidecar runs beside the working Python backend and must not \
             collide with it, so the port is explicit and has no default",
        )?;
        let listen_port: u16 = listen_port_raw.trim().parse().map_err(|_| {
            ConfigError(format!(
                "OPENFUT_BLAZE_HOST_PORT is not a valid port: {listen_port_raw:?}"
            ))
        })?;

        let listen_addr = optional("OPENFUT_BLAZE_HOST_BIND", &config_bind);

        // Derived from the ADVERTISED address, never loopback. The deployed
        // Python entrypoint does the same (`POW_HOST="${POW_HOST:-$ADV:8094}"`),
        // and an independent loopback fallback here would leave a remote
        // deployment emitting loopback POW URLs while every other URL was right
        // — a failure that surfaces far from its cause.
        let mut endpoints = Endpoints::advertising(&advertise);
        endpoints.bind = config_bind;
        if let Some(v) = optional_opt("POW_CONTENT_HOST") {
            endpoints.pow_content_host = v;
        }
        if let Some(v) = optional_opt("POW_HOST") {
            endpoints.pow_host = v;
        }
        if let Some(p) = optional_port("OPENFUT_BLAZE_ADVERTISED_PORT")? {
            endpoints.blaze_port = p;
        }
        if let Some(p) = optional_port("OPENFUT_UTAS_PORT")? {
            endpoints.utas_port = p;
        }

        Ok(HostConfig {
            listen_addr,
            listen_port,
            idle_timeout_secs: optional("OPENFUT_BLAZE_IDLE_TIMEOUT", "300")
                .parse()
                .unwrap_or(300),
            max_payload_bytes: 4 * 1024 * 1024,
            trace_path: env::var("OPENFUT_BLAZE_TRACE")
                .ok()
                .filter(|v| !v.trim().is_empty()),
            capture_path: env::var("OPENFUT_BLAZE_CAPTURE")
                .ok()
                .filter(|v| !v.trim().is_empty()),
            adapter: AdapterConfig {
                identity: Identity::default(),
                endpoints,
                server_version: adapter_config::DEFAULT_SERVER_VERSION.into(),
            },
        })
    }

    pub fn listen_on(&self) -> String {
        format!("{}:{}", self.listen_addr, self.listen_port)
    }
}

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

    /// Env is process-global, so these run under one lock rather than as
    /// separate tests that would race each other.
    #[test]
    fn env_contract() {
        let keys = [
            "OPENFUT_ADVERTISE",
            "OPENFUT_BIND",
            "OPENFUT_BLAZE_HOST_PORT",
            "OPENFUT_BLAZE_HOST_BIND",
            "POW_CONTENT_HOST",
            "POW_HOST",
            "OPENFUT_BLAZE_ADVERTISED_PORT",
            "OPENFUT_UTAS_PORT",
        ];
        let saved: Vec<_> = keys.iter().map(|k| (*k, env::var(k).ok())).collect();
        for k in keys {
            env::remove_var(k);
        }

        // Missing advertise is refused, not defaulted.
        let err = HostConfig::from_env().unwrap_err().to_string();
        assert!(err.contains("OPENFUT_ADVERTISE"), "{err}");

        // Missing port is refused too — no default that could collide.
        env::set_var("OPENFUT_ADVERTISE", "198.51.100.7");
        let err = HostConfig::from_env().unwrap_err().to_string();
        assert!(err.contains("OPENFUT_BLAZE_HOST_PORT"), "{err}");

        // (2) A missing advertised address FAILS CLEARLY — never defaulted.
        //     Re-asserted here because it is the single most important rule:
        //     a backend that guesses its own reachable address advertises a
        //     wrong one to a remote client and fails far from the cause.

        // A non-numeric port is a clear error, not a silent fallback.
        env::set_var("OPENFUT_BLAZE_HOST_PORT", "not-a-port");
        let err = HostConfig::from_env().unwrap_err().to_string();
        assert!(err.contains("not a valid port"), "{err}");

        // Happy path: listener bind defaults to the config bind.
        env::set_var("OPENFUT_BLAZE_HOST_PORT", "42230");
        env::set_var("OPENFUT_BIND", "0.0.0.0");
        let cfg = HostConfig::from_env().expect("configured");
        assert_eq!(cfg.listen_on(), "0.0.0.0:42230");
        assert_eq!(cfg.adapter.endpoints.advertise, "198.51.100.7");
        // The adapter's nucleus URL follows the CONFIG bind, reproducing the
        // oracle's behaviour rather than the listener's address.
        assert_eq!(cfg.adapter.nucleus_base(), "http://0.0.0.0:42131");

        // The listener bind can differ from the advertised-config bind.
        env::set_var("OPENFUT_BLAZE_HOST_BIND", "127.0.0.1");
        let cfg = HostConfig::from_env().expect("configured");
        assert_eq!(cfg.listen_on(), "127.0.0.1:42230");
        assert_eq!(cfg.adapter.nucleus_base(), "http://0.0.0.0:42131");

        // (1) POW endpoints DERIVE from advertise; no independent loopback
        //     fallback. This was a real defect: they defaulted to 127.0.0.1
        //     while every other URL followed the advertised address, so a
        //     remote deployment emitted loopback POW URLs.
        env::remove_var("POW_CONTENT_HOST");
        env::remove_var("POW_HOST");
        env::set_var("OPENFUT_ADVERTISE", "198.51.100.7");
        let cfg = HostConfig::from_env().expect("configured");
        assert_eq!(cfg.adapter.endpoints.pow_content_host, "198.51.100.7:8080");
        assert_eq!(cfg.adapter.endpoints.pow_host, "198.51.100.7:8094");
        assert!(cfg.adapter.pow_content_url().contains("198.51.100.7"));
        assert!(!cfg.adapter.pow_content_url().contains("127.0.0.1"));

        // Explicit overrides still win (the deployment remaps POW content).
        env::set_var("POW_CONTENT_HOST", "203.0.113.42:8085");
        let cfg = HostConfig::from_env().expect("configured");
        assert_eq!(cfg.adapter.endpoints.pow_content_host, "203.0.113.42:8085");
        env::remove_var("POW_CONTENT_HOST");

        // (4) The advertised Blaze port is configurable, and a bad value is an
        //     error rather than a silent fallback to the old one.
        env::set_var("OPENFUT_BLAZE_ADVERTISED_PORT", "42999");
        let cfg = HostConfig::from_env().expect("configured");
        assert_eq!(cfg.adapter.endpoints.blaze_port, 42999);
        env::set_var("OPENFUT_BLAZE_ADVERTISED_PORT", "not-a-port");
        let err = HostConfig::from_env().unwrap_err().to_string();
        assert!(err.contains("not a valid port"), "{err}");
        env::remove_var("OPENFUT_BLAZE_ADVERTISED_PORT");

        for (k, v) in saved {
            match v {
                Some(v) => env::set_var(k, v),
                None => env::remove_var(k),
            }
        }
    }
}
