//! `node-app new [name] [--type TYPE]`
//!
//! Scaffolds a new project. When `--type` is omitted the interactive wizard
//! guides the user.
//!
//! Template sources (resolved in order):
//!   1. `--templates <org/repo-or-path>` CLI flag
//!   2. `NODE_APP_TEMPLATES_REPO` environment variable
//!   3. DEFAULT_TEMPLATES_REPO const ("econ-v1/node-app-templates")
//!
//! Two scaffolding paths:
//!   - **GitHub template flow** (when `--github <slug>` is set AND the resolved
//!     template is a GitHub repo): uses `gh repo create <slug> --template <repo>
//!     --private`, then `git clone` the new repo to the destination, then
//!     substitutes placeholders in place. The new repo is properly linked to
//!     its template in the GitHub UI ("generated from <template>"), and the
//!     initial pack travels down via clone instead of up via push (avoiding
//!     spurious "remote unpack failed" errors on first push).
//!   - **Local-copy flow** (when templates is a local directory, or `--github`
//!     is not set): clones the template into a tmp dir via `gh repo clone
//!     --depth=1` (or reads from disk), copies files to the destination with
//!     placeholder substitution, then optionally `git init` + `gh repo create`.
//!
//! Placeholders substituted in every text file:
//!   `{{name}}`              — app name (kebab-case)
//!   `{{NAME_UPPER}}`        — app name uppercased with `-` → `_` (env-var safe;
//!                             e.g. "foo-bar" → "FOO_BAR"). Used by standalone
//!                             apps for per-app env-var names like
//!                             `NODE_APP_<NAME_UPPER>_MANIFEST_PATH`.
//!   `{{description}}`       — one-line description
//!   `{{systemd_order}}`     — systemd ordering directive (standalone only,
//!                             e.g. "Before=node.service")
//!   `{{maintainer}}`        — combined "Name <email>" (legacy convenience)
//!   `{{maintainer_name}}`   — `git config user.name` or --maintainer-name override
//!   `{{maintainer_email}}`  — `git config user.email` or --maintainer-email override
//!   `{{copyright_year}}`    — current year (used in Cargo.toml deb copyright lines)
//!
//! Manifest-only placeholders (resolved by `patch_manifest()`, not `render()`):
//!   `{{app_type}}`, `{{entrypoint}}`, `{{hot_reload}}`, `{{socket_path}}`

use crate::AppKind;
use anyhow::{anyhow, bail, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use walkdir::WalkDir;

// ── Maintainer / author info ──────────────────────────────────────────────────

/// Maintainer info derived from CLI flags or `git config user.{name,email}`.
/// Carried through scaffolding and substituted into manifests, Cargo.toml,
/// package.json, debian/control.template, etc.
#[derive(Clone, Debug)]
struct MaintainerInfo {
    name: String,
    email: String,
    /// "Name <email>" — convenient for `Maintainer:` and `authors = [...]`.
    combined: String,
}

fn get_git_config(key: &str) -> Option<String> {
    let out = Command::new("git").args(["config", key]).output().ok()?;
    if !out.status.success() {
        return None;
    }
    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
    if s.is_empty() { None } else { Some(s) }
}

fn resolve_maintainer(cli_name: Option<&str>, cli_email: Option<&str>) -> MaintainerInfo {
    let name = cli_name
        .map(str::to_string)
        .or_else(|| get_git_config("user.name"))
        .unwrap_or_else(|| "Unknown".to_string());
    let email = cli_email
        .map(str::to_string)
        .or_else(|| get_git_config("user.email"))
        .unwrap_or_else(|| "unknown@example.com".to_string());
    let combined = format!("{} <{}>", name, email);
    MaintainerInfo { name, email, combined }
}

fn current_year() -> String {
    Command::new("date")
        .arg("+%Y")
        .output()
        .ok()
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "2026".to_string())
}

/// Default template repository slug (GitHub org/repo).
pub const DEFAULT_TEMPLATES_REPO: &str = "econ-v1/node-app-templates";

fn family_dir_name(kind: AppKind) -> &'static str {
    match kind {
        AppKind::Bun
        | AppKind::BunFullstack
        | AppKind::StandaloneBun
        | AppKind::StandaloneBunFullstack => "bun",
        AppKind::Cdylib
        | AppKind::CdylibFullstack
        | AppKind::StandaloneRust
        | AppKind::StandaloneRustFullstack => "cdylib",
    }
}

fn family_github_repo(kind: AppKind) -> &'static str {
    match kind {
        AppKind::Bun
        | AppKind::BunFullstack
        | AppKind::StandaloneBun
        | AppKind::StandaloneBunFullstack => "econ-v1/node-app-template-bun",
        AppKind::Cdylib
        | AppKind::CdylibFullstack
        | AppKind::StandaloneRust
        | AppKind::StandaloneRustFullstack => "econ-v1/node-app-template-cdylib",
    }
}

// ── Public entry point ────────────────────────────────────────────────────────

/// Entry point called from `main.rs`.
///
/// When `name_hint` or `kind_hint` is `None`, the interactive wizard fills
/// in the missing pieces. When both are `Some`, the wizard is skipped
/// entirely (backward-compatible scripted usage).
pub fn run(
    name_hint: Option<String>,
    kind_hint: Option<AppKind>,
    out: Option<PathBuf>,
    git: bool,
    github: Option<String>,
    no_deps_update: bool,
    templates_repo: String,
    maintainer_name: Option<String>,
    maintainer_email: Option<String>,
) -> Result<()> {
    let maintainer = resolve_maintainer(maintainer_name.as_deref(), maintainer_email.as_deref());
    let copyright_year = current_year();

    let args = match (name_hint, kind_hint) {
        (Some(name), Some(kind)) => ScaffoldArgs {
            name,
            kind,
            description: "A Node mini app".to_string(),
            systemd_order: String::new(),
            tcp: TcpManifestFields::default(),
            out,
            git: git || github.is_some(),
            github,
            run_deps_update: !no_deps_update,
            templates_repo,
            maintainer,
            copyright_year,
        },
        (name_hint, kind_hint) => {
            let wiz = wizard_prompt(name_hint, kind_hint, git, github)?;
            ScaffoldArgs {
                name: wiz.name,
                kind: wiz.kind,
                description: wiz.description,
                systemd_order: wiz.systemd_order,
                tcp: wiz.tcp,
                out,
                git: wiz.git,
                github: wiz.github,
                run_deps_update: !no_deps_update,
                templates_repo,
                maintainer,
                copyright_year,
            }
        }
    };
    scaffold(args)
}

// ── Scaffold args ─────────────────────────────────────────────────────────────

struct ScaffoldArgs {
    name: String,
    kind: AppKind,
    description: String,
    systemd_order: String,
    tcp: TcpManifestFields,
    out: Option<PathBuf>,
    git: bool,
    github: Option<String>,
    run_deps_update: bool,
    templates_repo: String,
    maintainer: MaintainerInfo,
    copyright_year: String,
}

