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

use cabin_build::{Action, ActionKind, BuildGraph};
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.
///
/// # Errors
/// Propagates rendering failures from [`render_build_ninja`]
/// ([`NinjaError::NonUtf8Path`], [`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) -> Result<(), NinjaError> {
    let body = render_build_ninja(graph)?;
    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.
///
/// # Errors
/// Returns [`NinjaError::NonUtf8Path`] when an input, output, or default
/// path is not valid UTF-8, [`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) -> 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");

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

    out.push_str("rule cxx_compile\n");
    out.push_str("  command = $command\n");
    out.push_str("  depfile = $depfile\n");
    out.push_str("  deps = gcc\n");
    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 {
        write_edge(&mut out, action)?;
    }

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

    Ok(out)
}

fn write_edge(out: &mut String, action: &Action) -> Result<(), NinjaError> {
    let rule = match action.kind {
        ActionKind::CompileC => "c_compile",
        ActionKind::CompileCpp => "cxx_compile",
        ActionKind::ArchiveStaticLibrary => "cxx_archive",
        ActionKind::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(path_to_str(output)?)?);
    }
    out.push_str(": ");
    out.push_str(rule);
    for input in &action.inputs {
        out.push(' ');
        out.push_str(&escape_path(path_to_str(input)?)?);
    }
    if !action.implicit_inputs.is_empty() {
        out.push_str(" |");
        for input in &action.implicit_inputs {
            out.push(' ');
            out.push_str(&escape_path(path_to_str(input)?)?);
        }
    }
    out.push('\n');

    let command_value = shell_join(&action.command)?;
    write_var(out, "command", &command_value)?;
    if let Some(depfile) = &action.depfile {
        write_var(out, "depfile", path_to_str(depfile)?)?;
    }
    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(())
}

fn path_to_str(p: &Path) -> Result<&str, NinjaError> {
    p.to_str()
        .ok_or_else(|| NinjaError::NonUtf8Path(p.to_path_buf()))
}

/// 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-style command into a single POSIX shell command line.
///
/// Each argument is quoted via [`shlex::try_join`] so a value
/// containing spaces, quotes, dollar signs, or other shell
/// metacharacters round-trips back through `sh` 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)
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use cabin_build::{ActionKind, BuildGraph, CompileCommand};
    use std::path::PathBuf;

    fn compile_action() -> Action {
        Action {
            kind: ActionKind::CompileCpp,
            inputs: vec![PathBuf::from("/abs/src/main.cc")],
            implicit_inputs: vec![],
            outputs: vec![PathBuf::from("/abs/build/main.o")],
            depfile: Some(PathBuf::from("/abs/build/main.o.d")),
            command: vec![
                "/usr/bin/g++".into(),
                "-std=c++17".into(),
                "-c".into(),
                "/abs/src/main.cc".into(),
                "-o".into(),
                "/abs/build/main.o".into(),
            ],
            description: "CXX /abs/build/main.o".into(),
        }
    }

    fn archive_action() -> Action {
        Action {
            kind: ActionKind::ArchiveStaticLibrary,
            inputs: vec![PathBuf::from("/abs/build/main.o")],
            implicit_inputs: vec![],
            outputs: vec![PathBuf::from("/abs/build/libfoo.a")],
            depfile: None,
            command: vec![
                "/usr/bin/ar".into(),
                "crs".into(),
                "/abs/build/libfoo.a".into(),
                "/abs/build/main.o".into(),
            ],
            description: "AR /abs/build/libfoo.a".into(),
        }
    }

    fn link_action() -> Action {
        Action {
            kind: ActionKind::LinkExecutable,
            inputs: vec![PathBuf::from("/abs/build/main.o")],
            implicit_inputs: vec![],
            outputs: vec![PathBuf::from("/abs/build/hello")],
            depfile: None,
            command: vec![
                "/usr/bin/g++".into(),
                "/abs/build/main.o".into(),
                "-o".into(),
                "/abs/build/hello".into(),
            ],
            description: "LINK /abs/build/hello".into(),
        }
    }

    fn graph_with(actions: Vec<Action>, defaults: Vec<PathBuf>) -> BuildGraph {
        BuildGraph {
            actions,
            default_outputs: defaults,
            compile_commands: Vec::<CompileCommand>::new(),
        }
    }

    #[test]
    fn renders_all_three_rule_kinds() {
        let body = render_build_ninja(&graph_with(
            vec![compile_action(), archive_action(), link_action()],
            vec![PathBuf::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_build_ninja(&graph_with(
            vec![compile_action()],
            vec![PathBuf::from("/abs/build/main.o")],
        ))
        .unwrap();
        assert!(body.contains("default /abs/build/main.o"));
    }

    #[test]
    fn compile_edge_carries_depfile() {
        let body = render_build_ninja(&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"));
    }

    #[test]
    fn link_edge_uses_link_rule() {
        let body = render_build_ninja(&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_build_ninja(&graph_with(vec![archive_action()], vec![])).unwrap();
        assert!(body.contains("build /abs/build/libfoo.a: cxx_archive /abs/build/main.o"));
    }

    #[test]
    fn implicit_inputs_render_with_pipe() {
        let mut act = compile_action();
        act.implicit_inputs = vec![PathBuf::from("/abs/build/generated.h")];
        let body = render_build_ninja(&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 mut act = compile_action();
        act.description = "CXX foo.o\n  rule injected".into();
        let err = render_build_ninja(&graph_with(vec![act], vec![])).unwrap_err();
        assert!(
            matches!(err, NinjaError::ValueHasNewline(_)),
            "expected ValueHasNewline, got {err:?}"
        );
    }

    #[test]
    fn newline_in_command_aborts_render() {
        let mut act = compile_action();
        act.command.push("payload\n  command = /bin/rm".into());
        let err = render_build_ninja(&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:?}",
        );
    }

    #[test]
    fn render_includes_full_command_in_value() {
        let body = render_build_ninja(&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
        // earlier `command = $command` line belongs to the rule
        // template, not the edge, so skip it.
        let command_line = body
            .lines()
            .filter_map(|l| l.trim_start().strip_prefix("command = "))
            .find(|l| *l != "$command")
            .expect("edge command = line present");
        let split = shlex::split(command_line).expect("command must round-trip");
        assert_eq!(
            split,
            vec![
                "/usr/bin/g++",
                "-std=c++17",
                "-c",
                "/abs/src/main.cc",
                "-o",
                "/abs/build/main.o",
            ],
        );
    }

    #[test]
    fn render_propagates_unquotable_argument_error() {
        let mut act = compile_action();
        act.command.push("evil\0arg".into());
        let err = render_build_ninja(&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![PathBuf::from("/abs/build/main.o")],
        );
        write_build_ninja(&path, &graph).unwrap();
        let body = std::fs::read_to_string(&path).unwrap();
        assert_eq!(body, render_build_ninja(&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).unwrap();
        let body = std::fs::read_to_string(&path).unwrap();
        assert_eq!(body, render_build_ninja(&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).unwrap_err();
        match err {
            NinjaError::Io { path, .. } => assert_eq!(path, missing_parent),
            other => panic!("expected NinjaError::Io, got {other:?}"),
        }
    }
}
