//! `aura new` — scaffold a research project crate (cycle 0103, C16/C17).
//!
//! Emits the cycle-0102 fixture shape parameterized by name/namespace: a
//! cdylib crate with path-deps on the engine checkout (the baked default or
//! `--engine-path`), a paths-only `Aura.toml`, a sample node + runnable
//! blueprint, and a project `CLAUDE.md`. The scaffolder never builds and
//! never loads (the author builds; `aura new` bypasses the project-load
//! phase in `main()`).
//!
//! Templates are raw strings with `__NS__` / `__NAME__` / `__NAME_SNAKE__` /
//! `__ENGINE__` tokens substituted by plain `.replace()` — no format-string
//! brace escaping in Rust template bodies.

use std::path::{Path, PathBuf};

#[derive(Debug)]
pub struct ScaffoldSpec {
    pub name: String,
    pub namespace: String,
    pub engine_root: PathBuf,
    pub target_dir: PathBuf,
}

fn valid_name(s: &str) -> bool {
    !s.is_empty()
        && !s.starts_with(|c: char| c.is_ascii_digit())
        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}

fn valid_namespace(s: &str) -> bool {
    !s.is_empty()
        && !s.starts_with(|c: char| c.is_ascii_digit())
        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}

/// The name→namespace / name→blueprint-name mapping: dashes become
/// underscores (mirrors cargo's crate-name normalization).
pub fn snake(name: &str) -> String {
    name.replace('-', "_")
}

/// The compile-time default engine root: two ancestors above aura-cli's
/// manifest dir (`<root>/crates/aura-cli` → `<root>`). Valid on the box the
/// binary was built on; `scaffold` refuses when it no longer exists.
pub fn default_engine_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .ancestors()
        .nth(2)
        .expect("aura-cli manifest dir has two ancestors")
        .to_path_buf()
}

/// Validate the argv-level inputs. `Err` is a usage fault — the caller
/// exits 2 (C14 partition).
pub fn scaffold_spec(
    name: &str,
    engine_path: Option<&Path>,
    namespace: Option<&str>,
    cwd: &Path,
) -> Result<ScaffoldSpec, String> {
    if !valid_name(name) {
        return Err(format!(
            "invalid project name `{name}` (allowed: letters, digits, `_`, `-`; no leading digit)"
        ));
    }
    let namespace = match namespace {
        Some(ns) if !valid_namespace(ns) => {
            return Err(format!(
                "invalid namespace `{ns}` (allowed: letters, digits, `_`; no leading digit)"
            ));
        }
        Some(ns) => ns.to_string(),
        None => snake(name),
    };
    Ok(ScaffoldSpec {
        name: name.to_string(),
        namespace,
        engine_root: engine_path
            .map(Path::to_path_buf)
            .unwrap_or_else(default_engine_root),
        target_dir: cwd.join(name),
    })
}

// ---------------------------------------------------------------- templates

const CARGO_TOML: &str = r#"[package]
name = "__NAME__"
version = "0.1.0"
edition = "2024"
publish = false

[lib]
crate-type = ["cdylib"]

[dependencies]
aura-core = { path = "__ENGINE__/crates/aura-core" }

# Standalone workspace root: never a member of an enclosing workspace.
[workspace]
"#;

const AURA_TOML: &str = r#"# Static project context only (C17); paths only.
[paths]
runs = "runs"
# data = "/path/to/archive"  # the recorded-data root; defaults to the built-in path
"#;

const GITIGNORE: &str = "/target\nCargo.lock\n/runs\n";

const LIB_RS: &str = r#"//! __NAME__ — an aura research project: node logic + the exported vocabulary.
//!
//! Every node type this crate provides is registered in `vocabulary()` /
//! `type_ids()` under the `__NS__::` namespace prefix; the `aura` host loads
//! this cdylib per invocation and merges it with the std vocabulary.

use aura_core::{
    Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
    ScalarKind,
};

/// One-input scalar gain: emits `input * factor`. Emits `None` until its
/// input has a value (warm-up filter, C8).
pub struct Scale {
    factor: f64,
    out: [Cell; 1],
}

impl Scale {
    pub fn new(factor: f64) -> Self {
        Self { factor, out: [Cell::from_f64(0.0)] }
    }
    pub fn builder() -> PrimitiveBuilder {
        PrimitiveBuilder::new(
            "__NS__::Scale",
            NodeSchema {
                inputs: vec![PortSpec {
                    kind: ScalarKind::F64,
                    firing: Firing::Any,
                    name: "value".into(),
                }],
                output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
                params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
            },
            |p| Box::new(Scale::new(p[0].f64())),
        )
    }
}