fn scaffold(args: ScaffoldArgs) -> Result<()> {
    validate_name(&args.name)?;
    if let Some(ref slug) = args.github {
        validate_github_slug(slug)?;
    }

    // Default destination uses the fully-qualified `node-app-<name>` form,
    // mirroring the .deb package naming convention. `validate_name` already
    // rejects names that start with `node-app-`, so no double-prefix risk.
    // An explicit `--out <path>` override is honored verbatim.
    let dest = args
        .out
        .clone()
        .unwrap_or_else(|| PathBuf::from(format!("node-app-{}", args.name)));

    if dest.exists() && fs::read_dir(&dest)?.next().is_some() {
        bail!(
            "destination '{}' already exists and is not empty",
            dest.display()
        );
    }
    fs::create_dir_all(&dest)
        .with_context(|| format!("create destination {}", dest.display()))?;

    // Prefer the GitHub-native "use this template" flow when the user asked for a
    // GitHub repo AND the template source is a GitHub slug (not a local dir). This
    // creates the new repo from the template server-side (preserving the
    // "generated from <template>" link on GitHub) and clones it locally; the rest
    // of scaffolding then mutates the cloned tree in place.
    let templates_is_local = PathBuf::from(&args.templates_repo).is_dir();
    let use_github_template_flow = args.github.is_some() && !templates_is_local;

    if use_github_template_flow {
        let slug = args.github.as_deref().expect("guarded above");
        let template_repo = if args.templates_repo == DEFAULT_TEMPLATES_REPO {
            family_github_repo(args.kind)
        } else {
            args.templates_repo.as_str()
        };
        github_template_scaffold(
            &dest,
            slug,
            template_repo,
            args.kind,
            &args.name,
            &args.description,
            &args.systemd_order,
            &args.maintainer,
            &args.copyright_year,
        )?;
    } else {
        fetch_and_write_template(
            &args.templates_repo,
            args.kind,
            &dest,
            &args.name,
            &args.description,
            &args.systemd_order,
            &args.maintainer,
            &args.copyright_year,
        )?;
    }

    strip_for_kind(&dest, args.kind)?;
    verify_scaffold(&dest, args.kind)?;
    patch_sdk_versions(&dest);

    match args.kind {
        AppKind::Cdylib | AppKind::CdylibFullstack => {
            eprintln!(
                "⚠ Reminder: cdylib (native) apps must be GPG-signed by a node \
                 org keyring to load as FirstParty tier in production. The standalone \
                 release.yml workflow handles this automatically when you push a tag."
            );
        }
        AppKind::StandaloneRust | AppKind::StandaloneBun => {
            eprintln!(
                "ℹ Standalone apps run as their own systemd service and are NOT \
                 loaded by the node platform. They communicate with the platform \
                 via /run/node/control.sock (JSON-RPC 2.0) when it is available."
            );
        }
        AppKind::StandaloneRustFullstack | AppKind::StandaloneBunFullstack => {
            eprintln!(
                "ℹ Standalone fullstack apps run as their own systemd service and \
                 serve their own React UI on a configurable port (NODE_APP_HTTP_PORT, \
                 default 7000). UI assets are embedded into the binary/bundle for \
                 single-artifact distribution. They optionally talk to the platform \
                 via /run/node/control.sock when it is available."
            );
        }
        _ => {}
    }

    println!(
        "✓ Scaffolded {} app '{}' at {}",
        args.kind.label(),
        args.name,
        dest.display()
    );

    // Inject the optional `tcp` manifest block (feature 470 / T027). No-op
    // when the wizard collected no preferred_port (non-standalone variants).
    inject_tcp_into_manifest(&dest, &args.tcp)?;

    if args.run_deps_update {
        run_deps_update(&dest, args.kind)?;
    }

    if use_github_template_flow {
        // .git already exists (cloned from the newly-created repo) — just commit
        // the scaffold mutations on top of the from-template initial commit and push.
        let slug = args.github.as_deref().expect("guarded above");
        commit_and_push_scaffold(&dest, &args.name, slug)?;
        upload_release_secrets(slug);
    } else {
        if args.git {
            init_git(&dest, &args.name)?;
        }
        if let Some(ref slug) = args.github {
            create_github_repo(&dest, slug)?;
        }
    }

    println!();
    println!("Next steps:");
    println!("  cd {}", dest.display());
    match args.github.as_deref() {
        None => println!("  node-app dev          # hot-reload inner-loop"),
        Some(slug) => println!(
            "  node-app dev          # hot-reload inner-loop, your repo is on GitHub at {}",
            slug
        ),
    }

    Ok(())
}

// ── Interactive wizard ────────────────────────────────────────────────────────

/// Optional tcp-block fields collected by the wizard for standalone-fullstack
/// apps. Threaded through `ScaffoldArgs` and post-applied to the generated
/// manifest.json so the templates themselves stay simple (no extra
/// placeholders). See feature 470.
#[derive(Debug, Clone, Default)]
struct TcpManifestFields {
    preferred_port: Option<u16>,
    direct_bind: bool,
}

struct WizardResult {
    name: String,
    kind: AppKind,
    description: String,
    systemd_order: String,
    tcp: TcpManifestFields,
    git: bool,
    github: Option<String>,
}

fn wizard_prompt(
    name_hint: Option<String>,
    kind_hint: Option<AppKind>,
    git_flag: bool,
    github_flag: Option<String>,
) -> Result<WizardResult> {
    cliclack::intro(" node-app new ").ok();

    // Step 1: App name
    let name = match name_hint {
        Some(n) => n,
        None => cliclack::input("App name")
            .validate(|s: &String| validate_name(s).map_err(|e| e.to_string()))
            .interact()
            .context("app name prompt")?,
    };

    // Step 2a: Language → Step 2b: Variant
    let kind = match kind_hint {
        Some(k) => k,
        None => {
            #[derive(Clone, PartialEq, Eq)]
            enum Lang { Bun, Rust }

            let lang = cliclack::select("Language")
                .item(Lang::Bun,  "TypeScript (Bun)", "Interpreted · Architecture: all · bun runtime")
                .item(Lang::Rust, "Native Rust",       "Compiled cdylib/.so or binary · amd64 + arm64")
                .interact()
                .context("language selection")?;

            match lang {
                Lang::Bun => cliclack::select("Variant")
                    .item(AppKind::Bun,         "Platform app",       "IPC capabilities, optional simple HTML UI · node-ctl deploy")
                    .item(AppKind::BunFullstack, "Platform fullstack", "Platform app + embedded React/Vite UI served by the platform")
                    .item(AppKind::StandaloneBun,"Standalone service", "Own systemd unit · /run/node/control.sock (optional)")
                    .item(AppKind::StandaloneBunFullstack,"Standalone fullstack", "Own systemd unit + Bun.serve HTTP server with embedded React/Vite UI")
                    .interact()
                    .context("variant selection")?,
                Lang::Rust => cliclack::select("Variant")
                    .item(AppKind::Cdylib,        "Platform cdylib",    "Compiled .so loaded by the daemon · GPG-signed FirstParty")
                    .item(AppKind::CdylibFullstack,"Platform fullstack", "cdylib + embedded React/Vite/Tailwind UI")
                    .item(AppKind::StandaloneRust, "Standalone service", "Binary with own systemd unit · ideal for LCD/OTA/recovery")
                    .item(AppKind::StandaloneRustFullstack,"Standalone fullstack", "Binary with own systemd unit + Axum HTTP server with rust-embed React/Vite UI")
                    .interact()
                    .context("variant selection")?,
            }
        }
    };

    // Step 3: Description
    let description: String = cliclack::input("Description")
        .placeholder("A Node mini app")
        .default_input("A Node mini app")
        .interact()
        .context("description prompt")?;

    // Step 4: Systemd ordering (standalone only)
    let systemd_order = if matches!(
        kind,
        AppKind::StandaloneRust
            | AppKind::StandaloneBun
            | AppKind::StandaloneRustFullstack
            | AppKind::StandaloneBunFullstack
    ) {
        #[derive(Clone, PartialEq, Eq)]
        enum Order { Before, After }

        let order = cliclack::select("Systemd ordering")
            .item(Order::Before, "Before platform", "Before=node.service — LCD, recovery, OTA, pre-boot")
            .item(Order::After,  "After platform",  "After=node.service — depends on platform being up")
            .interact()
            .context("systemd ordering selection")?;

        match order {
            Order::Before => "Before=node.service".to_string(),
            Order::After  => "After=node.service".to_string(),
        }
    } else {
        String::new()
    };

    // Step 4b: TCP port preference (standalone-fullstack only, feature 470)
    //
    // The port registry assigns the preferred port at install time when it's
    // free; otherwise the next free pool slot. `direct_bind` is an opt-in for
    // apps that must outlive a platform restart (OTA-style self-upgrade) —
    // see specs/470-port-registry/spec.md Clarification Q5.
    let tcp = if matches!(
        kind,
        AppKind::StandaloneBunFullstack | AppKind::StandaloneRustFullstack
    ) {
        let preferred_str: String = cliclack::input("Preferred TCP port (1024-65535)")
            .placeholder("7001")
            .default_input("7001")
            .validate(|s: &String| match s.parse::<u16>() {
                Ok(p) if p >= 1024 => Ok(()),
                Ok(_) => Err("port must be >= 1024".to_string()),
                Err(_) => Err("must be a valid u16".to_string()),
            })
            .interact()
            .context("preferred port prompt")?;
        let preferred_port: u16 = preferred_str.parse().expect("validated above");

        let direct_bind = cliclack::confirm(
            "Direct-bind iframe (only for restart-survival apps like OTA)?",
        )
        .initial_value(false)
        .interact()
        .context("direct_bind confirm")?;

        TcpManifestFields {
            preferred_port: Some(preferred_port),
            direct_bind,
        }
    } else {
        TcpManifestFields::default()
    };

    // Step 5: Git init
    let git = git_flag
        || github_flag.is_some()
        || cliclack::confirm("Initialize git repo?")
            .initial_value(true)
            .interact()
            .context("git confirm")?;

    // Step 6: GitHub repo (only if git enabled and not already provided)
    let github = if github_flag.is_some() {
        github_flag
    } else if git {
        let slug: String = cliclack::input("GitHub repo (org/repo, blank to skip)")
            .placeholder("econ-v1/node-app-my-app")
            .required(false)
            .interact()
            .context("github prompt")?;
        let trimmed = slug.trim().to_string();
        if trimmed.is_empty() { None } else { Some(trimmed) }
    } else {
        None
    };

    cliclack::outro(format!("Scaffolding {}...", name)).ok();

    Ok(WizardResult { name, kind, description, systemd_order, tcp, git, github })
}

