use std::fmt::Write as _;
use std::path::Path;

use cabin_build::BuildGraph;
use cabin_driver::{Dialect, LoweredAction, LoweredActionKind, NinjaDeps, lower};
use cabin_fs::write_atomic;

use crate::error::NinjaError;

/// Write a `build.ninja` describing `graph` to `path`.
///
/// Replacement is atomic: the new bytes land in a sibling
/// temporary file and only rename onto `path` after a successful
/// write, so an interrupted run leaves the previous `build.ninja`
/// in place. The parent directory must already exist.
///
/// `check_stamp_runner` is the path to the `cabin` executable; the
/// syntax-check rule invokes it as `cabin stamp` to run the
/// compiler and stamp the check output without a shell (see
/// [`render_build_ninja`]).
///
/// # Errors
/// Propagates rendering failures from [`render_build_ninja`]
/// ([`NinjaError::PathHasNewline`], [`NinjaError::ValueHasNewline`], or
/// [`NinjaError::UnquotableArgument`]), and returns [`NinjaError::Io`]
/// when the atomic write to `path` fails (for example, when the parent
/// directory is missing).
pub fn write_build_ninja(
    path: &Path,
    graph: &BuildGraph,
    check_stamp_runner: &Path,
) -> Result<(), NinjaError> {
    let body = render_build_ninja(graph, check_stamp_runner)?;
    atomically_write(path, body.as_bytes())
}

/// Atomically replace `path` with `body`, tagging any IO error
/// with `path` so callers can point users at the destination
/// that failed.
pub(crate) fn atomically_write(path: &Path, body: &[u8]) -> Result<(), NinjaError> {
    write_atomic(path, body).map_err(|source| NinjaError::Io {
        path: path.to_path_buf(),
        source,
    })
}

/// Render `graph` as a Ninja build file.
///
/// Pulled out so unit tests can exercise the formatter without touching the
/// filesystem.
///
/// Each semantic [`cabin_build::BuildAction`] is lowered to a concrete
/// command via [`cabin_driver::lower()`] immediately before its edge is
/// rendered — the single point where compile/archive/link intent
/// becomes a command line. The dialect (today fixed to
/// [`Dialect::GnuLike`]) selects the spelling, so the same actions
/// render as GNU/Clang or MSVC without this writer changing.
///
/// # Errors
/// Returns [`NinjaError::PathHasNewline`] when a path token contains a
/// newline, [`NinjaError::ValueHasNewline`] when a command or
/// description value contains a newline or carriage return, and
/// [`NinjaError::UnquotableArgument`] when a command argument cannot be
/// shell-quoted.
pub fn render_build_ninja(
    graph: &BuildGraph,
    check_stamp_runner: &Path,
) -> Result<String, NinjaError> {
    let mut out = String::new();
    out.push_str("# Generated by cabin. Do not edit by hand.\n");
    out.push_str("ninja_required_version = 1.10\n\n");

    // Header-dependency discovery is dialect-specific: the GNU/Clang
    // dialect pairs a Makefile `depfile` with `deps = gcc`; MSVC parses
    // `cl /showIncludes` stdout with `deps = msvc` and an
    // `msvc_deps_prefix`.
    let deps = compile_deps_lines(graph.dialect);
    // Syntax-only checks (`cabin check`) produce no object, so the rule
    // stamps an output to record success and keep Ninja's incrementality
    // working. The compiler argv is bound to `$checkcmd` (not
    // `$command`): an edge-level `command` binding would shadow the
    // rule's `command` line entirely, dropping the stamp tail.
    let check_command = check_command_line(check_stamp_runner)?;

    out.push_str("rule c_compile\n");
    out.push_str("  command = $command\n");
    out.push_str(&deps);
    out.push_str("  description = $description\n\n");

    out.push_str("rule cxx_compile\n");
    out.push_str("  command = $command\n");
    out.push_str(&deps);
    out.push_str("  description = $description\n\n");

    out.push_str("rule c_check\n  command = ");
    out.push_str(&check_command);
    out.push('\n');
    out.push_str(&deps);
    out.push_str("  description = $description\n\n");

    out.push_str("rule cxx_check\n  command = ");
    out.push_str(&check_command);
    out.push('\n');
    out.push_str(&deps);
    out.push_str("  description = $description\n\n");

    out.push_str("rule cxx_archive\n");
    out.push_str("  command = $command\n");
    out.push_str("  description = $description\n\n");

    out.push_str("rule link_executable\n");
    out.push_str("  command = $command\n");
    out.push_str("  description = $description\n\n");

    for action in &graph.actions {
        // Lower semantic intent to a concrete command for the graph's
        // dialect right at the backend boundary, then render the edge.
        let lowered = lower(graph.dialect, action);
        write_edge(&mut out, &lowered)?;
    }

    if !graph.default_outputs.is_empty() {
        out.push_str("default");
        for output in &graph.default_outputs {
            out.push(' ');
            out.push_str(&escape_path(output.as_str())?);
        }
        out.push('\n');
    }

    Ok(out)
}

