use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{bail, Context, Result};

#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
pub struct MaterializationPayload {
    #[serde(default)]
    pub system: Option<String>,
    #[serde(default)]
    pub flake_inputs: BTreeMap<String, String>,
    #[serde(default)]
    pub nixos_module: NixosModulePayload,
}

#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
pub struct NixosModulePayload {
    #[serde(default)]
    pub extra_config: Vec<String>,
}

impl MaterializationPayload {
    pub fn from_source(path: &Path) -> Result<Self> {
        let loaded = crate::document::load_document::<Self>(path, "materialization payload")?;
        loaded.value.validate()?;
        Ok(loaded.value)
    }

    pub fn from_toml_str(content: &str) -> Result<Self> {
        let payload: Self =
            toml::from_str(content).context("invalid compatibility materialization TOML")?;
        payload.validate()?;
        Ok(payload)
    }

    pub fn to_compat_toml(&self) -> String {
        if self.flake_inputs.is_empty() {
            return String::new();
        }
        let mut lines = Vec::new();
        if let Some(system) = &self.system {
            lines.push(format!("system = {system:?}"));
            if !self.flake_inputs.is_empty() || !self.nixos_module.extra_config.is_empty() {
                lines.push(String::new());
            }
        }
        if !self.flake_inputs.is_empty() {
            lines.push("[flake_inputs]".to_string());
            for (name, reference) in &self.flake_inputs {
                lines.push(format!("{name} = {reference:?}"));
            }
        }
        if !self.nixos_module.extra_config.is_empty() {
            if !lines.is_empty() {
                lines.push(String::new());
            }
            lines.push("[nixos_module]".to_string());
            lines.push("extra_config = [".to_string());
            for fragment in &self.nixos_module.extra_config {
                lines.push(format!("  {fragment:?},"));
            }
            lines.push("]".to_string());
        }
        lines.join("\n")
    }

    pub fn validate(&self) -> Result<()> {
        if let Some(system) = &self.system {
            validate_nix_system(system)?;
        }
        for (name, reference) in &self.flake_inputs {
            validate_flake_input_name(name)?;
            validate_flake_input_ref(reference)?;
        }
        for fragment in &self.nixos_module.extra_config {
            validate_extra_config(fragment)?;
        }
        Ok(())
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaterializationTarget {
    Toplevel,
    SdImage,
}

impl MaterializationTarget {
    pub fn parse(value: &str) -> Result<Self> {
        match value {
            "toplevel" => Ok(Self::Toplevel),
            "sd-image" => Ok(Self::SdImage),
            other => {
                bail!("unsupported materialization target '{other}'; supported: toplevel, sd-image")
            }
        }
    }

    pub fn attr(self, hostname: &str) -> String {
        match self {
            Self::Toplevel => nixos_toplevel_attr(hostname),
            Self::SdImage => nixos_sd_image_attr(hostname),
        }
    }
}

#[derive(Debug, Clone)]
pub struct MaterializationCheck {
    pub workspace: PathBuf,
    pub hostname: String,
    pub target: MaterializationTarget,
}

impl MaterializationCheck {
    pub fn eval_attr(&self) -> String {
        self.target.attr(&self.hostname)
    }

    pub fn command(&self) -> Result<Command> {
        validate_hostname(&self.hostname)?;
        validate_workspace(&self.workspace)?;

        let mut command = Command::new(find_nix());
        command
            .env("NIX_CONFIG", "experimental-features = nix-command flakes")
            .env("NIX_SHOW_STATS", "0")
            .args([
                "--extra-experimental-features",
                "nix-command flakes",
                "eval",
                "--no-update-lock-file",
                "--no-write-lock-file",
                "--offline",
            ])
            .arg(self.eval_attr())
            .current_dir(&self.workspace);
        Ok(command)
    }

    pub fn run(&self) -> Result<()> {
        let output = self
            .command()?
            .output()
            .with_context(|| format!("running nix eval in {}", self.workspace.display()))?;

        if output.status.success() {
            return Ok(());
        }

        let stderr = crate::exec::captured_text(&output.stderr);
        let stdout = crate::exec::captured_text(&output.stdout);
        bail!(
            "materialization check failed for {}\n{}{}",
            self.eval_attr(),
            stdout,
            stderr
        );
    }
}

#[derive(Debug, Clone)]
pub struct MaterializationBuild {
    pub workspace: PathBuf,
    pub hostname: String,
    pub target: MaterializationTarget,
    pub out_link: PathBuf,
}

impl MaterializationBuild {
    pub fn eval_attr(&self) -> String {
        self.target.attr(&self.hostname)
    }

    pub fn command(&self) -> Result<Command> {
        validate_hostname(&self.hostname)?;
        validate_workspace(&self.workspace)?;
        if let Some(parent) = self.out_link.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("creating {}", parent.display()))?;
        }
        let mut command = Command::new(find_nix());
        command
            .env("NIX_CONFIG", "experimental-features = nix-command flakes")
            .env("NIX_SHOW_STATS", "0")
            .args([
                "--extra-experimental-features",
                "nix-command flakes",
                "build",
                "--no-update-lock-file",
                "--no-write-lock-file",
                "--offline",
                "--out-link",
            ])
            .arg(&self.out_link)
            .arg(self.eval_attr())
            .current_dir(&self.workspace);
        Ok(command)
    }