/// Post-process the scaffolded `manifest.json` to inject the optional `tcp`
/// block when the user opted in (feature 470 / T027). Surgical edit — leaves
/// every other manifest field unchanged. No-op if `preferred_port` is None.
fn inject_tcp_into_manifest(dest: &Path, tcp: &TcpManifestFields) -> Result<()> {
    let Some(port) = tcp.preferred_port else {
        return Ok(());
    };
    let manifest_path = dest.join("manifest.json");
    if !manifest_path.exists() {
        // Template variant may not have a manifest.json (e.g. Cargo-only); silently skip.
        return Ok(());
    }
    let raw = fs::read_to_string(&manifest_path)
        .with_context(|| format!("read {}", manifest_path.display()))?;
    let mut value: serde_json::Value =
        serde_json::from_str(&raw).with_context(|| {
            format!("parse {} as JSON", manifest_path.display())
        })?;
    let obj = value.as_object_mut().with_context(|| {
        format!(
            "{} is not a JSON object — cannot inject tcp block",
            manifest_path.display()
        )
    })?;
    let mut tcp_block = serde_json::Map::new();
    tcp_block.insert(
        "preferred_port".to_string(),
        serde_json::Value::from(port),
    );
    if tcp.direct_bind {
        tcp_block.insert("direct_bind".to_string(), serde_json::Value::Bool(true));
    }
    obj.insert("tcp".to_string(), serde_json::Value::Object(tcp_block));
    let pretty = serde_json::to_string_pretty(&value)
        .with_context(|| "serialize manifest.json")?;
    fs::write(&manifest_path, format!("{pretty}\n"))
        .with_context(|| format!("write {}", manifest_path.display()))?;
    Ok(())
}

// ── Template fetching ─────────────────────────────────────────────────────────

/// Resolve `templates_spec` to the appropriate template source and copy it to `dest`.
///
/// If `templates_spec` is an existing directory path, it is used directly
/// (offline / CI mode) and a `bun/` or `cdylib/` subdirectory is expected.
/// Otherwise it is treated as a GitHub `org/repo` slug and cloned with
/// `gh repo clone --depth=1`. When using the default repo constant, routing
/// is to the per-family repos (`econ-v1/node-app-template-bun` or
/// `econ-v1/node-app-template-cdylib`); user-specified repos are cloned
/// directly (with optional family-subdir fallback for monorepo-style repos).
fn fetch_and_write_template(
    templates_spec: &str,
    kind: AppKind,
    dest: &Path,
    name: &str,
    description: &str,
    systemd_order: &str,
    maintainer: &MaintainerInfo,
    copyright_year: &str,
) -> Result<()> {
    let local = PathBuf::from(templates_spec);

    if local.is_dir() {
        // Local mode: look for family-named subdir (bun/ or cdylib/)
        let subdir = local.join(family_dir_name(kind));
        if !subdir.is_dir() {
            bail!(
                "Local templates directory '{}' has no '{}/' subdirectory.\n\
                 Expected a '{}/' directory containing the base template.",
                templates_spec,
                family_dir_name(kind),
                family_dir_name(kind)
            );
        }
        return write_template_from_path(
            &subdir, dest, name, description, systemd_order, maintainer, copyright_year,
        );
    }

    // GitHub mode: resolve repo and clone
    ensure_gh()?;

    let repo = if templates_spec == DEFAULT_TEMPLATES_REPO {
        // Default: route to per-family repo, clone root (no subdir lookup)
        family_github_repo(kind)
    } else {
        // User-specified custom repo via --templates or NODE_APP_TEMPLATES_REPO
        templates_spec
    };

    println!("→ fetching {} template from {}...", kind.label(), repo);

    let tmp = tmp_dir()?;
    let clone_ok = Command::new("gh")
        .args([
            "repo",
            "clone",
            repo,
            tmp.to_str().unwrap_or_default(),
            "--",
            "--depth=1",
            "--quiet",
        ])
        .status()
        .with_context(|| format!("invoke `gh repo clone {}`", repo))?
        .success();

    if !clone_ok {
        let _ = fs::remove_dir_all(&tmp);
        bail!(
            "Failed to clone template repo '{}'.\n\
             Ensure you have read access and are authenticated (`gh auth login`).\n\
             Override with --templates <org/repo-or-path> or NODE_APP_TEMPLATES_REPO.",
            repo
        );
    }

    // For user-specified repos, check for a family-named subdir first (backward compat
    // with monorepo-style template repos); otherwise use the repo root.
    let template_src = if templates_spec != DEFAULT_TEMPLATES_REPO {
        let subdir = tmp.join(family_dir_name(kind));
        if subdir.is_dir() { subdir } else { tmp.clone() }
    } else {
        tmp.clone()
    };

    let result = write_template_from_path(
        &template_src,
        dest,
        name,
        description,
        systemd_order,
        maintainer,
        copyright_year,
    );
    let _ = fs::remove_dir_all(&tmp);
    result
}

#[allow(clippy::too_many_arguments)]
fn write_template_from_path(
    src: &Path,
    dest: &Path,
    name: &str,
    description: &str,
    systemd_order: &str,
    maintainer: &MaintainerInfo,
    copyright_year: &str,
) -> Result<()> {
    for entry in WalkDir::new(src)
        .min_depth(1)
        .into_iter()
        .filter_entry(|e| !(e.depth() == 1 && e.file_name() == ".git"))
    {
        let entry = entry.with_context(|| "iterate template files")?;
        let rel = entry
            .path()
            .strip_prefix(src)
            .expect("walkdir always under src");
        // Substitute placeholders in path components (e.g. "node-app-{{name}}.service").
        let rel_rendered: PathBuf = rel
            .components()
            .map(|c| {
                render(
                    c.as_os_str().to_string_lossy().as_ref(),
                    name,
                    description,
                    systemd_order,
                    maintainer,
                    copyright_year,
                )
            })
            .collect();
        let dest_path = dest.join(rel_rendered);

        if entry.file_type().is_dir() {
            fs::create_dir_all(&dest_path)
                .with_context(|| format!("create dir {}", dest_path.display()))?;
            continue;
        }

        if let Some(parent) = dest_path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("create parent {}", parent.display()))?;
        }

        let raw = fs::read(entry.path())
            .with_context(|| format!("read {}", entry.path().display()))?;

        if raw.contains(&0u8) {
            // Binary file: copy verbatim
            fs::write(&dest_path, &raw)
                .with_context(|| format!("write {}", dest_path.display()))?;
        } else {
            let text = std::str::from_utf8(&raw).with_context(|| {
                format!("template file {} is not valid UTF-8", entry.path().display())
            })?;
            fs::write(
                &dest_path,
                render(text, name, description, systemd_order, maintainer, copyright_year),
            )
            .with_context(|| format!("write {}", dest_path.display()))?;
        }

        if is_executable_template(entry.path()) {
            set_executable(&dest_path)?;
        }
    }
    Ok(())
}