/// The `depfile` / `deps` (and, for MSVC, `msvc_deps_prefix`) lines a
/// compile or check rule needs for header-dependency discovery in
/// `dialect`. Returned with trailing newlines, ready to splice into a
/// rule body.
fn compile_deps_lines(dialect: Dialect) -> String {
    match dialect.ninja_deps() {
        NinjaDeps::Gcc => "  depfile = $depfile\n  deps = gcc\n".to_owned(),
        NinjaDeps::Msvc { prefix } => format!("  deps = msvc\n  msvc_deps_prefix = {prefix}\n"),
    }
}

/// The `command` line for a syntax-check rule. A syntax-only compile
/// produces no object, so the rule must run the compiler (`$checkcmd`)
/// and, on success, stamp `$out` to record it for Ninja's
/// incrementality.
///
/// Rather than chain `compiler && stamp` through a shell — which differs
/// per host (`&&` + `touch` under POSIX `/bin/sh`, `cmd /c "… && type
/// nul"` under Windows `CreateProcess`) and forces shell-metacharacter
/// escaping the per-edge `$checkcmd` quoting cannot provide — Cabin runs
/// the compiler through its own `cabin stamp <stamp> -- <argv…>` witness
/// writer. That spawns the compiler directly (no shell), and writes the
/// stamp only on a zero exit. One host-independent rule, and no second
/// parsing layer to escape for — this removes the cmd.exe round-trip that
/// mangled paths with `&` / `|` / `()`.
///
/// Only the `cabin` runner token is quoted, using the same platform-aware
/// quoting (`command_line`: POSIX `/bin/sh` vs Windows `CreateProcess`)
/// every other edge command uses, then `$`-escaped for Ninja. A runner
/// path containing `$` is therefore single-quoted by `shlex` so POSIX
/// `/bin/sh` does not expand it. `$out` and `$checkcmd` stay raw so Ninja
/// expands them as the edge's stamp output and per-edge compiler argv.
fn check_command_line(runner: &Path) -> Result<String, NinjaError> {
    let runner_str = runner.to_string_lossy().into_owned();
    let runner_token = escape_value(&command_line(std::slice::from_ref(&runner_str))?)?;
    Ok(format!("{runner_token} stamp $out -- $checkcmd"))
}

fn write_edge(out: &mut String, action: &LoweredAction) -> Result<(), NinjaError> {
    let rule = match action.kind {
        LoweredActionKind::CompileC => "c_compile",
        LoweredActionKind::CompileCpp => "cxx_compile",
        LoweredActionKind::SyntaxCheckC => "c_check",
        LoweredActionKind::SyntaxCheckCpp => "cxx_check",
        LoweredActionKind::ArchiveStaticLibrary => "cxx_archive",
        LoweredActionKind::LinkExecutable => "link_executable",
    };

    out.push_str("build ");
    let mut first = true;
    for output in &action.outputs {
        if !first {
            out.push(' ');
        }
        first = false;
        out.push_str(&escape_path(output.as_str())?);
    }
    out.push_str(": ");
    out.push_str(rule);
    for input in &action.inputs {
        out.push(' ');
        out.push_str(&escape_path(input.as_str())?);
    }
    if !action.implicit_inputs.is_empty() {
        out.push_str(" |");
        for input in &action.implicit_inputs {
            out.push(' ');
            out.push_str(&escape_path(input.as_str())?);
        }
    }
    out.push('\n');

    // Ninja launches each edge's command itself, so the argv is quoted
    // for the platform that will run it (POSIX shell vs. Windows
    // `CreateProcess`) — distinct from `compile_commands.json`, whose
    // `command` field is always Bash-quoted per the Clang spec.
    let command_value = command_line(&action.command)?;
    // Check edges bind the argv to `$checkcmd` so the `c_check` /
    // `cxx_check` rule can run it through the shell-free
    // `cabin stamp $out -- $checkcmd` witness writer (which stamps
    // `$out` only on a zero exit); every other edge binds the plain
    // `command` the rule runs verbatim.
    let command_var = match action.kind {
        LoweredActionKind::SyntaxCheckC | LoweredActionKind::SyntaxCheckCpp => "checkcmd",
        _ => "command",
    };
    write_var(out, command_var, &command_value)?;
    if let Some(depfile) = &action.depfile {
        write_var(out, "depfile", depfile.as_str())?;
    }
    write_var(out, "description", &action.description)?;
    out.push('\n');

    Ok(())
}