    pub fn run(&self) -> Result<()> {
        let check = MaterializationCheck {
            workspace: self.workspace.clone(),
            hostname: self.hostname.clone(),
            target: self.target,
        };
        check.run()?;

        let output = self
            .command()?
            .output()
            .with_context(|| format!("running nix build in {}", self.workspace.display()))?;
        if output.status.success() {
            return Ok(());
        }
        let stderr = crate::exec::captured_text(&output.stderr);
        let stdout = crate::exec::captured_text(&output.stdout);
        bail!(
            "materialization build failed for {}\n{}{}",
            self.eval_attr(),
            stdout,
            stderr
        );
    }
}

#[derive(Debug, Clone)]
pub struct NixosModuleExport {
    pub workspace: PathBuf,
    pub name: String,
}

impl NixosModuleExport {
    pub fn write(&self, payload: &MaterializationPayload) -> Result<()> {
        validate_module_name(&self.name)?;
        std::fs::create_dir_all(&self.workspace)?;
        std::fs::write(
            self.workspace.join("module.nix"),
            render_nixos_module(payload),
        )?;
        std::fs::write(
            self.workspace.join("flake.nix"),
            format!(
                r#"{{
  description = "Nex materialization module export";

  outputs = {{ self, ... }}:
  {{
    nixosModules.{name} = import ./module.nix;
  }};
}}
"#,
                name = self.name
            ),
        )?;
        Ok(())
    }
}

pub fn validate_module_name(name: &str) -> Result<()> {
    if name.is_empty() {
        bail!("module name cannot be empty");
    }
    let mut chars = name.chars();
    let first = chars.next().expect("checked non-empty");
    if !(first.is_ascii_alphabetic() || first == '_') {
        bail!("module name '{name}' must start with a letter or underscore");
    }
    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
        bail!("module name '{name}' may only contain [A-Za-z0-9_-]");
    }
    Ok(())
}

pub fn nixos_toplevel_attr(hostname: &str) -> String {
    format!(".#nixosConfigurations.{hostname}.config.system.build.toplevel")
}

pub fn nixos_sd_image_attr(hostname: &str) -> String {
    format!(".#nixosConfigurations.{hostname}.config.system.build.sdImage")
}

pub fn validate_hostname(hostname: &str) -> Result<()> {
    if hostname.is_empty() || hostname.len() > 63 {
        bail!("hostname must be 1-63 characters");
    }
    if hostname.starts_with('-') || hostname.ends_with('-') {
        bail!("hostname cannot start or end with a hyphen");
    }
    if !hostname
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-')
    {
        bail!("hostname must contain only ASCII letters, digits, and hyphens");
    }
    Ok(())
}

pub fn validate_workspace(workspace: &Path) -> Result<()> {
    if !workspace.is_dir() {
        bail!(
            "materialization workspace does not exist: {}",
            workspace.display()
        );
    }
    let flake = workspace.join("flake.nix");
    if !flake.is_file() {
        bail!(
            "materialization workspace {} does not contain flake.nix",
            workspace.display()
        );
    }
    Ok(())
}

pub fn validate_nix_system(system: &str) -> Result<()> {
    match system {
        "x86_64-linux" | "aarch64-linux" | "x86_64-darwin" | "aarch64-darwin" => Ok(()),
        other => bail!(
            "unsupported nix system '{other}'; supported: x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin"
        ),
    }
}

pub fn validate_flake_input_name(name: &str) -> Result<()> {
    if name.is_empty() {
        bail!("flake input name cannot be empty");
    }
    let mut chars = name.chars();
    let first = chars.next().expect("checked non-empty");
    if !(first.is_ascii_alphabetic() || first == '_') {
        bail!("flake input name '{name}' must start with a letter or underscore");
    }
    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
        bail!("flake input name '{name}' may only contain [A-Za-z0-9_-]");
    }
    Ok(())
}

pub fn validate_flake_input_ref(reference: &str) -> Result<()> {
    if reference.trim().is_empty() {
        bail!("flake input ref cannot be empty");
    }
    if reference
        .chars()
        .any(|c| c.is_control() || c.is_whitespace())
    {
        bail!("flake input ref '{reference}' cannot contain whitespace or control characters");
    }
    if reference.contains('"')
        || reference.contains('\'')
        || reference.contains('`')
        || reference.contains('$')
        || reference.contains(';')
        || reference.contains('|')
        || reference.contains('&')
        || reference.contains('>')
        || reference.contains('<')
    {
        bail!("flake input ref '{reference}' contains unsupported shell/template characters");
    }
    Ok(())
}

pub fn validate_extra_config(fragment: &str) -> Result<()> {
    if fragment.contains("builtins.getFlake") || fragment.contains("builtins.fetchGit") {
        bail!("extra_config must not use impure flake/git fetches; declare flake_inputs instead");
    }
    Ok(())
}

pub fn render_nixos_module(payload: &MaterializationPayload) -> String {
    let mut lines = Vec::new();
    lines.push("{ config, lib, pkgs, inputs, ... }:".to_string());
    lines.push(String::new());
    lines.push("{".to_string());
    if payload.nixos_module.extra_config.is_empty() {
        lines.push("  # Generated by nex materialization export.".to_string());
    } else {
        for fragment in &payload.nixos_module.extra_config {
            lines.push("  # Extra NixOS config from materialization payload".to_string());
            for line in fragment.trim().lines() {
                if line.trim().is_empty() {
                    lines.push(String::new());
                } else {
                    lines.push(format!("  {line}"));
                }
            }
            lines.push(String::new());
        }
    }
    lines.push("}".to_string());
    lines.push(String::new());
    lines.join("\n")
}