fn strip_for_kind(dest: &Path, kind: AppKind) -> Result<()> {
    let rm = |p: &str| -> Result<()> {
        let path = dest.join(p);
        if path.is_file() {
            fs::remove_file(&path).with_context(|| format!("remove {}", p))?;
        }
        Ok(())
    };
    let rm_dir = |p: &str| -> Result<()> {
        let path = dest.join(p);
        if path.is_dir() {
            fs::remove_dir_all(&path).with_context(|| format!("remove dir {}", p))?;
        }
        Ok(())
    };

    match kind {
        AppKind::Bun => {
            // Keep ui/index.html; remove React/Vite build setup.
            rm("src/ipc.ts")?;
            rm_dir("systemd")?;
            rm_dir("ui/src")?;
            rm("ui/package.json")?;
            rm("ui/tsconfig.json")?;
            rm("ui/vite.config.ts")?;
            rm("ui/postcss.config.js")?;
            rm("ui/tailwind.config.js")?;
            remove_standalone_leftovers(dest)?;
        }
        AppKind::BunFullstack => {
            // Keep full React UI; remove standalone files.
            rm("src/ipc.ts")?;
            rm_dir("systemd")?;
            remove_standalone_leftovers(dest)?;
        }
        AppKind::StandaloneBun => {
            // Keep IPC client and systemd; remove platform UI.
            rm_dir("ui")?;
            // Drop the fullstack-specific src/index variant if shipped.
            rm("src/index.standalone-fullstack.ts")?;
            // Promote *.standalone files into place, then clean leftovers.
            promote_standalone_debian(dest)?;
            remove_standalone_leftovers(dest)?;
        }
        AppKind::Cdylib => {
            // Platform cdylib: no UI, no binary, no systemd.
            rm("src/main.rs")?;
            rm("src/ipc.rs")?;
            rm("Cargo.standalone.toml")?;
            rm_dir("systemd")?;
            rm_dir("ui")?;
            rm("debian/postrm")?;
            remove_standalone_leftovers(dest)?;
        }
        AppKind::CdylibFullstack => {
            // Platform cdylib with React UI.
            rm("src/main.rs")?;
            rm("src/ipc.rs")?;
            rm("Cargo.standalone.toml")?;
            rm_dir("systemd")?;
            rm("debian/postrm")?;
            remove_standalone_leftovers(dest)?;
        }
        AppKind::StandaloneRust => {
            // Standalone binary: no cdylib lib.rs, no UI.
            rm("src/lib.rs")?;
            rm_dir("ui")?;
            // Swap Cargo.toml files: Cargo.standalone.toml → Cargo.toml
            let cargo_main = dest.join("Cargo.toml");
            let cargo_standalone = dest.join("Cargo.standalone.toml");
            if cargo_standalone.exists() {
                if cargo_main.exists() {
                    fs::remove_file(&cargo_main)
                        .with_context(|| "remove cdylib Cargo.toml")?;
                }
                fs::rename(&cargo_standalone, &cargo_main)
                    .with_context(|| "rename Cargo.standalone.toml -> Cargo.toml")?;
            }
            // Drop the fullstack-specific variants if the template repo ships them.
            rm("Cargo.standalone-fullstack.toml")?;
            rm("src/main.standalone-fullstack.rs")?;
            // Promote *.standalone files into place, then clean leftovers.
            promote_standalone_debian(dest)?;
            remove_standalone_leftovers(dest)?;
        }
        AppKind::StandaloneRustFullstack => {
            // Standalone binary that serves its own React UI: keep ui/, replace
            // src/main.rs with the fullstack variant if shipped, swap to the
            // fullstack Cargo.toml (axum + rust-embed deps) if shipped, and
            // promote standalone debian.
            rm("src/lib.rs")?;
            // Promote a standalone (binary) Cargo.toml. Prefer the
            // fullstack-specific variant (axum + rust-embed deps) if the
            // template repo ships one; otherwise fall back to the plain
            // standalone Cargo so the project at least compiles. Never leave
            // the cdylib Cargo.toml in place — it has `[lib] crate-type =
            // ["cdylib"]` but no `src/lib.rs`, which fails cargo parsing.
            let cargo_main = dest.join("Cargo.toml");
            let cargo_fullstack = dest.join("Cargo.standalone-fullstack.toml");
            let cargo_standalone = dest.join("Cargo.standalone.toml");
            let chosen_cargo = if cargo_fullstack.is_file() {
                Some(cargo_fullstack.clone())
            } else if cargo_standalone.is_file() {
                Some(cargo_standalone.clone())
            } else {
                None
            };
            match chosen_cargo {
                Some(src) => {
                    if cargo_main.exists() {
                        fs::remove_file(&cargo_main)
                            .with_context(|| "remove cdylib Cargo.toml")?;
                    }
                    fs::rename(&src, &cargo_main).with_context(|| {
                        format!(
                            "rename {} -> Cargo.toml",
                            src.file_name().and_then(|s| s.to_str()).unwrap_or("?")
                        )
                    })?;
                }
                None => {
                    anyhow::bail!(
                        "template missing Cargo.standalone-fullstack.toml or \
                         Cargo.standalone.toml — cannot scaffold a standalone \
                         binary; the cdylib Cargo.toml would not compile"
                    );
                }
            }
            // Drop the un-chosen sibling (if it existed alongside the chosen one).
            rm("Cargo.standalone-fullstack.toml")?;
            rm("Cargo.standalone.toml")?;
            // Swap fullstack main: src/main.standalone-fullstack.rs → src/main.rs
            // If the variant is missing, the pristine `src/main.rs` (a tokio
            // binary entry) is kept — fine, it just lacks Axum wiring.
            let main_rs = dest.join("src/main.rs");
            let main_fullstack = dest.join("src/main.standalone-fullstack.rs");
            if main_fullstack.exists() {
                if main_rs.exists() {
                    fs::remove_file(&main_rs)
                        .with_context(|| "remove cdylib src/main.rs")?;
                }
                fs::rename(&main_fullstack, &main_rs).with_context(|| {
                    "rename src/main.standalone-fullstack.rs -> src/main.rs"
                })?;
            }
            // A standalone binary MUST have src/main.rs at this point.
            if !main_rs.is_file() {
                anyhow::bail!(
                    "template missing src/main.rs (and no \
                     src/main.standalone-fullstack.rs variant) — cannot \
                     scaffold a standalone binary"
                );
            }
            // Promote *.standalone files (debian + Makefile + README), then
            // the fullstack-specific *.standalone-fullstack variants on top.
            promote_standalone_debian(dest)?;
            promote_standalone_fullstack(dest)?;
            remove_standalone_leftovers(dest)?;
        }
        AppKind::StandaloneBunFullstack => {
            // Standalone Bun daemon that serves its own React UI via Bun.serve:
            // keep ui/, replace src/index.ts with the fullstack variant, and
            // promote standalone + fullstack debian/Makefile flavors.
            // (No ipc.ts removal — standalone-fullstack still talks to the
            // platform over /run/node/control.sock when available.)
            let index_ts = dest.join("src/index.ts");
            let index_fullstack = dest.join("src/index.standalone-fullstack.ts");
            if index_fullstack.exists() {
                if index_ts.exists() {
                    fs::remove_file(&index_ts)
                        .with_context(|| "remove platform src/index.ts")?;
                }
                fs::rename(&index_fullstack, &index_ts).with_context(|| {
                    "rename src/index.standalone-fullstack.ts -> src/index.ts"
                })?;
            }
            promote_standalone_debian(dest)?;
            promote_standalone_fullstack(dest)?;
            remove_standalone_leftovers(dest)?;
        }
    }

    patch_manifest(dest, kind)?;
    Ok(())
}

/// Sanity-check the scaffolded tree against the chosen `AppKind`. Catches
/// template drift loudly (e.g. a template that ships an unexpected `[lib]`
/// `crate-type = ["cdylib"]` for a standalone binary kind, or a Bun kind
/// missing `package.json`). Runs after `strip_for_kind` so it reflects the
/// final state the user sees on disk.
///
/// Why: the strip step relies on convention-named template files (e.g.
/// `Cargo.standalone-fullstack.toml`) that may go missing if a template repo
/// changes. The old code silently kept the wrong file in place, producing a
/// project that fails to compile. Fail here with a clear error instead.
fn verify_scaffold(dest: &Path, kind: AppKind) -> Result<()> {
    let cargo_toml = dest.join("Cargo.toml");

    let needs_cdylib_lib = matches!(kind, AppKind::Cdylib | AppKind::CdylibFullstack);
    let needs_standalone_bin = matches!(
        kind,
        AppKind::StandaloneRust | AppKind::StandaloneRustFullstack
    );

    if needs_cdylib_lib || needs_standalone_bin {
        if !cargo_toml.is_file() {
            anyhow::bail!(
                "scaffolded tree is missing Cargo.toml at {}",
                cargo_toml.display()
            );
        }
        let body = fs::read_to_string(&cargo_toml)
            .with_context(|| format!("read {}", cargo_toml.display()))?;
        let has_lib_cdylib = body.contains("crate-type") && body.contains("cdylib");
        let has_bin_section = body.contains("[[bin]]");
        let lib_rs = dest.join("src/lib.rs");
        let main_rs = dest.join("src/main.rs");

        if needs_cdylib_lib {
            if !has_lib_cdylib {
                anyhow::bail!(
                    "Cargo.toml at {} declares no cdylib `[lib]` but kind {:?} \
                     requires one",
                    cargo_toml.display(),
                    kind
                );
            }
            if !lib_rs.is_file() {
                anyhow::bail!(
                    "kind {:?} requires src/lib.rs but it is missing at {}",
                    kind,
                    lib_rs.display()
                );
            }
        }

        if needs_standalone_bin {
            if has_lib_cdylib && !lib_rs.is_file() {
                anyhow::bail!(
                    "Cargo.toml at {} declares a cdylib `[lib]` but kind {:?} \
                     is a standalone binary and src/lib.rs is absent — the \
                     template's standalone Cargo override did not get promoted. \
                     Check that the template ships Cargo.standalone.toml or \
                     Cargo.standalone-fullstack.toml.",
                    cargo_toml.display(),
                    kind
                );
            }
            if !has_bin_section && !main_rs.is_file() {
                anyhow::bail!(
                    "kind {:?} requires a `[[bin]]` Cargo target or src/main.rs \
                     — both are missing",
                    kind
                );
            }
            if !main_rs.is_file() {
                anyhow::bail!(
                    "kind {:?} requires src/main.rs but it is missing at {}",
                    kind,
                    main_rs.display()
                );
            }
        }
    }

    if matches!(
        kind,
        AppKind::Bun
            | AppKind::BunFullstack
            | AppKind::StandaloneBun
            | AppKind::StandaloneBunFullstack
    ) {
        let pkg_json = dest.join("package.json");
        if !pkg_json.is_file() {
            anyhow::bail!(
                "kind {:?} requires package.json but it is missing at {}",
                kind,
                pkg_json.display()
            );
        }
    }

    Ok(())
}