fn write_var(out: &mut String, key: &str, value: &str) -> Result<(), NinjaError> {
    // Ninja indents variable definitions inside a build edge with two
    // spaces. Within a value, `$` is meaningful (variable references
    // and line continuation) and a literal newline ends the value;
    // `escape_value` doubles `$` and rejects newlines so a manifest
    // string cannot inject a fresh statement.
    let _ = writeln!(out, "  {key} = {}", escape_value(value)?);
    Ok(())
}

/// Escape a path for use as a token inside a Ninja `build` edge.
///
/// In token positions, Ninja recognizes `$$` for a literal `$`, `$ ` for a
/// literal space, and `$:` for a literal colon. A literal newline cannot be
/// represented and is rejected.
pub(crate) fn escape_path(s: &str) -> Result<String, NinjaError> {
    if s.contains('\n') {
        return Err(NinjaError::PathHasNewline(s.into()));
    }
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            ' ' => out.push_str("$ "),
            ':' => out.push_str("$:"),
            '$' => out.push_str("$$"),
            other => out.push(other),
        }
    }
    Ok(out)
}

/// Escape a value for the right-hand side of a Ninja variable assignment.
///
/// In value positions `$` is special (used for variable references and
/// line continuation). Spaces are preserved verbatim. A literal `\n` or
/// `\r` would terminate the variable line and is rejected so a
/// manifest-derived value cannot inject sibling Ninja declarations.
pub(crate) fn escape_value(s: &str) -> Result<String, NinjaError> {
    if s.contains('\n') || s.contains('\r') {
        return Err(NinjaError::ValueHasNewline(s.to_owned()));
    }
    Ok(s.replace('$', "$$"))
}

/// Join an argv into the single command string Ninja will hand to the
/// platform's process launcher for a `build.ninja` edge.
///
/// The quoting convention is dictated by *how Ninja runs the command*,
/// which is platform- (not dialect-) specific: on POSIX it runs each
/// command through `/bin/sh`, so arguments are POSIX-shell-quoted; on
/// Windows it calls `CreateProcess` with no shell, so arguments follow
/// the `CommandLineToArgvW` convention `CreateProcess` uses to split
/// them back apart. A MinGW (GCC-dialect) build on Windows still goes
/// through the same `CreateProcess`, so the axis is the host OS.
///
/// This is deliberately separate from [`shell_join`]: the
/// `compile_commands.json` `command` field is specified to use Bash
/// quoting on every platform, so it stays on [`shell_join`].
fn command_line(args: &[String]) -> Result<String, NinjaError> {
    if cfg!(windows) {
        windows_join(args)
    } else {
        shell_join(args)
    }
}

/// Join an argv into a single POSIX-shell command line (also the Bash
/// quoting the Clang compilation-database `command` field requires on
/// every platform).
///
/// Each argument is quoted via [`shlex::try_join`] so a value
/// containing spaces, quotes, dollar signs, backslashes, or other shell
/// metacharacters round-trips back through `sh` (or Clang's Bash-rules
/// unescaper) to the same argv. Returns
/// [`NinjaError::UnquotableArgument`] when `shlex` refuses an argument
/// (in practice, a NUL byte).
pub(crate) fn shell_join(args: &[String]) -> Result<String, NinjaError> {
    shlex::try_join(args.iter().map(String::as_str)).map_err(|_| {
        let bad = args
            .iter()
            .find(|a| a.contains('\0'))
            .cloned()
            .unwrap_or_default();
        NinjaError::UnquotableArgument(bad)
    })
}

/// Join an argv into a single Windows command line, following the
/// `CommandLineToArgvW` convention `CreateProcess` uses to split it
/// back into arguments. This is the quoting Ninja needs on Windows,
/// where it launches commands with no shell.
///
/// Returns [`NinjaError::UnquotableArgument`] for an argument
/// containing a NUL byte (which cannot appear in a Windows command
/// line), matching [`shell_join`]'s contract.
fn windows_join(args: &[String]) -> Result<String, NinjaError> {
    let mut out = String::new();
    for (i, arg) in args.iter().enumerate() {
        if arg.contains('\0') {
            return Err(NinjaError::UnquotableArgument(arg.clone()));
        }
        if i > 0 {
            out.push(' ');
        }
        windows_quote_arg(arg, &mut out);
    }
    Ok(out)
}