pub fn render_flake_inputs(inputs: &BTreeMap<String, String>) -> String {
    let mut lines = String::new();
    for (name, reference) in inputs {
        lines.push_str(&format!("    {name}.url = \"{reference}\";\n"));
    }
    lines
}

pub fn find_nix() -> String {
    if std::env::var_os("NEX_TESTING").is_some() {
        return "nix".to_string();
    }

    let candidates = [
        "/nix/var/nix/profiles/default/bin/nix",
        "/run/current-system/sw/bin/nix",
        "/etc/profiles/per-user/default/bin/nix",
    ];
    for path in candidates {
        if Path::new(path).exists() {
            return path.to_string();
        }
    }
    "nix".to_string()
}

/// Scaffold a minimal NixOS configuration from a canonical Pkl source or compatibility TOML source.
pub fn scaffold_nixos_config_from_source(
    config_dir: &Path,
    hostname: &str,
    source: &Path,
) -> Result<()> {
    match source.extension().and_then(|ext| ext.to_str()) {
        Some("toml") => {
            let content = std::fs::read_to_string(source)
                .with_context(|| format!("reading compatibility materialization TOML {}", source.display()))?;
            scaffold_nixos_config(config_dir, hostname, &content)
        }
        Some("pkl") => {
            let payload = MaterializationPayload::from_source(source)?;
            scaffold_nixos_config(config_dir, hostname, &payload.to_compat_toml())
        }
        Some(ext) => bail!("unsupported materialization source extension .{ext}; canonical Nex sources use .pkl (.toml is compatibility/interchange)"),
        None => bail!("materialization source path must have an extension; canonical Nex sources use .pkl"),
    }
}

/// Scaffold a minimal NixOS configuration for the installer.
/// Retained for compatibility TOML interchange and tests.
#[allow(dead_code)]
pub fn scaffold_nixos_config(config_dir: &Path, hostname: &str, profile_toml: &str) -> Result<()> {
    std::fs::create_dir_all(config_dir)?;

    let user = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
    let detected_system = crate::discover::detect_system();

    // Parse profile to extract linux settings and materialization metadata.
    let profile: toml::Value =
        toml::from_str(profile_toml).context("invalid materialization TOML")?;
    let payload = MaterializationPayload::from_toml_str(profile_toml)?;
    let extra_inputs = render_flake_inputs(&payload.flake_inputs);
    let system = payload.system.as_deref().unwrap_or(detected_system);

    // flake.nix
    std::fs::write(
        config_dir.join("flake.nix"),
        format!(
            r#"{{
  description = "NixOS configuration — generated by nex forge";

  inputs = {{
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    home-manager = {{
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    }};
{extra_inputs}  }};

  outputs = {{ self, nixpkgs, home-manager, ... }}@inputs:
  {{
    nixosConfigurations."{hostname}" = nixpkgs.lib.nixosSystem {{
      system = "{system}";
      specialArgs = {{ inherit inputs; username = "{user}"; hostname = "{hostname}"; }};
      modules = [
        ./configuration.nix
        ./materialization-module.nix
        ./hardware-configuration.nix
        home-manager.nixosModules.home-manager
        {{
          home-manager = {{
            useGlobalPkgs = true;
            useUserPackages = true;
            backupFileExtension = "backup";
            extraSpecialArgs = {{ username = "{user}"; hostname = "{hostname}"; }};
            users."{user}" = import ./home.nix;
          }};
        }}
      ];
    }};
  }};
}}
"#
        ),
    )?;

    // configuration.nix — system-level config generated from profile
    let mut config_lines = Vec::new();
    config_lines.push("{ pkgs, lib, username, hostname, ... }:".to_string());
    config_lines.push(String::new());
    config_lines.push("{".to_string());
    config_lines.push(format!("  networking.hostName = \"{hostname}\";"));
    config_lines.push(String::new());

    // Nix settings
    config_lines
        .push("  nix.settings.experimental-features = [ \"nix-command\" \"flakes\" ];".to_string());
    config_lines.push("  nixpkgs.config.allowUnfree = true;".to_string());
    config_lines.push(String::new());

    // Boot
    config_lines.push("  boot.loader.systemd-boot.enable = true;".to_string());
    config_lines.push("  boot.loader.efi.canTouchEfiVariables = true;".to_string());
    config_lines.push(String::new());

    // User
    config_lines.push(format!("  users.users.\"{user}\" = {{"));
    config_lines.push("    isNormalUser = true;".to_string());
    config_lines.push(
        "    extraGroups = [ \"wheel\" \"networkmanager\" \"video\" \"audio\" ];".to_string(),
    );
    config_lines.push("    shell = pkgs.bash;".to_string());
    config_lines.push("  };".to_string());
    config_lines.push(String::new());

    // Networking
    config_lines.push("  networking.networkmanager.enable = true;".to_string());
    config_lines.push(String::new());

    // Locale / timezone
    config_lines.push("  # time.timeZone is set at install time by polymerize".to_string());
    config_lines.push("  i18n.defaultLocale = \"en_US.UTF-8\";".to_string());
    config_lines.push(String::new());

    // Generate from [linux] section of profile
    if let Some(linux) = profile.get("linux") {
        generate_linux_config(&mut config_lines, linux);
    }

    config_lines.push("  system.stateVersion = \"25.05\";".to_string());
    config_lines.push("}".to_string());
    config_lines.push(String::new());

    std::fs::write(
        config_dir.join("configuration.nix"),
        config_lines.join("\n"),
    )?;

    std::fs::write(
        config_dir.join("materialization-module.nix"),
        render_nixos_module(&payload),
    )?;

    // hardware-configuration.nix — placeholder, polymerize will generate the real one
    std::fs::write(
        config_dir.join("hardware-configuration.nix"),
        r#"# Placeholder — polymerize.sh generates the real one via nixos-generate-config
{ config, lib, pkgs, modulesPath, ... }:

{
  imports = [
    (modulesPath + "/installer/scan/not-detected.nix")
  ];
}
"#,
    )?;

    // home.nix — user-level config from profile packages
    let mut home_lines = vec![
        "{ pkgs, username, ... }:".to_string(),
        String::new(),
        "{".to_string(),
        "  home = {".to_string(),
        "    username = username;".to_string(),
        "    homeDirectory = \"/home/${username}\";".to_string(),
        "    stateVersion = \"25.05\";".to_string(),
    ];
    home_lines.push("  };".to_string());
    home_lines.push(String::new());
    home_lines.push("  home.sessionPath = [".to_string());
    home_lines.push("    \"$HOME/.local/bin\"".to_string());
    if let Some(paths) = profile
        .get("shell")
        .and_then(|s| s.get("paths"))
        .and_then(|p| p.as_array())
    {
        for path in paths {
            if let Some(path_str) = path.as_str() {
                if path_str != "$HOME/.local/bin" && path_str != "~/.local/bin" {
                    home_lines.push(format!("    \"{path_str}\""));
                }
            }
        }
    }
    home_lines.push("  ];".to_string());
    home_lines.push(String::new());

    // Packages from profile
    home_lines.push("  home.packages = with pkgs; [".to_string());
    if let Some(pkgs) = profile
        .get("packages")
        .and_then(|p| p.get("nix"))
        .and_then(|n| n.as_array())
    {
        for pkg in pkgs {
            if let Some(name) = pkg.as_str() {
                home_lines.push(format!("    {name}"));
            }
        }
    }
    home_lines.push("  ];".to_string());
    home_lines.push(String::new());
    home_lines.push("  programs.home-manager.enable = true;".to_string());
    home_lines.push("}".to_string());
    home_lines.push(String::new());

    std::fs::write(config_dir.join("home.nix"), home_lines.join("\n"))?;

    Ok(())
}