/// Replace platform-flavored files with their .standalone variants when the
/// scaffolded kind is `StandaloneRust` or `StandaloneBun`.
///
/// For each entry below, if a `.standalone` sibling exists, it replaces the
/// platform-flavored file. Otherwise nothing happens (templates that don't
/// ship a given .standalone variant just keep their default).
fn promote_standalone_debian(dest: &Path) -> Result<()> {
    // (base_path, suffix) pairs. The base is relative to `dest`.
    // E.g. ("debian/postinst", "standalone") swaps debian/postinst.standalone → debian/postinst.
    let swaps: &[&str] = &[
        "debian/control.template",
        "debian/postinst",
        "debian/prerm",
        "debian/postrm",
        "Makefile",
        "README.md",
    ];

    for rel in swaps {
        let platform = dest.join(rel);
        let standalone = dest.join(format!("{}.standalone", rel));
        if standalone.exists() {
            if platform.exists() {
                fs::remove_file(&platform)
                    .with_context(|| format!("remove platform {}", rel))?;
            }
            fs::rename(&standalone, &platform)
                .with_context(|| format!("rename {}.standalone -> {}", rel, rel))?;
        }
    }
    Ok(())
}

/// Apply fullstack-specific overrides on top of an already-promoted
/// `.standalone` tree. For each entry below, if a `.standalone-fullstack`
/// sibling exists, it replaces the file currently at that path (which by this
/// point is the `.standalone`-promoted version, NOT the platform default).
///
/// Used by `StandaloneRustFullstack` and `StandaloneBunFullstack` after
/// `promote_standalone_debian` has run.
fn promote_standalone_fullstack(dest: &Path) -> Result<()> {
    let swaps: &[&str] = &["Makefile", "README.md"];

    for rel in swaps {
        let current = dest.join(rel);
        let fullstack = dest.join(format!("{}.standalone-fullstack", rel));
        if fullstack.exists() {
            if current.exists() {
                fs::remove_file(&current)
                    .with_context(|| format!("remove current {}", rel))?;
            }
            fs::rename(&fullstack, &current).with_context(|| {
                format!("rename {}.standalone-fullstack -> {}", rel, rel)
            })?;
        }
    }
    Ok(())
}

/// Remove any leftover `.standalone` / `.standalone-fullstack` files from a
/// destination that was scaffolded as a non-matching kind. Walks the full tree
/// so it catches any siblings the templates ship for other-path scaffolds.
fn remove_standalone_leftovers(dest: &Path) -> Result<()> {
    for entry in WalkDir::new(dest).into_iter().filter_map(|e| e.ok()) {
        if !entry.file_type().is_file() {
            continue;
        }
        if let Some(name) = entry.path().file_name().and_then(|s| s.to_str()) {
            let is_standalone_leftover = name.ends_with(".standalone")
                || (name.contains(".standalone.") && !name.starts_with('.'));
            let is_fullstack_leftover = name.ends_with(".standalone-fullstack")
                || (name.contains(".standalone-fullstack.") && !name.starts_with('.'));
            if is_standalone_leftover || is_fullstack_leftover {
                fs::remove_file(entry.path())
                    .with_context(|| format!("remove leftover {}", entry.path().display()))?;
            }
        }
    }
    Ok(())
}

fn patch_manifest(dest: &Path, kind: AppKind) -> Result<()> {
    let path = dest.join("manifest.json");
    if !path.exists() {
        return Ok(());
    }

    let raw = fs::read_to_string(&path).context("read manifest.json")?;
    let mut v: serde_json::Value = serde_json::from_str(&raw).context("parse manifest.json")?;

    let obj = v.as_object_mut().ok_or_else(|| anyhow!("manifest.json is not a JSON object"))?;

    // Strip the scaffolder-documentation block (purely descriptive; it tells
    // this fn which placeholders to resolve, but the resolution itself happens
    // in code below — the doc is not consumed at runtime).
    obj.remove("_scaffolder");

    // Extract app name once (for entrypoint computation on standalone-rust).
    let app_name = obj
        .get("name")
        .and_then(|v| v.as_str())
        .unwrap_or("app")
        .to_string();

    match kind {
        AppKind::Bun => {
            obj.insert("app_type".into(), serde_json::json!("bun"));
            obj.insert("has_ui".into(), serde_json::json!(true));
            obj.insert("ui_path".into(), serde_json::json!("ui"));
            obj.insert("entrypoint".into(), serde_json::json!("dist/index.js"));
        }
        AppKind::BunFullstack => {
            obj.insert("app_type".into(), serde_json::json!("bun"));
            obj.insert("has_ui".into(), serde_json::json!(true));
            obj.insert("entrypoint".into(), serde_json::json!("dist/index.js"));
            // ui_path stays as-is from template (e.g. "dist" or "ui/dist")
        }
        AppKind::StandaloneBun => {
            obj.insert("app_type".into(), serde_json::json!("standalone"));
            obj.insert("has_ui".into(), serde_json::json!(false));
            obj.insert("entrypoint".into(), serde_json::json!("dist/index.js"));
            obj.remove("ui_path");
            if let Some(caps) = obj.get_mut("capabilities") {
                if let Some(c) = caps.as_object_mut() {
                    c.insert("requires".into(), serde_json::json!([]));
                    c.insert("provides".into(), serde_json::json!([]));
                }
            }
            obj.remove("provides");
        }
        AppKind::Cdylib => {
            obj.insert("app_type".into(), serde_json::json!("native"));
            obj.insert("has_ui".into(), serde_json::json!(false));
            obj.insert("entrypoint".into(), serde_json::json!("app.so"));
            obj.remove("ui_path");
        }
        AppKind::CdylibFullstack => {
            obj.insert("app_type".into(), serde_json::json!("native"));
            obj.insert("has_ui".into(), serde_json::json!(true));
            obj.insert("entrypoint".into(), serde_json::json!("app.so"));
            // ui_path stays as-is
        }
        AppKind::StandaloneRust => {
            obj.insert("app_type".into(), serde_json::json!("standalone"));
            obj.insert("has_ui".into(), serde_json::json!(false));
            // Standalone Rust binaries install to /usr/bin/node-app-<name>;
            // the manifest entrypoint names that binary so validators + tooling
            // can find it.
            obj.insert(
                "entrypoint".into(),
                serde_json::json!(format!("node-app-{}", app_name)),
            );
            obj.remove("ui_path");
            if let Some(caps) = obj.get_mut("capabilities") {
                if let Some(c) = caps.as_object_mut() {
                    c.insert("requires".into(), serde_json::json!([]));
                    c.insert("provides".into(), serde_json::json!([]));
                }
            }
            obj.remove("provides");
        }
        AppKind::StandaloneRustFullstack => {
            // Spec 0001 (template-0001-fullstack-standalone-via-platform-proxy):
            // no HTTP server, no port. UI ships to /usr/lib/node/apps/<name>/dist/
            // and is served by the platform's reverse proxy at
            // /api/v2/node-apps/<name>/ui/. REST calls arrive at the app's UDS
            // as `app.handle_request` JSON-RPC. So has_ui=true with the same
            // ui_path the platform mounts (dist).
            obj.insert("app_type".into(), serde_json::json!("standalone"));
            obj.insert("has_ui".into(), serde_json::json!(true));
            obj.insert("ui_path".into(), serde_json::json!("dist"));
            obj.insert(
                "entrypoint".into(),
                serde_json::json!(format!("node-app-{}", app_name)),
            );
            // Preserve the template's `capabilities.requires` (it lists
            // core.cron/notifications/websocket which the helper modules use)
            // and the example `provides` entry. The user trims them after
            // wiring their own handlers.
        }
        AppKind::StandaloneBunFullstack => {
            obj.insert("app_type".into(), serde_json::json!("standalone"));
            obj.insert("has_ui".into(), serde_json::json!(true));
            obj.insert("entrypoint".into(), serde_json::json!("dist/index.js"));
            // ui_path matches where the .deb installs the Vite output relative
            // to app_path: scripts/build-deb.sh stages `ui/dist/` as
            // `/usr/lib/node/apps/<name>/ui-dist/`. The platform's UI handler
            // mounts ServeDir against `app_path.join("ui-dist")` directly.
            // The daemon's own Bun.serve reads from the same path via
            // NODE_APP_UI_DIR.
            obj.insert("ui_path".into(), serde_json::json!("ui-dist"));
            if let Some(caps) = obj.get_mut("capabilities") {
                if let Some(c) = caps.as_object_mut() {
                    c.insert("requires".into(), serde_json::json!([]));
                    c.insert("provides".into(), serde_json::json!([]));
                }
            }
            obj.remove("provides");
        }
    }

    // ── Resolve the variant-aware placeholders ────────────────────────────────
    // The template carries these as literal "{{placeholder}}" strings inside
    // quoted JSON values so the file parses as valid JSON. We substitute the
    // right value (or remove the key entirely) based on the chosen variant.
    //
    //   `{{hot_reload}}`  → "experimental" for cdylib variants, key removed
    //                       for standalone (where hot_reload doesn't apply).
    //   `{{socket_path}}` → "/run/node-app-<name>.sock" for standalone variants,
    //                       the whole `standalone` block removed otherwise.
    //
    // We detect "needs resolution" by checking whether the field still holds
    // a literal "{{...}}" — apps generated from older templates without these
    // placeholders are left alone, so this is safe to run unconditionally.

    let app_type_str = obj
        .get("app_type")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let is_standalone = app_type_str == "standalone";

    // hot_reload
    let hot_reload_is_placeholder = obj
        .get("hot_reload")
        .and_then(|v| v.as_str())
        .map(|s| s.starts_with("{{") && s.ends_with("}}"))
        .unwrap_or(false);
    if is_standalone {
        // Standalone apps load by spawning a binary, not by dlopen-ing a .so —
        // there's no SDK-level hot reload story. Drop the field rather than
        // leaving it set to a value the validator would reject.
        obj.remove("hot_reload");
    } else if hot_reload_is_placeholder {
        obj.insert("hot_reload".into(), serde_json::json!("experimental"));
    }

    // standalone.socket_path
    if is_standalone {
        let socket = format!("/run/node-app-{}.sock", app_name);
        match obj.get_mut("standalone").and_then(|v| v.as_object_mut()) {
            Some(s) => {
                s.insert("socket_path".into(), serde_json::json!(socket));
            }
            None => {
                obj.insert(
                    "standalone".into(),
                    serde_json::json!({ "socket_path": socket }),
                );
            }
        }
    } else {
        // Non-standalone manifests must not carry a `standalone` block —
        // validate::run() rejects it. The template includes the block with
        // a placeholder socket_path; strip it for cdylib/bun variants.
        obj.remove("standalone");
    }

    let out = serde_json::to_string_pretty(&v).context("serialize manifest.json")?;
    fs::write(&path, out + "\n").context("write manifest.json")?;
    Ok(())
}