/// Append one argument to `out` using `CommandLineToArgvW` quoting.
///
/// An argument is emitted verbatim unless it is empty or contains a
/// space, tab, or double-quote — so backslash-laden Windows paths like
/// `/FoC:\build\main.obj` pass through untouched, while
/// `C:\Program Files\…\cl.exe` is wrapped in double quotes. Inside a
/// quoted argument, a run of N backslashes is doubled to 2N when it
/// precedes the closing quote, and emitted as 2N+1 (then `\"`) when it
/// precedes an embedded double-quote; elsewhere backslashes are
/// literal.
fn windows_quote_arg(arg: &str, out: &mut String) {
    if !arg.is_empty() && !arg.contains([' ', '\t', '"']) {
        out.push_str(arg);
        return;
    }
    out.push('"');
    let mut backslashes = 0usize;
    for c in arg.chars() {
        match c {
            '\\' => backslashes += 1,
            '"' => {
                // These backslashes precede a quote: escape each, then
                // the quote itself.
                for _ in 0..=backslashes * 2 {
                    out.push('\\');
                }
                out.push('"');
                backslashes = 0;
            }
            other => {
                for _ in 0..backslashes {
                    out.push('\\');
                }
                backslashes = 0;
                out.push(other);
            }
        }
    }
    // Trailing backslashes precede the closing quote, so double them.
    for _ in 0..backslashes * 2 {
        out.push('\\');
    }
    out.push('"');
}

#[cfg(test)]
mod tests {
    use super::*;
    use cabin_build::{
        ArchiveAction, BuildAction, BuildGraph, CompileAction, CompileArguments, CompileCommand,
        CompileMode, LinkAction,
    };
    use cabin_core::{OptLevel, SourceLanguage};
    use camino::Utf8PathBuf;

    // These fixtures are *semantic* actions; the writer lowers them to
    // concrete commands as it renders, so the assertions below check
    // the GNU/Clang lowering as it appears in `build.ninja`.

    fn compile_action() -> BuildAction {
        BuildAction::Compile(CompileAction {
            language: SourceLanguage::Cxx,
            source: Utf8PathBuf::from("/abs/src/main.cc"),
            object: Utf8PathBuf::from("/abs/build/main.o"),
            mode: CompileMode::Object,
            implicit_inputs: vec![],
            depfile: Some(Utf8PathBuf::from("/abs/build/main.o.d")),
            compiler: Utf8PathBuf::from("/usr/bin/g++"),
            compiler_wrapper: None,
            arguments: CompileArguments {
                opt_level: OptLevel::O0,
                debug_info: false,
                define_ndebug: false,
                include_dirs: vec![],
                defines: vec![],
                extra_flags: vec![],
            },
            description: "CXX /abs/build/main.o".into(),
        })
    }

    /// Clone the compile fixture and tweak its inner action. Keeps the
    /// edge-mutation tests legible now that the field lives inside a
    /// [`BuildAction::Compile`].
    fn compile_with(f: impl FnOnce(&mut CompileAction)) -> BuildAction {
        let BuildAction::Compile(mut c) = compile_action() else {
            unreachable!("compile_action builds a compile");
        };
        f(&mut c);
        BuildAction::Compile(c)
    }

    fn archive_action() -> BuildAction {
        BuildAction::Archive(ArchiveAction {
            archiver: Utf8PathBuf::from("/usr/bin/ar"),
            output: Utf8PathBuf::from("/abs/build/libfoo.a"),
            inputs: vec![Utf8PathBuf::from("/abs/build/main.o")],
            description: "AR /abs/build/libfoo.a".into(),
        })
    }

    fn link_action() -> BuildAction {
        BuildAction::Link(LinkAction {
            linker: Utf8PathBuf::from("/usr/bin/g++"),
            output: Utf8PathBuf::from("/abs/build/hello"),
            inputs: vec![Utf8PathBuf::from("/abs/build/main.o")],
            implicit_inputs: vec![],
            arguments: vec![],
            link_libs: vec![],
            description: "LINK /abs/build/hello".into(),
        })
    }