/// Generate NixOS config lines from the [linux] section of a profile.
/// Public so `polymerize` can reuse the same generation logic.
pub fn generate_linux_config(lines: &mut Vec<String>, linux: &toml::Value) {
    // Desktop environment
    if let Some(de) = linux.get("desktop").and_then(|v| v.as_str()) {
        match de {
            "gnome" => {
                lines.push("  # Desktop: GNOME".to_string());
                lines.push("  services.xserver.enable = true;".to_string());
                lines.push("  services.xserver.displayManager.gdm.enable = true;".to_string());
                lines.push("  services.xserver.desktopManager.gnome.enable = true;".to_string());
            }
            "kde" | "plasma" => {
                lines.push("  # Desktop: KDE Plasma".to_string());
                lines.push("  services.desktopManager.plasma6.enable = true;".to_string());
                lines.push("  services.displayManager.sddm.enable = true;".to_string());
                lines.push("  services.displayManager.sddm.wayland.enable = true;".to_string());
            }
            "cosmic" => {
                lines.push("  # Desktop: COSMIC".to_string());
                lines.push("  services.desktopManager.cosmic.enable = true;".to_string());
                lines.push("  services.displayManager.cosmic-greeter.enable = true;".to_string());
            }
            _ => {}
        }
        lines.push(String::new());
    }

    // GPU
    if let Some(gpu) = linux.get("gpu") {
        let driver = gpu.get("driver").and_then(|v| v.as_str()).unwrap_or("");
        let lib32 = gpu.get("32bit").and_then(|v| v.as_bool()).unwrap_or(false);
        let _vulkan = gpu.get("vulkan").and_then(|v| v.as_bool()).unwrap_or(true);
        let vaapi = gpu.get("vaapi").and_then(|v| v.as_bool()).unwrap_or(false);
        let opencl = gpu.get("opencl").and_then(|v| v.as_bool()).unwrap_or(false);

        // Multiple drivers can be specified comma-separated: "amdgpu,nvidia"
        let drivers: Vec<&str> = driver.split(',').map(|d| d.trim()).collect();

        lines.push("  hardware.graphics.enable = true;".to_string());
        if lib32 {
            lines.push("  hardware.graphics.enable32Bit = true;".to_string());
        }

        let mut video_drivers: Vec<&str> = Vec::new();
        let mut extra_packages: Vec<&str> = Vec::new();

        for drv in &drivers {
            match *drv {
                "amdgpu" => {
                    lines.push("  # GPU: AMD".to_string());
                    lines.push("  hardware.amdgpu.initrd.enable = true;".to_string());
                    if opencl {
                        lines.push("  hardware.amdgpu.opencl.enable = true;".to_string());
                    }
                    if vaapi {
                        extra_packages.push("libva-vdpau-driver");
                    }
                }
                "nvidia" => {
                    let nvidia_open = gpu
                        .get("nvidia_open")
                        .and_then(|v| v.as_bool())
                        .unwrap_or(true);
                    lines.push("  # GPU: NVIDIA".to_string());
                    video_drivers.push("nvidia");
                    lines.push("  hardware.nvidia.modesetting.enable = true;".to_string());
                    lines.push(format!(
                        "  hardware.nvidia.open = {};",
                        if nvidia_open { "true" } else { "false" }
                    ));
                }
                "nouveau" => {
                    lines.push("  # GPU: NVIDIA (open-source nouveau)".to_string());
                    video_drivers.push("nouveau");
                }
                "intel" => {
                    lines.push("  # GPU: Intel".to_string());
                    // Intel i915 is loaded automatically by the kernel
                    if vaapi {
                        extra_packages.push("intel-media-driver");
                    }
                }
                "" => {} // no driver specified, just enable graphics
                other => {
                    lines.push(format!("  # GPU: {other}"));
                }
            }
        }

        if !video_drivers.is_empty() {
            let drivers_str = video_drivers
                .iter()
                .map(|d| format!("\"{d}\""))
                .collect::<Vec<_>>()
                .join(" ");
            lines.push(format!(
                "  services.xserver.videoDrivers = [ {drivers_str} ];"
            ));
        }

        if !extra_packages.is_empty() {
            lines.push("  hardware.graphics.extraPackages = with pkgs; [".to_string());
            for pkg in &extra_packages {
                lines.push(format!("    {pkg}"));
            }
            lines.push("  ];".to_string());
        }
        lines.push(String::new());
    }

    // Audio
    if let Some(audio) = linux.get("audio") {
        let backend = audio
            .get("backend")
            .and_then(|v| v.as_str())
            .unwrap_or("pipewire");
        let low_latency = audio
            .get("low_latency")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        let bluetooth = audio
            .get("bluetooth")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);

        lines.push("  # Audio".to_string());
        if backend == "pipewire" {
            lines.push("  services.pipewire = {".to_string());
            lines.push("    enable = true;".to_string());
            lines.push("    alsa.enable = true;".to_string());
            lines.push("    alsa.support32Bit = true;".to_string());
            lines.push("    pulse.enable = true;".to_string());
            if low_latency {
                lines.push("    extraConfig.pipewire.\"92-low-latency\" = {".to_string());
                lines.push("      \"context.properties\" = { \"default.clock.rate\" = 48000; \"default.clock.quantum\" = 64; };".to_string());
                lines.push("    };".to_string());
            }
            lines.push("  };".to_string());
        }
        if bluetooth {
            lines.push("  hardware.bluetooth.enable = true;".to_string());
            lines.push("  hardware.bluetooth.powerOnBoot = true;".to_string());
        }
        lines.push(String::new());
    }

    // Gaming
    if let Some(gaming) = linux.get("gaming") {
        let steam = gaming
            .get("steam")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        let gamemode = gaming
            .get("gamemode")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        let gamescope = gaming
            .get("gamescope")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        let controllers = gaming
            .get("controllers")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        let mangohud = gaming
            .get("mangohud")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        let _proton_ge = gaming
            .get("proton_ge")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);

        lines.push("  # Gaming".to_string());
        if steam {
            lines.push("  programs.steam = {".to_string());
            lines.push("    enable = true;".to_string());
            lines.push(format!(
                "    gamescopeSession.enable = {};",
                if gamescope { "true" } else { "false" }
            ));
            lines.push("  };".to_string());
        }
        if gamemode {
            lines.push("  programs.gamemode.enable = true;".to_string());
        }
        if controllers {
            lines.push("  hardware.steam-hardware.enable = true;".to_string());
        }

        let mut pkgs = Vec::new();
        if mangohud {
            pkgs.push("mangohud");
        }
        // proton-ge-bin is installed via Steam's compatibility tools, not as a system package
        // if proton_ge { pkgs.push("proton-ge-bin"); }
        if !pkgs.is_empty() {
            lines.push("  environment.systemPackages = with pkgs; [".to_string());
            for p in &pkgs {
                lines.push(format!("    {p}"));
            }
            lines.push("  ];".to_string());
        }
        lines.push(String::new());
    }

    // GNOME dconf settings (via home-manager)
    if let Some(gnome) = linux.get("gnome") {
        lines.push("  # GNOME settings (applied via dconf in home-manager)".to_string());

        let dark = gnome
            .get("dark_mode")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        if dark {
            // NixOS-level setting for GNOME dark mode
            lines.push("  environment.sessionVariables.GTK_THEME = \"Adwaita:dark\";".to_string());
        }

        // Favorite apps for the GNOME dock (via dconf)
        if let Some(favs) = gnome.get("favorite_apps").and_then(|v| v.as_array()) {
            let apps: Vec<String> = favs
                .iter()
                .filter_map(|v| v.as_str())
                .map(|s| format!("'{s}'"))
                .collect();
            if !apps.is_empty() {
                // This needs to go in home-manager's dconf settings, but since we're
                // generating system-level config here, we use environment.etc to write
                // a dconf profile that gets applied on login.
                lines.push("  # GNOME favorite apps — written as dconf db override".to_string());
                let apps_str = apps.join(", ");
                lines.push(
                    "  environment.etc.\"dconf/db/local.d/01-nex-favorites\".text = ''".to_string(),
                );
                lines.push("    [org/gnome/shell]".to_string());
                lines.push(format!("    favorite-apps=[{apps_str}]"));
                if dark {
                    lines.push(String::new());
                    lines.push("    [org/gnome/desktop/interface]".to_string());
                    lines.push("    color-scheme='prefer-dark'".to_string());
                    lines.push("    gtk-theme='Adwaita-dark'".to_string());
                }
                lines.push("  '';".to_string());
                // Need dconf update to apply
                lines.push("  system.activationScripts.dconf-update = \"dconf update 2>/dev/null || true\";".to_string());
            }
        }

        // Extensions
        if let Some(exts) = gnome.get("extensions").and_then(|v| v.as_array()) {
            let ext_pkgs: Vec<&str> = exts.iter().filter_map(|v| v.as_str()).collect();
            if !ext_pkgs.is_empty() {
                // NixOS module system merges multiple environment.systemPackages declarations
                lines.push(
                    "  environment.systemPackages = with pkgs.gnomeExtensions; [".to_string(),
                );
                for ext in &ext_pkgs {
                    lines.push(format!("    {ext}"));
                }
                lines.push("  ];".to_string());
            }
        }

        lines.push(String::new());
    }

    // COSMIC desktop settings
    if let Some(cosmic) = linux.get("cosmic") {
        lines.push("  # COSMIC desktop settings".to_string());

        let dark = cosmic
            .get("dark_mode")
            .and_then(|v| v.as_bool())
            .unwrap_or(true);
        let autohide = cosmic
            .get("dock_autohide")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);

        // COSMIC uses RON config files in ~/.config/cosmic/
        // Write as /etc/skel entries so new users get them on first login.
        lines.push(format!(
            "  environment.etc.\"skel/.config/cosmic/com.system76.CosmicTheme.Mode/v1/is-dark\".text = \"{}\";",
            dark
        ));

        if autohide {
            lines.push(
                "  environment.etc.\"skel/.config/cosmic/com.system76.CosmicPanel.Dock/v1/autohide\".text = \"true\";".to_string()
            );
        }

        if let Some(favs) = cosmic.get("dock_favorites").and_then(|v| v.as_array()) {
            let fav_list: Vec<String> = favs
                .iter()
                .filter_map(|v| v.as_str())
                .map(|s| {
                    if s.ends_with(".desktop") {
                        s.to_string()
                    } else {
                        format!("{s}.desktop")
                    }
                })
                .collect();
            if !fav_list.is_empty() {
                // Inner quotes must be escaped for Nix: \" inside "..."
                let ron = fav_list
                    .iter()
                    .map(|f| format!("\\\"{f}\\\""))
                    .collect::<Vec<_>>()
                    .join(", ");
                lines.push(format!(
                    "  environment.etc.\"skel/.config/cosmic/com.system76.CosmicAppList/v1/favorites\".text = \"[{ron}]\";",
                ));
            }
        }

        lines.push(String::new());
    }

    // Extra NixOS services
    if let Some(services) = linux.get("services").and_then(|v| v.as_array()) {
        let services: Vec<&str> = services.iter().filter_map(|v| v.as_str()).collect();
        if !services.is_empty() {
            lines.push("  # Extra services".to_string());
            for service in services {
                lines.push(format!("  services.{service}.enable = true;"));
            }
            lines.push(String::new());
        }
    }

    // Kernel parameters
    if let Some(params) = linux.get("kernel_params").and_then(|v| v.as_array()) {
        let params: Vec<String> = params
            .iter()
            .filter_map(|v| v.as_str())
            .map(nix_string)
            .collect();
        if !params.is_empty() {
            lines.push(format!("  boot.kernelParams = [ {} ];", params.join(" ")));
            lines.push(String::new());
        }
    }

    // Firewall ports
    if let Some(firewall) = linux.get("firewall") {
        if let Some(ports) = firewall.get("allowed_tcp_ports").and_then(|v| v.as_array()) {
            let ports: Vec<String> = ports
                .iter()
                .filter_map(|v| v.as_integer())
                .map(|p| p.to_string())
                .collect();
            if !ports.is_empty() {
                lines.push(format!(
                    "  networking.firewall.allowedTCPPorts = [ {} ];",
                    ports.join(" ")
                ));
            }
        }
        if let Some(ports) = firewall.get("allowed_udp_ports").and_then(|v| v.as_array()) {
            let ports: Vec<String> = ports
                .iter()
                .filter_map(|v| v.as_integer())
                .map(|p| p.to_string())
                .collect();
            if !ports.is_empty() {
                lines.push(format!(
                    "  networking.firewall.allowedUDPPorts = [ {} ];",
                    ports.join(" ")
                ));
            }
        }
        lines.push(String::new());
    }

    // K3s substrate support. Profiles may point at a token file, but should not
    // embed the token itself because Nix store paths are world-readable.
    if let Some(k3s) = linux.get("k3s") {
        let enabled = k3s.get("enable").and_then(|v| v.as_bool()).unwrap_or(true);
        if enabled {
            lines.push("  # K3s".to_string());
            lines.push("  services.k3s = {".to_string());
            lines.push("    enable = true;".to_string());
            let role = k3s.get("role").and_then(|v| v.as_str()).unwrap_or("server");
            lines.push(format!("    role = {};", nix_string(role)));

            if let Some(cluster_init) = k3s.get("cluster_init").and_then(|v| v.as_bool()) {
                lines.push(format!(
                    "    clusterInit = {};",
                    if cluster_init { "true" } else { "false" }
                ));
            }
            if let Some(disable_agent) = k3s.get("disable_agent").and_then(|v| v.as_bool()) {
                lines.push(format!(
                    "    disableAgent = {};",
                    if disable_agent { "true" } else { "false" }
                ));
            }
            if let Some(server_addr) = k3s.get("server_addr").and_then(|v| v.as_str()) {
                lines.push(format!("    serverAddr = {};", nix_string(server_addr)));
            }
            if let Some(token_file) = k3s.get("token_file").and_then(|v| v.as_str()) {
                lines.push(format!("    tokenFile = {};", nix_string(token_file)));
            }

            let mut flags: Vec<String> = Vec::new();
            if let Some(disabled) = k3s.get("disable").and_then(|v| v.as_array()) {
                for component in disabled.iter().filter_map(|v| v.as_str()) {
                    flags.push(format!("--disable={component}"));
                }
            }
            if let Some(extra_flags) = k3s.get("extra_flags").and_then(|v| v.as_array()) {
                for flag in extra_flags.iter().filter_map(|v| v.as_str()) {
                    flags.push(flag.to_string());
                }
            }
            if !flags.is_empty() {
                lines.push("    extraFlags = [".to_string());
                for flag in flags {
                    lines.push(format!("      {}", nix_string(&flag)));
                }
                lines.push("    ];".to_string());
            }
            lines.push("  };".to_string());
            lines.push(String::new());
        }
    }

    let mut extra_configs = Vec::new();
    if let Some(extra_config) = linux.get("extra_config").and_then(|v| v.as_str()) {
        extra_configs.push(extra_config);
    }
    if let Some(fragments) = linux
        .get("extra_config_fragments")
        .and_then(|v| v.as_array())
    {
        for fragment in fragments.iter().filter_map(|v| v.as_str()) {
            extra_configs.push(fragment);
        }
    }
    for extra_config in extra_configs {
        append_extra_nixos_config(lines, extra_config);
    }
}