fn render(
    template: &str,
    name: &str,
    description: &str,
    systemd_order: &str,
    maintainer: &MaintainerInfo,
    copyright_year: &str,
) -> String {
    let name_upper = name_to_env_token(name);
    template
        .replace("{{name}}", name)
        // `{{NAME_UPPER}}` must be substituted BEFORE `{{name}}` would naturally
        // catch it, but since `replace` is literal-string it's already safe.
        // Substituting here lets standalone apps build env-var names like
        // `NODE_APP_<NAME_UPPER>_MANIFEST_PATH`.
        .replace("{{NAME_UPPER}}", &name_upper)
        .replace("{{description}}", description)
        .replace("{{systemd_order}}", systemd_order)
        .replace("{{maintainer_name}}", &maintainer.name)
        .replace("{{maintainer_email}}", &maintainer.email)
        // Note: {{maintainer}} is the combined "Name <email>" form. It must be
        // substituted AFTER the split forms so we don't accidentally interpolate
        // "Name <email>" into a context that already split the parts.
        .replace("{{maintainer}}", &maintainer.combined)
        .replace("{{copyright_year}}", copyright_year)
}

/// Turn a kebab-case app name into an env-var-safe token: uppercase, with
/// `-` rewritten to `_`. E.g. "foo-bar" → "FOO_BAR".
fn name_to_env_token(name: &str) -> String {
    name.to_ascii_uppercase().replace('-', "_")
}

// ── SDK version resolution ────────────────────────────────────────────────────

/// Scan the scaffolded project for `package.json` and `Cargo.toml` files that
/// reference the node SDK packages and replace their versions with the latest
/// published releases.  Failures are non-fatal — the template version is kept.
fn patch_sdk_versions(dest: &Path) {
    if let Some(v) = latest_npm_version("@econ-v1/app-sdk") {
        patch_npm_dep(dest, "@econ-v1/app-sdk", &v);
    }
    if let Some(v) = latest_cargo_version("node-app-sdk-rust") {
        patch_cargo_dep(dest, "node-app-sdk-rust", &v);
    }
}

fn latest_npm_version(pkg: &str) -> Option<String> {
    let encoded = pkg.replace('/', "%2F");
    let url = format!("https://registry.npmjs.org/{encoded}/latest");
    let resp: serde_json::Value = ureq::get(&url)
        .set("Accept", "application/json")
        .call()
        .ok()?
        .into_json()
        .ok()?;
    resp.get("version")?.as_str().map(String::from)
}

fn latest_cargo_version(krate: &str) -> Option<String> {
    let url = format!("https://crates.io/api/v1/crates/{krate}");
    let resp: serde_json::Value = ureq::get(&url)
        .set("Accept", "application/json")
        .set("User-Agent", "node-app-build/0.1.0 (https://github.com/econ-v1/node)")
        .call()
        .ok()?
        .into_json()
        .ok()?;
    resp.pointer("/crate/newest_version")?.as_str().map(String::from)
}

fn patch_npm_dep(dest: &Path, pkg: &str, version: &str) {
    let path = dest.join("package.json");
    if !path.exists() {
        return;
    }
    let Ok(text) = fs::read_to_string(&path) else { return };
    let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&text) else { return };

    let patched = patch_json_dep(&mut json, pkg, version);
    if patched {
        if let Ok(out) = serde_json::to_string_pretty(&json) {
            let _ = fs::write(&path, out + "\n");
            println!("  → @econ-v1/app-sdk pinned to {version} (latest)");
        }
    }
}

fn patch_json_dep(json: &mut serde_json::Value, pkg: &str, version: &str) -> bool {
    let mut patched = false;
    for section in ["dependencies", "devDependencies", "peerDependencies"] {
        if let Some(deps) = json.get_mut(section).and_then(|d| d.as_object_mut()) {
            if deps.contains_key(pkg) {
                deps.insert(pkg.to_string(), serde_json::Value::String(version.to_string()));
                patched = true;
            }
        }
    }
    patched
}

fn patch_cargo_dep(dest: &Path, krate: &str, version: &str) {
    // Walk one level: Cargo.toml at root, or inside a workspace member.
    for candidate in [dest.join("Cargo.toml")] {
        if !candidate.exists() {
            continue;
        }
        let Ok(text) = fs::read_to_string(&candidate) else { continue };
        // Simple line-level replacement: handles `crate = "..."` and `crate = { version = "..." ... }`.
        let mut changed = false;
        let new_text: String = text
            .lines()
            .map(|line| {
                if line.trim_start().starts_with(krate)
                    && (line.contains("= \"") || line.contains("version ="))
                {
                    changed = true;
                    // Replace any quoted version string on this line.
                    replace_toml_version(line, version)
                } else {
                    line.to_string()
                }
            })
            .collect::<Vec<_>>()
            .join("\n")
            + "\n";
        if changed {
            let _ = fs::write(&candidate, new_text);
            println!("  → node-app-sdk-rust pinned to {version} (latest)");
        }
    }
}

