//! Environment → typed configuration, shared by every OpenFUT service host.
//!
//! **This is the only crate in the migration that reads the environment.** The
//! adapter and protocol crates never do, and each service host resolves its
//! endpoints through here rather than parsing its own. That single construction
//! path is what the deployment-address audit established; two hosts each
//! parsing `OPENFUT_ADVERTISE` would be exactly the "separate helpers
//! constructing endpoints from different sources of truth" the audit forbids.
//!
//! Bind and advertise stay distinct throughout:
//!
//! * **BIND** — where a listener binds. Per-host, never client-visible.
//! * **ADVERTISE** — what the remote client is told to contact. Required, with
//!   no default, because a backend that guesses its own reachable address
//!   advertises a wrong one and fails far from the cause.

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, Clone, PartialEq, Eq)]
pub struct ConfigError(pub 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 {}

pub 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}"))),
    }
}

pub fn optional(key: &str, default: &str) -> String {
    optional_opt(key).unwrap_or_else(|| default.to_string())
}

pub 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, never a
/// silent fallback — a typo must not quietly leave the previous port in place.
pub 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:?}"))),
    }
}

pub fn required_port(key: &str, why: &str) -> Result<u16, ConfigError> {
    let raw = required(key, why)?;
    raw.trim()
        .parse()
        .map_err(|_| ConfigError(format!("{key} is not a valid port: {raw:?}")))
}

/// Resolve the endpoints every client-visible URL is built from.
///
/// POW hosts DERIVE from the advertised address, matching the deployed Python
/// entrypoint (`POW_HOST="${POW_HOST:-$ADV:8094}"`). They must not fall back to
/// loopback independently: that would leave a remote deployment emitting
/// loopback POW URLs while every other URL was correct.
pub fn endpoints_from_env() -> Result<Endpoints, ConfigError> {
    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",
    )?;

    let mut endpoints = Endpoints::advertising(&advertise);

    // The advertised-config bind, NOT any listener's bind. The adapter derives
    // nucleusConnect from it, reproducing the oracle; see the vault's
    // compatibility exceptions.
    endpoints.bind = optional("OPENFUT_BIND", "127.0.0.1");

    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(endpoints)
}

/// The full adapter configuration a host hands to the FIFA 17 adapter.
pub fn adapter_from_env() -> Result<AdapterConfig, ConfigError> {
    Ok(AdapterConfig {
        identity: Identity::default(),
        endpoints: endpoints_from_env()?,
        server_version: adapter_config::DEFAULT_SERVER_VERSION.into(),
    })
}

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

    /// Env is process-global, so this runs as one test rather than several that
    /// would race each other.
    #[test]
    fn env_contract() {
        let keys = [
            "OPENFUT_ADVERTISE",
            "OPENFUT_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, never defaulted.
        let err = endpoints_from_env().unwrap_err().to_string();
        assert!(err.contains("OPENFUT_ADVERTISE"), "{err}");

        // POW derives from advertise; no independent loopback fallback.
        env::set_var("OPENFUT_ADVERTISE", "198.51.100.7");
        let e = endpoints_from_env().expect("resolves");
        assert_eq!(e.pow_content_host, "198.51.100.7:8080");
        assert_eq!(e.pow_host, "198.51.100.7:8094");

        // Bind is independent of advertise.
        env::set_var("OPENFUT_BIND", "0.0.0.0");
        let e = endpoints_from_env().unwrap();
        assert_eq!(e.bind, "0.0.0.0");
        assert_eq!(e.advertise, "198.51.100.7");

        // A bad port is an error, not a silent fallback.
        env::set_var("OPENFUT_BLAZE_ADVERTISED_PORT", "nope");
        assert!(endpoints_from_env()
            .unwrap_err()
            .to_string()
            .contains("not a valid port"));
        env::set_var("OPENFUT_BLAZE_ADVERTISED_PORT", "42999");
        assert_eq!(endpoints_from_env().unwrap().blaze_port, 42999);

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