fn nix_string(value: &str) -> String {
    format!("{value:?}")
}

fn append_extra_nixos_config(lines: &mut Vec<String>, extra_config: &str) {
    let extra_config = extra_config.trim();
    if extra_config.is_empty() {
        return;
    }

    lines.push("  # Extra NixOS config from profile".to_string());
    for line in extra_config.lines() {
        if line.trim().is_empty() {
            lines.push(String::new());
        } else {
            lines.push(format!("  {}", line));
        }
    }
    lines.push(String::new());
}

/// Generate a legacy polymerize.sh installer script.
/// Superseded by `nex polymerize` but retained for non-nex environments.
#[allow(dead_code)]
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn scaffold_nixos_config_includes_materialization_flake_inputs() {
        let dir = tempfile::tempdir().expect("create temp dir");
        let profile = r#"
[flake_inputs]
dns-dhcp = "github:styrene-lab/dhcp-dns-work"
nixos-hardware = "github:NixOS/nixos-hardware"

[linux]
extra_config = ""
"#;

        scaffold_nixos_config(dir.path(), "test-host", profile).expect("operation should succeed");
        let flake = std::fs::read_to_string(dir.path().join("flake.nix"))
            .expect("operation should succeed");

        assert!(flake.contains("dns-dhcp.url = \"github:styrene-lab/dhcp-dns-work\";"));
        assert!(flake.contains("nixos-hardware.url = \"github:NixOS/nixos-hardware\";"));
        assert!(flake.contains("outputs = { self, nixpkgs, home-manager, ... }@inputs:"));
        assert!(flake.contains("specialArgs = { inherit inputs; username ="));
        assert!(flake.contains("./materialization-module.nix"));
        assert!(dir.path().join("materialization-module.nix").is_file());
        assert!(!dir.path().join("module.nix").exists());
    }

    #[test]
    fn scaffold_nixos_config_rejects_invalid_flake_inputs() {
        let dir = tempfile::tempdir().expect("create temp dir");
        let profile = r#"
[flake_inputs]
bad = "github:owner/repo;rm-rf"
"#;

        let error = scaffold_nixos_config(dir.path(), "test-host", profile)
            .expect_err("invalid flake input rejected");
        assert!(format!("{error:#}").contains("unsupported shell/template characters"));
    }

    #[test]
    fn builds_nixos_toplevel_attr() {
        assert_eq!(
            nixos_toplevel_attr("test-host"),
            ".#nixosConfigurations.test-host.config.system.build.toplevel"
        );
    }

    #[test]
    fn command_uses_eval_attr_and_workspace() {
        let dir = tempfile::tempdir().expect("tempdir");
        std::fs::write(dir.path().join("flake.nix"), "{}").expect("write flake");
        let check = MaterializationCheck {
            workspace: dir.path().to_path_buf(),
            hostname: "test-host".to_string(),
            target: MaterializationTarget::Toplevel,
        };
        let command = check.command().expect("valid command");
        let args = command
            .get_args()
            .map(|arg| arg.to_string_lossy().to_string())
            .collect::<Vec<_>>();

        assert_eq!(
            args,
            vec![
                "--extra-experimental-features".to_string(),
                "nix-command flakes".to_string(),
                "eval".to_string(),
                "--no-update-lock-file".to_string(),
                "--no-write-lock-file".to_string(),
                "--offline".to_string(),
                nixos_toplevel_attr("test-host"),
            ]
        );
        assert_eq!(command.get_current_dir(), Some(dir.path()));
    }

    #[test]
    fn rejects_invalid_hostname() {
        let error = validate_hostname("bad/host").expect_err("invalid hostname rejected");
        assert!(format!("{error:#}").contains("hostname must contain only"));
    }

    #[test]
    fn rejects_workspace_without_flake() {
        let dir = tempfile::tempdir().expect("tempdir");
        let error = validate_workspace(dir.path()).expect_err("missing flake rejected");
        assert!(format!("{error:#}").contains("does not contain flake.nix"));
    }

    #[test]
    fn parses_and_renders_flake_inputs() {
        let payload = MaterializationPayload::from_toml_str(
            r#"
[flake_inputs]
dns-dhcp = "github:styrene-lab/dhcp-dns-work"
nixos-hardware = "github:NixOS/nixos-hardware"
"#,
        )
        .expect("valid payload");

        let rendered = render_flake_inputs(&payload.flake_inputs);

        assert!(rendered.contains("    dns-dhcp.url = \"github:styrene-lab/dhcp-dns-work\";"));
        assert!(rendered.contains("    nixos-hardware.url = \"github:NixOS/nixos-hardware\";"));
    }

    #[test]
    fn rejects_invalid_flake_input_names() {
        let error = MaterializationPayload::from_toml_str(
            r#"
[flake_inputs]
"9bad" = "github:owner/repo"
"#,
        )
        .expect_err("invalid input name rejected");
        assert!(format!("{error:#}").contains("must start with a letter or underscore"));
    }

    #[test]
    fn rejects_suspicious_flake_input_refs() {
        let error = MaterializationPayload::from_toml_str(
            r#"
[flake_inputs]
bad = "github:owner/repo;rm-rf"
"#,
        )
        .expect_err("invalid input ref rejected");
        assert!(format!("{error:#}").contains("unsupported shell/template characters"));
    }
    #[test]
    fn parses_explicit_materialization_system() {
        let payload = MaterializationPayload::from_toml_str(
            r#"
system = "aarch64-linux"
"#,
        )
        .expect("valid payload");
        assert_eq!(payload.system.as_deref(), Some("aarch64-linux"));
    }

    #[test]
    fn rejects_invalid_materialization_system() {
        let error = MaterializationPayload::from_toml_str(
            r#"
system = "mips-linux"
"#,
        )
        .expect_err("invalid system rejected");
        assert!(format!("{error:#}").contains("unsupported nix system"));
    }

    #[test]
    fn materialization_target_builds_sd_image_attr() {
        let target = MaterializationTarget::parse("sd-image").expect("operation should succeed");
        assert_eq!(
            target.attr("test-host"),
            ".#nixosConfigurations.test-host.config.system.build.sdImage"
        );
    }

    #[test]
    fn renders_nixos_module_extra_config() {
        let payload = MaterializationPayload {
            system: None,
            flake_inputs: BTreeMap::new(),
            nixos_module: NixosModulePayload {
                extra_config: vec!["services.openssh.enable = true;".to_string()],
            },
        };
        let module = render_nixos_module(&payload);
        assert!(module.contains("services.openssh.enable = true;"));
    }

    #[test]
    fn rejects_impure_extra_config_fetches() {
        let error = MaterializationPayload::from_toml_str(
            r#"
[nixos_module]
extra_config = ["let x = builtins.getFlake \"github:owner/repo\"; in {}"]
"#,
        )
        .expect_err("impure fetch rejected");
        assert!(format!("{error:#}").contains("declare flake_inputs instead"));
    }

    #[test]
    fn build_command_uses_deterministic_flags_and_out_link() {
        let dir = tempfile::tempdir().expect("tempdir");
        std::fs::write(dir.path().join("flake.nix"), "{}").expect("write flake");
        let build = MaterializationBuild {
            workspace: dir.path().to_path_buf(),
            hostname: "test-host".to_string(),
            target: MaterializationTarget::SdImage,
            out_link: dir.path().join("result-sd-image"),
        };
        let command = build.command().expect("valid command");
        let args = command
            .get_args()
            .map(|arg| arg.to_string_lossy().to_string())
            .collect::<Vec<_>>();

        assert!(args.contains(&"build".to_string()));
        assert!(args.contains(&"--offline".to_string()));
        assert!(args.contains(&"--no-update-lock-file".to_string()));
        assert!(args.contains(&"--no-write-lock-file".to_string()));
        assert!(args.contains(&"--out-link".to_string()));
        assert!(args.contains(&nixos_sd_image_attr("test-host")));
    }
}