/// Replace the first quoted version string in a TOML line, preserving the rest.
fn replace_toml_version(line: &str, new_version: &str) -> String {
    // Match `= "..."` or `version = "..."` — replace the content inside quotes.
    let mut result = String::new();
    let mut chars = line.chars().peekable();
    let mut replaced = false;
    while let Some(c) = chars.next() {
        if c == '"' && !replaced {
            result.push('"');
            // Skip until closing quote.
            for inner in chars.by_ref() {
                if inner == '"' {
                    break;
                }
            }
            result.push_str(new_version);
            result.push('"');
            replaced = true;
        } else {
            result.push(c);
        }
    }
    result
}

// ── Validation ────────────────────────────────────────────────────────────────

fn validate_name(name: &str) -> Result<()> {
    if name.is_empty() {
        bail!("name cannot be empty");
    }
    if name.starts_with("node-app-") {
        bail!("name must not start with 'node-app-'; the .deb script adds the prefix");
    }
    if !name
        .chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
    {
        bail!("name '{}' must be lowercase alphanumeric + hyphens", name);
    }
    if !name
        .chars()
        .next()
        .map(|c| c.is_ascii_lowercase())
        .unwrap_or(false)
    {
        bail!("name '{}' must start with a lowercase letter", name);
    }
    Ok(())
}

fn validate_github_slug(slug: &str) -> Result<()> {
    let parts: Vec<&str> = slug.split('/').collect();
    if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
        bail!("--github value '{}' must be in the form 'org/repo'", slug);
    }
    if slug.chars().any(|c| c.is_whitespace()) {
        bail!("--github value '{}' contains whitespace", slug);
    }
    Ok(())
}

// ── Post-scaffold steps ───────────────────────────────────────────────────────

fn run_deps_update(dest: &Path, kind: AppKind) -> Result<()> {
    match kind {
        AppKind::Bun
        | AppKind::BunFullstack
        | AppKind::StandaloneBun
        | AppKind::StandaloneBunFullstack => {
            if which("bun").is_some() {
                run_in(dest, "bun", &["install"], "bun install")?;
            } else {
                eprintln!(
                    "→ skipping `bun install` (bun not found on PATH; install from https://bun.sh)"
                );
            }
        }
        AppKind::Cdylib
        | AppKind::CdylibFullstack
        | AppKind::StandaloneRust
        | AppKind::StandaloneRustFullstack => {
            if which("cargo").is_some() {
                run_in(dest, "cargo", &["update"], "cargo update")?;
            } else {
                eprintln!(
                    "→ skipping `cargo update` (cargo not found on PATH; \
                     install Rust from https://rustup.rs)"
                );
            }
        }
    }
    Ok(())
}

fn init_git(dest: &Path, name: &str) -> Result<()> {
    if which("git").is_none() {
        eprintln!("→ skipping `git init` (git not found on PATH)");
        return Ok(());
    }

    let git_exists = dest.join(".git").is_dir();

    if !git_exists {
        run_in(dest, "git", &["init", "-q", "-b", "main"], "git init")?;
    }

    // Stage all files regardless — handles fresh init and re-scaffold into existing repo.
    run_in(dest, "git", &["add", "."], "git add .")?;

    // Commit only when there is something staged; skip silently otherwise.
    let has_staged = Command::new("git")
        .current_dir(dest)
        .args(["diff", "--cached", "--quiet"])
        .status()
        .map(|s| !s.success()) // exit 1 = changes staged
        .unwrap_or(false);

    if has_staged {
        let msg = format!("chore: scaffold {} via node-app new", name);
        run_in(dest, "git", &["commit", "-q", "-m", &msg], "git commit")?;
    }

    println!(
        "✓ Initialized git repo in {} (branch: main)",
        dest.display()
    );
    Ok(())
}

/// Create a new GitHub repo from a template and clone it to `dest`, then
/// substitute scaffold placeholders in the cloned tree.
///
/// Uses `gh repo create <slug> --template <template_repo> --private` so the
/// new repo is properly recorded as "generated from <template_repo>" on
/// GitHub. The `dest` directory must exist and be empty when called.
#[allow(clippy::too_many_arguments)]
fn github_template_scaffold(
    dest: &Path,
    slug: &str,
    template_repo: &str,
    _kind: AppKind,
    name: &str,
    description: &str,
    systemd_order: &str,
    maintainer: &MaintainerInfo,
    copyright_year: &str,
) -> Result<()> {
    ensure_gh()?;

    let auth_ok = Command::new("gh")
        .args(["auth", "status"])
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if !auth_ok {
        eprintln!(
            "→ `gh auth status` failed — running `gh auth login` first is recommended.\n\
             Continuing; you may be prompted to authenticate."
        );
    }

    println!(
        "→ creating GitHub repo {} from template {}...",
        slug, template_repo
    );
    let create_ok = Command::new("gh")
        .args([
            "repo",
            "create",
            slug,
            "--template",
            template_repo,
            "--private",
        ])
        .status()
        .with_context(|| {
            format!(
                "invoke `gh repo create {} --template {} --private`",
                slug, template_repo
            )
        })?
        .success();
    if !create_ok {
        bail!(
            "Failed to create GitHub repo '{}' from template '{}'.\n\
             Common causes: the repo already exists, the template repo isn't \
             marked as a GitHub Template (Settings → Template repository), \
             insufficient org permissions, or expired auth.\n\
             Local scaffold has not been written yet.",
            slug,
            template_repo
        );
    }

    // The pre-created destination must be removed so `git clone` can land cleanly.
    if dest.exists() {
        if fs::read_dir(dest)
            .with_context(|| format!("read_dir {}", dest.display()))?
            .next()
            .is_some()
        {
            bail!(
                "destination '{}' unexpectedly non-empty before clone",
                dest.display()
            );
        }
        fs::remove_dir(dest)
            .with_context(|| format!("remove empty dest {}", dest.display()))?;
    }

    let url = format!("https://github.com/{}.git", slug);

    // `gh repo create --template` returns success before GitHub has finished
    // copying template contents into the new repo — cloning immediately races
    // and lands on an empty repo. Poll `git ls-remote` until at least one branch
    // ref is published, then proceed with the clone.
    print!("→ waiting for template contents to be ready");
    let _ = std::io::Write::flush(&mut std::io::stdout());
    let mut populated = false;
    for _ in 0..60 {
        let has_refs = Command::new("git")
            .args(["ls-remote", "--heads", "--exit-code", &url])
            .output()
            .map(|o| o.status.success() && !o.stdout.is_empty())
            .unwrap_or(false);
        if has_refs {
            populated = true;
            break;
        }
        print!(".");
        let _ = std::io::Write::flush(&mut std::io::stdout());
        thread::sleep(Duration::from_secs(1));
    }
    println!();
    if !populated {
        bail!(
            "Timed out (60s) waiting for template population in {}.\n\
             The GitHub repo was created but appears empty.\n\
             Try cloning manually in a moment:\n  git clone {} {}",
            slug,
            url,
            dest.display()
        );
    }

    let clone_ok = Command::new("git")
        .args(["clone", "-q", &url, dest.to_str().unwrap_or_default()])
        .status()
        .with_context(|| format!("invoke `git clone {}`", url))?
        .success();
    if !clone_ok {
        bail!(
            "Failed to clone the newly-created repo {}.\n\
             The GitHub repo was created — clone it manually:\n  git clone {} {}",
            slug,
            url,
            dest.display()
        );
    }

    transform_template_in_place(
        dest,
        name,
        description,
        systemd_order,
        maintainer,
        copyright_year,
    )?;

    Ok(())
}