impl Node for Scale {
    fn lookbacks(&self) -> Vec<usize> {
        vec![1]
    }
    fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
        let w = ctx.f64_in(0);
        if w.is_empty() {
            return None;
        }
        self.out[0] = Cell::from_f64(w[0] * self.factor);
        Some(&self.out)
    }
    fn label(&self) -> String {
        format!("__NS__::Scale({})", self.factor)
    }
}

fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
    match type_id {
        "__NS__::Scale" => Some(Scale::builder()),
        _ => None,
    }
}

fn type_ids() -> &'static [&'static str] {
    &["__NS__::Scale"]
}

aura_core::aura_project! {
    namespace: "__NS__",
    vocabulary: vocabulary,
    type_ids: type_ids,
}
"#;

const SIGNAL_JSON: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"__NS__::Scale","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":1.0}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;

const CLAUDE_MD: &str = r#"# __NAME__ — an aura research project

This crate is an aura project: a cdylib of node/strategy blueprints the
`aura` host loads during research (see the engine's docs/project-layout.md).

- Build: `cargo build` (the next `aura` invocation loads the fresh dylib)
- Run:   `aura run blueprints/signal.json` (from anywhere inside this dir)
- Nodes live in `src/`; each is registered in `vocabulary()`/`type_ids()`
  under the `__NS__::` namespace prefix.
- Topology is data (`blueprints/*.json`), node logic is Rust.
"#;

fn render(template: &str, spec: &ScaffoldSpec) -> String {
    template
        .replace("__NAME_SNAKE__", &snake(&spec.name))
        .replace("__NAME__", &spec.name)
        .replace("__NS__", &spec.namespace)
        .replace("__ENGINE__", &spec.engine_root.display().to_string())
}

// --------------------------------------------------------------------- emit

/// Emit the project. `Err` is a runtime refusal — the caller exits 1 (C14
/// partition). All refusals fire before the first write.
pub fn scaffold(spec: &ScaffoldSpec) -> Result<(), String> {
    if spec.target_dir.exists() {
        return Err(format!(
            "destination `{}` already exists",
            spec.target_dir.display()
        ));
    }
    if !spec.engine_root.join("crates/aura-core").is_dir() {
        return Err(format!(
            "engine checkout not found at `{}` — pass --engine-path <engine checkout>",
            spec.engine_root.display()
        ));
    }
    let write = |rel: &str, body: String| -> Result<(), String> {
        let path = spec.target_dir.join(rel);
        if let Some(dir) = path.parent() {
            std::fs::create_dir_all(dir)
                .map_err(|e| format!("creating `{}`: {e}", dir.display()))?;
        }
        std::fs::write(&path, body).map_err(|e| format!("writing `{}`: {e}", path.display()))
    };
    write("Cargo.toml", render(CARGO_TOML, spec))?;
    write("Aura.toml", render(AURA_TOML, spec))?;
    write(".gitignore", render(GITIGNORE, spec))?;
    write("src/lib.rs", render(LIB_RS, spec))?;
    write("blueprints/signal.json", render(SIGNAL_JSON, spec))?;
    write("CLAUDE.md", render(CLAUDE_MD, spec))?;
    // Best-effort git init: a warning, never an error (cargo-new mirror).
    // Also the cargo-new mirror: inside an existing work tree the init is
    // SKIPPED silently — a nested .git is a gitlink/submodule hazard when the
    // scaffold lands in a tracked tree (#204).
    if inside_git_work_tree(&spec.target_dir) {
        return Ok(());
    }
    match std::process::Command::new("git")
        .arg("init")
        .current_dir(&spec.target_dir)
        .output()
    {
        Ok(o) if o.status.success() => {}
        _ => eprintln!("aura: warning: git init failed (project created without a repo)"),
    }
    Ok(())
}

/// Whether `dir` already sits inside a git work tree (`git rev-parse`, the
/// same oracle cargo-new consults). A missing git binary or any failure reads
/// as "not in a work tree" — the init stays best-effort either way.
fn inside_git_work_tree(dir: &std::path::Path) -> bool {
    std::process::Command::new("git")
        .args(["rev-parse", "--is-inside-work-tree"])
        .current_dir(dir)
        .output()
        .map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true")
        .unwrap_or(false)
}

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

    fn spec_for(name: &str, ns: Option<&str>) -> ScaffoldSpec {
        scaffold_spec(name, None, ns, Path::new("/tmp/x")).expect("valid spec")
    }

    #[test]
    fn name_and_namespace_validation_table() {
        assert!(scaffold_spec("ger40-lab", None, None, Path::new("/t")).is_ok());
        assert!(scaffold_spec("x_1", None, None, Path::new("/t")).is_ok());
        for bad in ["", "bad/name", "9lives", "a b", "ä"] {
            let e = scaffold_spec(bad, None, None, Path::new("/t")).unwrap_err();
            assert!(e.contains("invalid project name"), "{bad}: {e}");
        }
        for bad_ns in ["", "with-dash", "9x", "a::b"] {
            let e = scaffold_spec("ok", None, Some(bad_ns), Path::new("/t")).unwrap_err();
            assert!(e.contains("invalid namespace"), "{bad_ns}: {e}");
        }
    }

    #[test]
    fn namespace_defaults_to_snake_name() {
        assert_eq!(spec_for("ger40-lab", None).namespace, "ger40_lab");
        assert_eq!(spec_for("ger40-lab", Some("ger40")).namespace, "ger40");
        assert_eq!(snake("a-b-c"), "a_b_c");
    }

    #[test]
    fn default_engine_root_is_this_checkout() {
        assert!(default_engine_root().join("crates/aura-core").is_dir());
    }

    #[test]
    fn templates_render_without_leftover_tokens() {
        let spec = spec_for("demo-lab", None);
        for t in [CARGO_TOML, AURA_TOML, GITIGNORE, LIB_RS, SIGNAL_JSON, CLAUDE_MD] {
            let out = render(t, &spec);
            assert!(!out.contains("__"), "unsubstituted token in: {out}");
        }
        let lib = render(LIB_RS, &spec);
        assert!(lib.contains("\"demo_lab::Scale\""));
        assert!(lib.contains("namespace: \"demo_lab\""));
        let json = render(SIGNAL_JSON, &spec);
        assert!(json.contains("\"type\":\"demo_lab::Scale\""));
        assert!(json.contains("\"name\":\"demo_lab_signal\""));
        let cargo = render(CARGO_TOML, &spec);
        assert!(cargo.contains("name = \"demo-lab\""));
        assert!(cargo.contains("/crates/aura-core"));
    }

    /// Property (#183, fieldtest F2 gap): the scaffold's own starter node teaches
    /// the bound-param build closure. Its rendered `src/lib.rs` declares a param in
    /// the node schema and reads a bound value in the build closure (not the
    /// param-less `|_|` Identity the template shipped before), and the rendered
    /// starter blueprint binds `factor` on the project's own node — so `aura run`
    /// exercises the bound-param path with zero hand-editing. (The scaffold→build→
    /// run e2e in tests/project_new.rs re-validates the swap end to end; this pins
    /// the copyable template content a newcomer learns from directly.)
    #[test]
    fn starter_node_declares_and_reads_a_bound_param() {
        let spec = spec_for("demo-lab", None);
        let lib = render(LIB_RS, &spec);
        // (a) the node schema declares a bindable param...
        assert!(
            lib.contains("ParamSpec"),
            "starter node must declare a param in its schema, not `params: vec![]`: {lib}"
        );
        // ...and the build closure reads that bound param (p[0]), not `|_|`.
        assert!(
            lib.contains("p[0].f64()"),
            "starter build closure must consume a bound param (p[0].f64()), not `|_|`: {lib}"
        );
        // (b) the starter blueprint binds `factor` on the project's own node, so
        // `aura run` drives the bound-param path out of the box.
        let json = render(SIGNAL_JSON, &spec);
        assert!(
            json.contains("\"name\":\"factor\""),
            "starter blueprint must bind the `factor` param on the project node: {json}"
        );
    }

    #[test]
    fn scaffold_refuses_existing_dir_and_missing_engine() {
        let tmp = std::env::temp_dir().join(format!("aura-scaf-{}", std::process::id()));
        std::fs::create_dir_all(&tmp).unwrap();
        // existing destination
        let mut spec = scaffold_spec("x", None, None, &tmp).unwrap();
        std::fs::create_dir_all(tmp.join("x")).unwrap();
        let e = scaffold(&spec).unwrap_err();
        assert!(e.contains("already exists"), "{e}");
        // missing engine root
        std::fs::remove_dir_all(tmp.join("x")).unwrap();
        spec.engine_root = PathBuf::from("/nonexistent-engine");
        let e = scaffold(&spec).unwrap_err();
        assert!(e.contains("--engine-path"), "{e}");
        std::fs::remove_dir_all(&tmp).ok();
    }
}