    fn syntax_check_action() -> BuildAction {
        BuildAction::Compile(CompileAction {
            language: SourceLanguage::Cxx,
            source: Utf8PathBuf::from("/abs/src/main.cc"),
            object: Utf8PathBuf::from("/abs/build/main.o"),
            mode: CompileMode::SyntaxOnly {
                stamp: Utf8PathBuf::from("/abs/build/main.o.check"),
            },
            implicit_inputs: vec![],
            depfile: Some(Utf8PathBuf::from("/abs/build/main.o.d")),
            compiler: Utf8PathBuf::from("/usr/bin/g++"),
            compiler_wrapper: None,
            arguments: CompileArguments {
                opt_level: OptLevel::O0,
                debug_info: false,
                define_ndebug: false,
                include_dirs: vec![],
                defines: vec![],
                extra_flags: vec![],
            },
            description: "CHECK /abs/build/main.o".into(),
        })
    }

    fn graph_with(actions: Vec<BuildAction>, defaults: Vec<Utf8PathBuf>) -> BuildGraph {
        BuildGraph {
            actions,
            dialect: Dialect::GnuLike,
            default_outputs: defaults,
            compile_commands: Vec::<CompileCommand>::new(),
        }
    }

    /// Render with a fixed `cabin` runner path so the syntax-check rule
    /// is deterministic across hosts (the real path comes from
    /// `std::env::current_exe()` at build time).
    fn render(graph: &BuildGraph) -> Result<String, NinjaError> {
        render_build_ninja(graph, Path::new("/opt/cabin/bin/cabin"))
    }

    #[test]
    fn renders_all_three_rule_kinds() {
        let body = render(&graph_with(
            vec![compile_action(), archive_action(), link_action()],
            vec![Utf8PathBuf::from("/abs/build/hello")],
        ))
        .unwrap();
        // Two compile rules — one per language — plus archive
        // and the language-neutral link rule. The link rule name
        // does not encode a language because the link driver is
        // selected per target by the planner and threaded into
        // the action's command, not into the rule name.
        assert!(body.contains("rule c_compile"));
        assert!(body.contains("rule cxx_compile"));
        assert!(body.contains("rule cxx_archive"));
        assert!(body.contains("rule link_executable"));
    }

    #[test]
    fn includes_default_targets() {
        let body = render(&graph_with(
            vec![compile_action()],
            vec![Utf8PathBuf::from("/abs/build/main.o")],
        ))
        .unwrap();
        assert!(body.contains("default /abs/build/main.o"));
    }

    #[test]
    fn compile_edge_carries_depfile() {
        let body = render(&graph_with(vec![compile_action()], vec![])).unwrap();
        assert!(body.contains("build /abs/build/main.o: cxx_compile /abs/src/main.cc"));
        assert!(body.contains("depfile = /abs/build/main.o.d"));
        assert!(body.contains("deps = gcc"));
    }

    /// Build a graph that renders for the MSVC dialect.
    fn msvc_graph_with(actions: Vec<BuildAction>) -> BuildGraph {
        BuildGraph {
            actions,
            dialect: Dialect::Msvc,
            default_outputs: vec![],
            compile_commands: Vec::<CompileCommand>::new(),
        }
    }

    #[test]
    fn msvc_dialect_uses_showincludes_deps_not_depfile() {
        let body = render(&msvc_graph_with(vec![compile_action()])).unwrap();
        // MSVC compiles discover headers through `cl /showIncludes`,
        // parsed by Ninja's `deps = msvc` + `msvc_deps_prefix` — never a
        // Makefile depfile or `deps = gcc`.
        assert!(body.contains("deps = msvc"));
        assert!(body.contains("msvc_deps_prefix = Note: including file:"));
        assert!(!body.contains("deps = gcc"));
        assert!(!body.contains("depfile ="));
        assert!(body.contains("/showIncludes"));
    }