/// Walk `root` (skipping `.git/`) and apply placeholder substitution to both
/// file contents and basenames, mutating the tree in place. File contents are
/// rewritten first; renames happen bottom-up so a parent dir can be renamed
/// after its children are already renamed.
fn transform_template_in_place(
    root: &Path,
    name: &str,
    description: &str,
    systemd_order: &str,
    maintainer: &MaintainerInfo,
    copyright_year: &str,
) -> Result<()> {
    let entries: Vec<_> = WalkDir::new(root)
        .min_depth(1)
        .contents_first(true)
        .into_iter()
        .filter_entry(|e| !(e.depth() == 1 && e.file_name() == ".git"))
        .filter_map(|e| e.ok())
        .collect();

    // Pass 1: rewrite file contents.
    for entry in &entries {
        if !entry.file_type().is_file() {
            continue;
        }
        let path = entry.path();
        let raw = fs::read(path).with_context(|| format!("read {}", path.display()))?;
        if raw.contains(&0u8) {
            continue; // binary
        }
        let text = match std::str::from_utf8(&raw) {
            Ok(t) => t,
            Err(_) => continue,
        };
        let rendered = render(
            text,
            name,
            description,
            systemd_order,
            maintainer,
            copyright_year,
        );
        if rendered != text {
            fs::write(path, rendered)
                .with_context(|| format!("write {}", path.display()))?;
        }
    }

    // Pass 2: rename basenames containing placeholders (bottom-up).
    for entry in &entries {
        let path = entry.path();
        let basename = match path.file_name() {
            Some(b) => b.to_string_lossy().into_owned(),
            None => continue,
        };
        if !basename.contains("{{") {
            continue;
        }
        let rendered_basename = render(
            &basename,
            name,
            description,
            systemd_order,
            maintainer,
            copyright_year,
        );
        if rendered_basename == basename {
            continue;
        }
        let parent = match path.parent() {
            Some(p) => p,
            None => continue,
        };
        let new_path = parent.join(&rendered_basename);
        fs::rename(path, &new_path).with_context(|| {
            format!("rename {} -> {}", path.display(), new_path.display())
        })?;
    }
    Ok(())
}

/// Stage all scaffold mutations, commit, and push to the already-configured
/// `origin` remote. Used by the GitHub template flow after
/// `github_template_scaffold` has already cloned the new repo to `dest`.
fn commit_and_push_scaffold(dest: &Path, name: &str, slug: &str) -> Result<()> {
    if !dest.join(".git").is_dir() {
        bail!(
            "expected git repo at {} but .git/ is missing — \
             github_template_scaffold did not run as expected",
            dest.display()
        );
    }

    run_in(dest, "git", &["add", "-A"], "git add -A")?;

    let has_staged = Command::new("git")
        .current_dir(dest)
        .args(["diff", "--cached", "--quiet"])
        .status()
        .map(|s| !s.success())
        .unwrap_or(false);

    if has_staged {
        let msg = format!("chore: scaffold {} via node-app new", name);
        run_in(dest, "git", &["commit", "-q", "-m", &msg], "git commit")?;
    }

    run_in(dest, "git", &["push", "-q", "origin", "HEAD"], "git push")?;
    println!(
        "✓ Created GitHub repo https://github.com/{} from template and pushed scaffold customizations",
        slug
    );
    Ok(())
}

fn create_github_repo(dest: &Path, slug: &str) -> Result<()> {
    if which("gh").is_none() {
        eprintln!(
            "→ skipping `gh repo create` (gh CLI not found on PATH; install from https://cli.github.com).\n\
             Local scaffold is still ready. To bootstrap manually:\n  \
             gh repo create {} --private --source={} --push",
            slug,
            dest.display()
        );
        return Ok(());
    }

    let auth_ok = Command::new("gh")
        .args(["auth", "status"])
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if !auth_ok {
        eprintln!(
            "→ `gh auth status` failed — running `gh auth login` first is recommended.\n\
             Continuing with repo creation; you may be prompted to authenticate."
        );
    }

    // Create the repo without --source/--push to avoid "Unable to add remote origin"
    // when origin already exists in the local git repo.
    let create_ok = Command::new("gh")
        .current_dir(dest)
        .args(["repo", "create", slug, "--private"])
        .status()
        .with_context(|| format!("invoke `gh repo create {}`", slug))?
        .success();
    if !create_ok {
        bail!(
            "gh repo create failed. Common causes: repo already exists, \
             org permissions missing, or auth expired. Local scaffold remains intact at {}.",
            dest.display()
        );
    }

    // Set or update origin to point at the new repo.
    let remote_url = format!("https://github.com/{}.git", slug);
    let has_origin = Command::new("git")
        .current_dir(dest)
        .args(["remote", "get-url", "origin"])
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false);
    if has_origin {
        run_in(
            dest,
            "git",
            &["remote", "set-url", "origin", &remote_url],
            "git remote set-url origin",
        )?;
    } else {
        run_in(
            dest,
            "git",
            &["remote", "add", "origin", &remote_url],
            "git remote add origin",
        )?;
    }

    run_in(
        dest,
        "git",
        &["push", "-u", "origin", "HEAD"],
        "git push",
    )?;
    println!(
        "✓ Created GitHub repo https://github.com/{} and pushed initial commit",
        slug
    );

    upload_release_secrets(slug);
    Ok(())
}

fn upload_release_secrets(slug: &str) {
    let keys_dir = std::env::var_os("NODE_DEV_KEYS_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|| {
            std::env::var_os("HOME")
                .map(PathBuf::from)
                .unwrap_or_default()
                .join(".config/node")
        });

    let secrets: &[(&str, &str, &str)] = &[
        (
            "GPG_PRIVATE_KEY",
            "gpg-private-key.asc",
            "Generate with `gpg --armor --export-secret-keys <key-id>` and save to this path",
        ),
        (
            "GPG_PASSPHRASE",
            "gpg-passphrase.txt",
            "Plain-text passphrase matching GPG_PRIVATE_KEY",
        ),
        (
            "APT_REPO_DISPATCH_TOKEN",
            "apt-repo-dispatch-token.txt",
            "GitHub fine-grained PAT with `actions:write` on econ-v1/node-releases",
        ),
    ];

    for (name, basename, hint) in secrets {
        let path = keys_dir.join(basename);
        if !path.exists() {
            eprintln!(
                "→ secret {} not found at {}. {}.\n  \
                 Set manually later: `gh secret set {} --repo {} < /path/to/secret`",
                name,
                path.display(),
                hint,
                name,
                slug
            );
            continue;
        }
        let status = Command::new("gh")
            .args(["secret", "set", name, "--repo", slug])
            .stdin(fs::File::open(&path).expect("opened above"))
            .status();
        match status {
            Ok(s) if s.success() => println!("✓ Set GitHub secret {} on {}", name, slug),
            Ok(s) => eprintln!(
                "→ `gh secret set {}` exited {} — set manually with: \
                 `gh secret set {} --repo {} < {}`",
                name,
                s.code().unwrap_or(-1),
                name,
                slug,
                path.display()
            ),
            Err(e) => eprintln!(
                "→ failed to invoke `gh secret set {}`: {}. \
                 Set manually: `gh secret set {} --repo {} < {}`",
                name, e, name, slug, path.display()
            ),
        }
    }
}

// ── Utilities ─────────────────────────────────────────────────────────────────

fn ensure_gh() -> Result<()> {
    if which("gh").is_none() {
        bail!(
            "gh CLI not found on PATH.\n\
             Install from https://cli.github.com then authenticate with `gh auth login`.\n\
             Templates are fetched from GitHub — gh is required for remote repos.\n\
             For offline use, set NODE_APP_TEMPLATES_REPO to a local directory path."
        );
    }
    Ok(())
}

fn run_in(dest: &Path, cmd: &str, args: &[&str], label: &str) -> Result<()> {
    let status = Command::new(cmd)
        .current_dir(dest)
        .args(args)
        .status()
        .with_context(|| format!("invoke `{}`", label))?;
    if !status.success() {
        bail!("`{}` exited {}", label, status.code().unwrap_or(-1));
    }
    Ok(())
}

fn which(bin: &str) -> Option<PathBuf> {
    let path = std::env::var_os("PATH")?;
    for dir in std::env::split_paths(&path) {
        let candidate = dir.join(bin);
        if candidate.is_file() {
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                if let Ok(meta) = candidate.metadata() {
                    if meta.permissions().mode() & 0o111 != 0 {
                        return Some(candidate);
                    }
                }
            }
            #[cfg(not(unix))]
            {
                return Some(candidate);
            }
        }
    }
    None
}

fn tmp_dir() -> Result<PathBuf> {
    let ts = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis())
        .unwrap_or(0);
    Ok(std::env::temp_dir().join(format!("node-app-templates-{ts}")))
}

fn is_executable_template(path: &Path) -> bool {
    let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
        return false;
    };
    // Debian maintainer scripts (and their `.standalone` siblings, which
    // promote_standalone_debian renames into place for standalone-* kinds).
    let base = name.strip_suffix(".standalone").unwrap_or(name);
    if matches!(base, "postinst" | "prerm" | "postrm" | "preinst") {
        return true;
    }
    // Shell scripts under scripts/ (build-deb.sh, etc.) — match by extension.
    name.ends_with(".sh")
}

fn set_executable(path: &Path) -> Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perm = fs::metadata(path)?.permissions();
        perm.set_mode(0o755);
        fs::set_permissions(path, perm)?;
    }
    #[cfg(not(unix))]
    {
        let _ = path;
    }
    Ok(())
}
