use figment::providers::{Env, Format, Serialized, Toml};
use figment::Figment;
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
    pub server: ServerConfig,
    pub network: NetworkConfig,
    pub paths: Paths,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
    /// Address the web/API server binds to.
    pub http_bind: IpAddr,
    /// Port for the web/API + ISO/iPXE HTTP server (single port, multiplexed by path).
    pub http_port: u16,
    /// Address the TFTP server binds to.
    pub tftp_bind: IpAddr,
    /// Port for TFTP (RFC 1350 default is 69).
    pub tftp_port: u16,
    /// External hostname/IP clients should use to reach this server. If
    /// `None`, auto-detect from the interface that received the DHCP request
    /// (via IP_PKTINFO). This is what ends up in DHCP option 54 / siaddr,
    /// option 66 (TFTP server), and the base of generated iPXE URLs.
    pub public_ip: Option<Ipv4Addr>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct NetworkConfig {
    pub dhcp_mode: DhcpMode,
    /// Address the DHCP proxy/server binds to. For proxy mode, usually 0.0.0.0.
    pub dhcp_bind: IpAddr,
    /// UDP port for DHCP server-side receive. Standard is 67.
    pub dhcp_port: u16,
    /// UDP port for PXE Boot Server discovery. Standard is 4011.
    pub pxe_port: u16,
    /// Optional allowlist of client MAC prefixes (OUI). Empty = serve everyone.
    pub mac_allowlist: Vec<String>,
    /// Optional allowlist of subnets (CIDR). Empty = serve everyone.
    pub subnet_allowlist: Vec<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum DhcpMode {
    /// Run as DHCP proxy (RFC 4578): reply with boot info only, don't lease
    /// IPs. Coexists with an existing DHCP server on the network. Default
    /// because it's the only mode that works in most real deployments without
    /// taking over address assignment.
    #[default]
    Proxy,
    /// Disabled — rely on an external DHCP server that has been manually
    /// configured with `next-server` / `filename`. OpenPXE only serves TFTP
    /// + HTTP in this mode. Useful for home routers that can be pre-set.
    // `off`/`none` are accepted as aliases for backward-compat with the old
    // hand-rolled `apply_env`, which mapped them to Disabled.
    #[serde(alias = "off", alias = "none")]
    Disabled,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Paths {
    /// Directory holding uploaded ISO files.
    pub iso_dir: PathBuf,
    /// Directory for extracted kernel/initrd and other per-ISO derived assets.
    pub work_dir: PathBuf,
    /// Directory containing bundled iPXE binaries (undionly.kpxe, snponly.efi, ...).
    pub ipxe_dir: PathBuf,
    /// Path to the wimboot binary for Windows ISOs (optional — feature-controlled).
    pub wimboot_path: Option<PathBuf>,
    /// Directory under which Windows ISOs are extracted and served via SMB.
    /// Only used when `settings.windows_enabled = true`. Defaults to
    /// `/var/lib/openpxe/smb` in the container image.
    pub smb_dir: PathBuf,
    /// v0.5.2: directory holding uploaded unattended-install answer files
    /// (Kickstart / Preseed / Autoinstall / Windows answer files). Kept
    /// separate from `iso_dir` so answer files never appear in the ISO
    /// listing or the PXE menu. Defaults to `/var/lib/openpxe/unattended`.
    pub unattended_dir: PathBuf,
}

impl Default for ServerConfig {
    fn default() -> Self {
        Self {
            http_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
            http_port: 80,
            tftp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
            tftp_port: 69,
            public_ip: None,
        }
    }
}

impl Default for NetworkConfig {
    fn default() -> Self {
        Self {
            dhcp_mode: DhcpMode::Proxy,
            dhcp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
            dhcp_port: 67,
            pxe_port: 4011,
            mac_allowlist: Vec::new(),
            subnet_allowlist: Vec::new(),
        }
    }
}

impl Default for Paths {
    fn default() -> Self {
        Self {
            iso_dir: PathBuf::from("/var/lib/openpxe/isos"),
            work_dir: PathBuf::from("/var/lib/openpxe/work"),
            ipxe_dir: PathBuf::from("/usr/share/openpxe/ipxe"),
            wimboot_path: None,
            smb_dir: PathBuf::from("/var/lib/openpxe/smb"),
            unattended_dir: PathBuf::from("/var/lib/openpxe/unattended"),
        }
    }
}

// `Config` derives `Default` because each component supplies its own
// non-trivial defaults via `impl Default` blocks above; deriving keeps
// this in sync if a new section is added.

impl Config {
    pub fn from_toml_file(path: &Path) -> crate::Result<Self> {
        let text = std::fs::read_to_string(path)?;
        toml::from_str(&text).map_err(|e| crate::Error::Config(e.to_string()))
    }

    /// Load configuration with layered precedence (v0.5.4, via `figment`):
    /// built-in [`Default`] → optional TOML file → `OPENPXE_*` environment
    /// (highest). Replaces the old `from_toml_file` + `apply_env` two-step
    /// and now covers **every** field automatically (the previous hand-rolled
    /// mapping silently skipped `unattended_dir`, the bind addresses, etc.).
    ///
    /// The env layer preserves the historical flat names
    /// (`OPENPXE_HTTP_PORT`, `OPENPXE_ISO_DIR`, …) so existing deployments
    /// (the Unraid template, `entrypoint.sh`) keep working unchanged, and
    /// additionally accepts the explicit nested form
    /// `OPENPXE_<SECTION>__<FIELD>` (double underscore).
    pub fn load(path: Option<&Path>) -> crate::Result<Self> {
        let mut fig = Figment::from(Serialized::defaults(Config::default()));
        if let Some(p) = path {
            if p.exists() {
                fig = fig.merge(Toml::file(p));
            }
        }
        fig = fig.merge(env_provider());
        fig.extract()
            .map_err(|e| crate::Error::Config(e.to_string()))
    }
}

/// The `OPENPXE_*` environment provider. Maps the historical flat variable
/// names onto the nested [`Config`] fields, and also accepts the explicit
/// `OPENPXE_SECTION__FIELD` nested form. Keys that match nothing (e.g.
/// `OPENPXE_CONFIG`, `OPENPXE_UID` from the entrypoint) become stray
/// top-level keys that `Config` ignores on extract.
fn env_provider() -> Env {
    Env::prefixed("OPENPXE_")
        .map(|key| {
            // Lowercase so the match is robust regardless of how the OS
            // reports the var's case.
            let k = key.as_str().to_ascii_lowercase();
            let mapped = match k.as_str() {
                "http_port" => "server.http_port",
                "http_bind" => "server.http_bind",
                "tftp_port" => "server.tftp_port",
                "tftp_bind" => "server.tftp_bind",
                "public_ip" => "server.public_ip",
                "dhcp_port" => "network.dhcp_port",
                "dhcp_bind" => "network.dhcp_bind",
                "dhcp_mode" => "network.dhcp_mode",
                "pxe_port" => "network.pxe_port",
                "iso_dir" => "paths.iso_dir",
                "work_dir" => "paths.work_dir",
                "ipxe_dir" => "paths.ipxe_dir",
                "smb_dir" => "paths.smb_dir",
                "wimboot_path" => "paths.wimboot_path",
                "unattended_dir" => "paths.unattended_dir",
                // Unknown: support the explicit nested form
                // (OPENPXE_SERVER__HTTP_PORT). `replace` is a no-op for the
                // already-handled flat names above.
                other => return other.replace("__", ".").into(),
            };
            mapped.into()
        })
        .split(".")
}

#[cfg(test)]
mod tests {
    // figment's `Jail::expect_with` closure returns `Result<(), figment::Error>`
    // and `figment::Error` is large; that's the library's API, not ours.
    #![allow(clippy::result_large_err)]
    use super::*;

    #[test]
    fn defaults_load_when_no_file_or_env() {
        figment::Jail::expect_with(|_jail| {
            let c = Config::load(None).expect("load defaults");
            assert_eq!(c.server.http_port, 80);
            assert_eq!(c.network.dhcp_mode, DhcpMode::Proxy);
            assert_eq!(c.paths.iso_dir, PathBuf::from("/var/lib/openpxe/isos"));
            Ok(())
        });
    }

    #[test]
    fn legacy_flat_env_vars_still_apply() {
        figment::Jail::expect_with(|jail| {
            jail.set_env("OPENPXE_HTTP_PORT", "8123");
            jail.set_env("OPENPXE_TFTP_PORT", "6900");
            jail.set_env("OPENPXE_DHCP_PORT", "6767");
            jail.set_env("OPENPXE_PXE_PORT", "4444");
            jail.set_env("OPENPXE_PUBLIC_IP", "10.20.30.40");
            jail.set_env("OPENPXE_DHCP_MODE", "disabled");
            jail.set_env("OPENPXE_ISO_DIR", "/data/isos");
            jail.set_env("OPENPXE_WORK_DIR", "/data/work");
            jail.set_env("OPENPXE_IPXE_DIR", "/data/ipxe");
            jail.set_env("OPENPXE_SMB_DIR", "/data/smb");
            // v0.5.4: a field the old apply_env never covered.
            jail.set_env("OPENPXE_UNATTENDED_DIR", "/data/unattended");
            let c = Config::load(None).expect("load with env");
            assert_eq!(c.server.http_port, 8123);
            assert_eq!(c.server.tftp_port, 6900);
            assert_eq!(c.network.dhcp_port, 6767);
            assert_eq!(c.network.pxe_port, 4444);
            assert_eq!(c.server.public_ip, Some("10.20.30.40".parse().unwrap()));
            assert_eq!(c.network.dhcp_mode, DhcpMode::Disabled);
            assert_eq!(c.paths.iso_dir, PathBuf::from("/data/isos"));
            assert_eq!(c.paths.work_dir, PathBuf::from("/data/work"));
            assert_eq!(c.paths.ipxe_dir, PathBuf::from("/data/ipxe"));
            assert_eq!(c.paths.smb_dir, PathBuf::from("/data/smb"));
            assert_eq!(c.paths.unattended_dir, PathBuf::from("/data/unattended"));
            Ok(())
        });
    }

    #[test]
    fn dhcp_mode_off_alias_maps_to_disabled() {
        figment::Jail::expect_with(|jail| {
            jail.set_env("OPENPXE_DHCP_MODE", "off");
            let c = Config::load(None).unwrap();
            assert_eq!(c.network.dhcp_mode, DhcpMode::Disabled);
            Ok(())
        });
    }

    #[test]
    fn nested_double_underscore_form_also_works() {
        figment::Jail::expect_with(|jail| {
            jail.set_env("OPENPXE_SERVER__HTTP_PORT", "9001");
            let c = Config::load(None).unwrap();
            assert_eq!(c.server.http_port, 9001);
            Ok(())
        });
    }

    #[test]
    fn env_overrides_toml_file() {
        figment::Jail::expect_with(|jail| {
            jail.create_file(
                "openpxe.toml",
                "[server]\nhttp_port = 8080\n[paths]\niso_dir = \"/from/toml\"\n",
            )?;
            jail.set_env("OPENPXE_HTTP_PORT", "8443");
            let c = Config::load(Some(Path::new("openpxe.toml"))).unwrap();
            // env wins over TOML…
            assert_eq!(c.server.http_port, 8443);
            // …but TOML-only values still apply.
            assert_eq!(c.paths.iso_dir, PathBuf::from("/from/toml"));
            Ok(())
        });
    }

    #[test]
    fn unrelated_openpxe_env_vars_are_ignored() {
        figment::Jail::expect_with(|jail| {
            // entrypoint.sh sets these; they must not break config load.
            jail.set_env("OPENPXE_UID", "10001");
            jail.set_env("OPENPXE_CONFIG", "/etc/openpxe.toml");
            let c = Config::load(None).expect("stray vars ignored");
            assert_eq!(c.server.http_port, 80);
            Ok(())
        });
    }
}