    /// The host-independent check command the rule must carry: the
    /// `cabin stamp` witness writer (no shell, no `cfg!(windows)` split),
    /// spelled with the fixed test runner from [`render`]. The runner
    /// path has no shell metacharacters, so it is unquoted on both POSIX
    /// and Windows.
    fn expected_check_command() -> &'static str {
        "command = /opt/cabin/bin/cabin stamp $out -- $checkcmd"
    }

    #[test]
    fn msvc_check_rule_uses_zs_with_shellfree_stamp() {
        let body = render(&msvc_graph_with(vec![syntax_check_action()])).unwrap();
        // The compile content is MSVC-specific (`/Zs`), but the stamp
        // runner is the same host-independent `cabin stamp` invocation
        // for either dialect.
        assert!(body.contains("/Zs"));
        assert!(body.contains(expected_check_command()));
    }

    #[test]
    fn check_runner_path_is_shell_quoted_and_ninja_escaped() {
        // Adversarial runner path: a `$` plus a space. The old quoting
        // wrapped the token in *double* quotes, so POSIX `/bin/sh` would
        // expand `$h` and invoke the wrong path. The runner must instead
        // be quoted with the platform's real argv quoting (so the shell
        // treats `$` literally) and then `$`-escaped for Ninja, while
        // `$out` / `$checkcmd` stay raw Ninja variables.
        let line = check_command_line(Path::new("/tmp/ca$h dir/cabin")).unwrap();
        // `$out` and `$checkcmd` survive verbatim as single-`$` vars.
        assert!(line.ends_with(" stamp $out -- $checkcmd"), "{line}");
        // The runner path's literal `$` is doubled for Ninja (Ninja then
        // hands a single `$` to the shell) — never a bare `$` that Ninja
        // would read as a variable reference.
        assert!(line.contains("ca$$h"), "{line}");
        // POSIX: `shlex` single-quotes the whole token so `/bin/sh`
        // performs no `$` / backtick expansion on it.
        #[cfg(unix)]
        assert!(line.starts_with("'/tmp/ca$$h dir/cabin'"), "{line}");
    }

    #[test]
    fn link_edge_uses_link_rule() {
        let body = render(&graph_with(vec![link_action()], vec![])).unwrap();
        assert!(body.contains("build /abs/build/hello: link_executable /abs/build/main.o"));
    }

    #[test]
    fn archive_edge_uses_archive_rule() {
        let body = render(&graph_with(vec![archive_action()], vec![])).unwrap();
        assert!(body.contains("build /abs/build/libfoo.a: cxx_archive /abs/build/main.o"));
    }

    #[test]
    fn renders_syntax_check_rule_and_edge() {
        let body = render(&graph_with(vec![syntax_check_action()], vec![])).unwrap();
        // The check rule stamps the output after a successful syntax-only
        // compile (via the shell-free `cabin stamp` witness writer) so
        // Ninja records completion, and keeps depfile + `deps = gcc` for
        // header-edit incrementality.
        assert!(body.contains("rule cxx_check"));
        assert!(body.contains(expected_check_command()));
        assert!(body.contains("build /abs/build/main.o.check: cxx_check /abs/src/main.cc"));
        // The argv binds to `$checkcmd` (not `command`) so the rule's
        // stamp tail is not shadowed by the edge binding.
        assert!(body.contains("checkcmd = /usr/bin/g++"));
        assert!(body.contains("-fsyntax-only"));
        assert!(body.contains("depfile = /abs/build/main.o.d"));
        // The C variant rule is always emitted alongside the C++ one.
        assert!(body.contains("rule c_check"));
    }

    #[test]
    fn implicit_inputs_render_with_pipe() {
        let act =
            compile_with(|c| c.implicit_inputs = vec![Utf8PathBuf::from("/abs/build/generated.h")]);
        let body = render(&graph_with(vec![act], vec![])).unwrap();
        assert!(body.contains("| /abs/build/generated.h"));
    }

    #[test]
    fn escape_path_handles_specials() {
        assert_eq!(escape_path("simple/path").unwrap(), "simple/path");
        assert_eq!(escape_path("with space").unwrap(), "with$ space");
        assert_eq!(escape_path("with$dollar").unwrap(), "with$$dollar");
        assert_eq!(escape_path("foo:bar").unwrap(), "foo$:bar");
        assert!(escape_path("with\nnewline").is_err());
    }

    #[test]
    fn escape_value_only_doubles_dollars() {
        assert_eq!(escape_value("hello world").unwrap(), "hello world");
        assert_eq!(escape_value("$VAR").unwrap(), "$$VAR");
    }

    #[test]
    fn escape_value_rejects_newlines_and_carriage_returns() {
        // A literal newline or CR inside a value would terminate the
        // Ninja variable line and let the next-line content be
        // re-interpreted as a fresh top-level declaration. Reject so
        // a manifest-controlled string cannot smuggle in `rule` or
        // `command` overrides.
        assert!(matches!(
            escape_value("foo\nbar"),
            Err(NinjaError::ValueHasNewline(_))
        ));
        assert!(matches!(
            escape_value("foo\rbar"),
            Err(NinjaError::ValueHasNewline(_))
        ));
    }

    #[test]
    fn newline_in_description_aborts_render() {
        let act = compile_with(|c| c.description = "CXX foo.o\n  rule injected".into());
        let err = render(&graph_with(vec![act], vec![])).unwrap_err();
        assert!(
            matches!(err, NinjaError::ValueHasNewline(_)),
            "expected ValueHasNewline, got {err:?}"
        );
    }

    #[test]
    fn newline_in_command_aborts_render() {
        // The newline rides in on a compile flag, which lowering copies
        // verbatim into the command argv.
        let act = compile_with(|c| {
            c.arguments
                .extra_flags
                .push("payload\n  command = /bin/rm".into());
        });
        let err = render(&graph_with(vec![act], vec![])).unwrap_err();
        assert!(
            matches!(err, NinjaError::ValueHasNewline(_)),
            "expected ValueHasNewline, got {err:?}"
        );
    }

    #[test]
    fn shell_join_passes_safe_args_through() {
        // shlex emits POSIX-safe tokens (letters, digits, and a
        // narrow punctuation set including `-`, `.`, `/`, `+`)
        // verbatim.  Args within that set must not gain any
        // quoting.
        assert_eq!(
            shell_join(&["simple".into(), "/abs/path/foo.cc".into(), "-c".into()]).unwrap(),
            "simple /abs/path/foo.cc -c",
        );
    }

    #[test]
    fn shell_join_quotes_argument_with_space() {
        let joined = shell_join(&["with space".into()]).unwrap();
        // Round-trip back through `shlex::split` recovers the
        // exact argv, regardless of whether the formatter chose
        // single or double quotes.
        assert_eq!(shlex::split(&joined).unwrap(), vec!["with space"]);
    }

    #[test]
    fn shell_join_quotes_argument_with_dollar_or_metacharacters() {
        for arg in ["$VAR", "a;b", "a|b", "a&b", "*.cc", "`echo`"] {
            let joined = shell_join(&[arg.into()]).unwrap();
            assert_eq!(
                shlex::split(&joined).unwrap(),
                vec![arg.to_owned()],
                "{arg:?} must round-trip through shell quoting",
            );
        }
    }

    #[test]
    fn shell_join_quotes_argument_with_single_quote() {
        let joined = shell_join(&["it's".into()]).unwrap();
        assert_eq!(shlex::split(&joined).unwrap(), vec!["it's"]);
    }

    #[test]
    fn shell_join_handles_empty_argument() {
        let joined = shell_join(&[String::new()]).unwrap();
        assert_eq!(shlex::split(&joined).unwrap(), vec![""]);
    }

    #[test]
    fn shell_join_rejects_nul_byte() {
        let err = shell_join(&["a\0b".into()]).unwrap_err();
        assert!(
            matches!(err, NinjaError::UnquotableArgument(ref s) if s == "a\0b"),
            "expected UnquotableArgument(a\\0b), got {err:?}",
        );
    }

    // ---- Windows command-line quoting (CommandLineToArgvW). ----
    // These exercise the pure joiner directly so the Windows quoting is
    // validated on every host, not only on a Windows runner.

    #[test]
    fn windows_join_leaves_backslash_paths_and_flags_verbatim() {
        // The common MSVC argv: a bare compiler name, slash flags, and
        // backslash-laden object/source paths — none contain a space,
        // tab, or quote, so all pass through untouched. (POSIX `shlex`
        // would single-quote the backslash paths, which Windows
        // `CreateProcess` cannot launch — the whole reason this exists.)
        let joined = windows_join(&[
            "cl.exe".into(),
            "/nologo".into(),
            "/c".into(),
            r"/TpC:\build\src\main.cc".into(),
            r"/FoC:\build\obj\main.obj".into(),
        ])
        .unwrap();
        assert_eq!(
            joined,
            r"cl.exe /nologo /c /TpC:\build\src\main.cc /FoC:\build\obj\main.obj"
        );
    }

    #[test]
    fn windows_join_double_quotes_compiler_path_with_spaces() {
        // The exact failure the quoter fixes: a PATH-resolved cl under
        // `Program Files`. Backslashes that don't precede a quote stay
        // single; the argument is wrapped so `CreateProcess` reads the
        // whole path as argv[0].
        let joined = windows_join(&[
            r"C:\Program Files\Microsoft Visual Studio\VC\cl.exe".into(),
            "/nologo".into(),
        ])
        .unwrap();
        assert_eq!(
            joined,
            "\"C:\\Program Files\\Microsoft Visual Studio\\VC\\cl.exe\" /nologo"
        );
    }

    #[test]
    fn windows_quote_doubles_trailing_backslashes_before_closing_quote() {
        // A trailing backslash inside a quoted arg would escape the
        // closing quote, so a run of N is doubled to 2N.
        let mut out = String::new();
        windows_quote_arg(r"C:\dir with space\", &mut out);
        assert_eq!(out, "\"C:\\dir with space\\\\\"");
    }

    #[test]
    fn windows_quote_escapes_embedded_quote_and_its_backslashes() {
        // An embedded `"` is escaped as `\"`, and a run of N backslashes
        // immediately before it becomes 2N+1 backslashes.
        let mut zero = String::new();
        windows_quote_arg(r#"a "b"#, &mut zero); // no backslash before the quote
        assert_eq!(zero, "\"a \\\"b\"");

        let mut one = String::new();
        windows_quote_arg(r#"a\"b"#, &mut one); // one backslash before the quote
        assert_eq!(one, "\"a\\\\\\\"b\"");
    }

    #[test]
    fn windows_join_quotes_empty_argument() {
        // An empty argv element must survive as an empty token, which
        // requires explicit quotes.
        assert_eq!(windows_join(&[String::new()]).unwrap(), "\"\"");
    }

    #[test]
    fn windows_join_rejects_nul_byte() {
        let err = windows_join(&["a\0b".into()]).unwrap_err();
        assert!(
            matches!(err, NinjaError::UnquotableArgument(ref s) if s == "a\0b"),
            "expected UnquotableArgument(a\\0b), got {err:?}",
        );
    }

    #[test]
    fn render_includes_full_command_in_value() {
        let body = render(&graph_with(vec![compile_action()], vec![])).unwrap();
        // The build edge's `command = ` line round-trips back
        // through POSIX shell-style splitting to the exact argv
        // the action declared.  Tying the assertion to the
        // round-trip rather than a fixed quoted form keeps the
        // test stable when shlex tweaks its formatter.  The
        // Rule-template `command =` lines reference Ninja variables
        // (`$command`, `$checkcmd`, `$out`); the edge's command is the
        // literal argv with no `$`, so pick the line without one.
        let command_line = body
            .lines()
            .filter_map(|l| l.trim_start().strip_prefix("command = "))
            .find(|l| !l.contains('$'))
            .expect("edge command = line present");
        let split = shlex::split(command_line).expect("command must round-trip");
        // The edge carries the *lowered* GNU/Clang argv, including the
        // `-MMD -MF <depfile>` dependency block the writer's lowering
        // injects for a depfile-bearing compile.
        assert_eq!(
            split,
            vec![
                "/usr/bin/g++",
                "-std=c++17",
                "-O0",
                "-MMD",
                "-MF",
                "/abs/build/main.o.d",
                "-c",
                "/abs/src/main.cc",
                "-o",
                "/abs/build/main.o",
            ],
        );
    }

    #[test]
    fn render_propagates_unquotable_argument_error() {
        let act = compile_with(|c| c.arguments.extra_flags.push("evil\0arg".into()));
        let err = render(&graph_with(vec![act], vec![])).unwrap_err();
        assert!(
            matches!(err, NinjaError::UnquotableArgument(_)),
            "expected UnquotableArgument, got {err:?}",
        );
    }

    #[test]
    fn write_build_ninja_creates_file_with_rendered_body() {
        let dir = assert_fs::TempDir::new().unwrap();
        let path = dir.path().join("build.ninja");
        let graph = graph_with(
            vec![compile_action()],
            vec![Utf8PathBuf::from("/abs/build/main.o")],
        );
        write_build_ninja(&path, &graph, Path::new("/opt/cabin/bin/cabin")).unwrap();
        let body = std::fs::read_to_string(&path).unwrap();
        assert_eq!(body, render(&graph).unwrap());
    }

    #[test]
    fn write_build_ninja_replaces_existing_contents() {
        let dir = assert_fs::TempDir::new().unwrap();
        let path = dir.path().join("build.ninja");
        std::fs::write(&path, "stale\n").unwrap();
        let graph = graph_with(vec![compile_action()], vec![]);
        write_build_ninja(&path, &graph, Path::new("/opt/cabin/bin/cabin")).unwrap();
        let body = std::fs::read_to_string(&path).unwrap();
        assert_eq!(body, render(&graph).unwrap());
    }

    #[test]
    fn write_build_ninja_reports_destination_when_parent_directory_missing() {
        let dir = assert_fs::TempDir::new().unwrap();
        let missing_parent = dir.path().join("nonexistent").join("build.ninja");
        let graph = graph_with(vec![compile_action()], vec![]);
        let err = write_build_ninja(&missing_parent, &graph, Path::new("/opt/cabin/bin/cabin"))
            .unwrap_err();
        match err {
            NinjaError::Io { path, .. } => assert_eq!(path, missing_parent),
            other => panic!("expected NinjaError::Io, got {other:?}"),
        }
    }
}
