//! `node-app package --target <arch>`
//!
//! Bundles all .deb staging logic in-process — no external shell script.
//! Reads the project's manifest at `<project>/manifest.json` (no monorepo
//! assumption), runs the appropriate build (cargo for native/standalone,
//! bun for bun), stages the payload + UI bundle + DEBIAN control files
//! into a temp dir, and invokes `dpkg-deb` to produce the final .deb.
//!
//! ## Cross-compilation modes
//!
//! - `--no-docker` (or auto when host toolchain is present): cargo runs on
//!   the host. Requires the matching `rustup target` and a `*-linux-gnu-gcc`
//!   linker for arm64/amd64 cross builds, plus `dpkg-deb`.
//! - `--docker` (or auto when host toolchain is missing): cargo runs inside
//!   `infra/docker/Dockerfile.app-cross-compile`, the project is bind-mounted,
//!   and `dpkg-deb` runs in a small Debian container too if it's not on the
//!   host (e.g. macOS dev box). Output lands in the host's `dist/app-debs/`.
//!
//! Override the auto decision with `--docker` / `--no-docker`. Auto-detection
//! probes for `rustup target list --installed`, `<linker> --version`, and
//! `dpkg-deb --version`. Bun apps never need Docker for the build itself
//! (Architecture: all), but may still need it for dpkg-deb pack.

use crate::commands::validate;
use crate::manifest::{AppManifest, AppType};
use anyhow::{bail, Context, Result};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;

/// How to drive the cross-toolchain.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DockerMode {
    Auto,
    Force,
    Disable,
}

pub fn run(
    project_path: &Path,
    target: &str,
    version: Option<&str>,
    out: Option<&Path>,
    docker_mode: DockerMode,
) -> Result<()> {
    let manifest = validate::run(project_path)?;

    match (target, manifest.app_type) {
        ("all", AppType::Native) => {
            bail!("native (cdylib) apps cannot ship as Architecture: all; use --target arm64 or amd64")
        }
        ("amd64" | "arm64", AppType::Bun) => {
            bail!("bun apps must ship as Architecture: all; use --target all")
        }
        ("amd64" | "arm64" | "all", _) => {}
        (other, _) => bail!("unsupported --target '{}': use amd64, arm64, or all", other),
    }
    if matches!(manifest.app_type, AppType::Standalone) {
        bail!("standalone app_type .deb packaging is not yet implemented in `node-app package`");
    }

    let project_path = project_path.canonicalize()?;
    let use_docker = decide_use_docker(&manifest, target, docker_mode)?;
    if use_docker {
        println!("» build mode: docker (cross-toolchain in container)");
    } else {
        println!("» build mode: host");
    }

    // 1. Build payload.
    match manifest.app_type {
        AppType::Bun => build_bun(&project_path, &manifest)?,
        AppType::Native => build_native(&project_path, &manifest, target, use_docker)?,
        AppType::Standalone => unreachable!("validated above"),
    }

    // 2. Stage + pack the .deb.
    let pkg_version = version
        .map(str::to_string)
        .unwrap_or_else(|| manifest.version.clone());
    let pkg_name = format!("node-app-{}", manifest.name);
    let deb_filename = format!("{}_{}_{}.deb", pkg_name, pkg_version, target);

    let stage_root = tempdir_prefixed("node-app-deb-")?;
    let stage = stage_root.path.clone();
    let install_path = format!("usr/lib/node/apps/{}", manifest.name);

    fs::create_dir_all(stage.join("DEBIAN"))?;
    fs::create_dir_all(stage.join(&install_path))?;
    fs::create_dir_all(stage.join(format!("usr/share/doc/{}", pkg_name)))?;

    fs::copy(
        project_path.join("manifest.json"),
        stage.join(&install_path).join("manifest.json"),
    )
    .context("copy manifest.json into stage")?;

    stage_payload(&project_path, &stage, &install_path, &manifest, target)?;
    stage_ui(&project_path, &stage, &install_path, &manifest)?;
    write_copyright(&stage, &pkg_name)?;
    render_control(&project_path, &stage, &manifest, &pkg_version, target)?;
    copy_maintainer_scripts(&project_path, &stage, &manifest.name)?;
    inject_port_registry_snippet(&stage, &manifest)?;

    // World-readable; dpkg-deb sets ownership via --root-owner-group.
    chmod_recursive_a_plus_rx(&stage)?;

    let out_rel = out.map(Path::to_path_buf);
    let out_dir = out_rel.unwrap_or_else(|| project_path.join("dist/app-debs"));
    fs::create_dir_all(&out_dir).with_context(|| format!("mkdir {}", out_dir.display()))?;
    let deb_output = out_dir.join(&deb_filename);

    pack_deb(&stage, &deb_output, use_docker)?;
    sanity_size_check(&deb_output)?;
    println!("✓ Built {}", deb_output.display());
    Ok(())
}

// =============================================================================
// Build step
// =============================================================================

fn build_bun(project_path: &Path, manifest: &AppManifest) -> Result<()> {
    run_step(
        "bun install",
        Command::new("bun").arg("install").current_dir(project_path),
    )?;
    // `bun run build` is convention; if no script is declared it's a no-op.
    run_step(
        "bun run build",
        Command::new("bun").args(["run", "build"]).current_dir(project_path),
    )
    .ok();

    if manifest.has_ui {
        let ui_root_segment = manifest
            .ui_path
            .as_deref()
            .unwrap_or("ui/dist")
            .split('/')
            .next()
            .unwrap_or("ui");
        let ui_dir = project_path.join(ui_root_segment);
        if ui_dir.is_dir() {
            run_step(
                "bun install (ui)",
                Command::new("bun").arg("install").current_dir(&ui_dir),
            )
            .ok();
            run_step(
                "bun run build (ui)",
                Command::new("bun").args(["run", "build"]).current_dir(&ui_dir),
            )
            .ok();
        }
    }
    Ok(())
}

fn build_native(
    project_path: &Path,
    manifest: &AppManifest,
    target: &str,
    use_docker: bool,
) -> Result<()> {
    let rust_target = rust_target_for(target)?;
    if use_docker {
        docker_cargo_build(project_path, rust_target)?;
    } else {
        run_step(
            "cargo build --release",
            Command::new("cargo")
                .args(["build", "--release", "--target", rust_target])
                .current_dir(project_path),
        )?;
    }

    let crate_underscored = manifest.name.replace('-', "_");
    let so_src = project_path
        .join("target")
        .join(rust_target)
        .join("release")
        .join(format!("lib{}.so", crate_underscored));
    if !so_src.exists() {
        bail!(
            "expected cdylib output not found: {} — does Cargo.toml set crate-type = [\"cdylib\"]?",
            so_src.display()
        );
    }
    Ok(())
}

fn rust_target_for(target: &str) -> Result<&'static str> {
    Ok(match target {
        "amd64" => "x86_64-unknown-linux-gnu",
        "arm64" => "aarch64-unknown-linux-gnu",
        other => bail!("rust cross target undefined for --target {}", other),
    })
}

// =============================================================================
// Staging
// =============================================================================

fn stage_payload(
    project_path: &Path,
    stage: &Path,
    install_path: &str,
    manifest: &AppManifest,
    target: &str,
) -> Result<()> {
    let dst_root = stage.join(install_path);
    match manifest.app_type {
        AppType::Native => {
            let rust_target = rust_target_for(target)?;
            let crate_underscored = manifest.name.replace('-', "_");
            let so_src = project_path
                .join("target")
                .join(rust_target)
                .join("release")
                .join(format!("lib{}.so", crate_underscored));
            let so_dst = dst_root.join("app.so");
            fs::copy(&so_src, &so_dst)
                .with_context(|| format!("copy {} -> {}", so_src.display(), so_dst.display()))?;
            fs::set_permissions(&so_dst, fs::Permissions::from_mode(0o644))?;
        }
        AppType::Bun => {
            let entrypoint = manifest
                .entrypoint
                .clone()
                .unwrap_or_else(|| "dist/index.js".to_string());
            let src = project_path.join(&entrypoint);
            if !src.exists() {
                bail!(
                    "bun entrypoint not found: {} — run `bun build` first",
                    src.display()
                );
            }
            let entry_dir = Path::new(&entrypoint)
                .parent()
                .filter(|p| !p.as_os_str().is_empty());
            match entry_dir {
                None => {
                    let dst = dst_root.join(Path::new(&entrypoint).file_name().unwrap());
                    fs::copy(&src, &dst)?;
                }
                Some(dir) => {
                    let dst_dir = dst_root.join(dir);
                    fs::create_dir_all(&dst_dir)?;
                    copy_dir_recursive(&project_path.join(dir), &dst_dir)?;
                }
            }
        }
        AppType::Standalone => unreachable!("rejected earlier"),
    }
    Ok(())
}

fn stage_ui(
    project_path: &Path,
    stage: &Path,
    install_path: &str,
    manifest: &AppManifest,
) -> Result<()> {
    if !manifest.has_ui {
        return Ok(());
    }
    let ui_path = manifest
        .ui_path
        .as_deref()
        .ok_or_else(|| anyhow::anyhow!("has_ui=true but ui_path is missing"))?;
    let src = project_path.join(ui_path);
    if !src.is_dir() {
        bail!(
            "has_ui=true but UI bundle not found: {} — run the UI build first",
            src.display()
        );
    }
    let dst = stage.join(install_path).join(ui_path);
    fs::create_dir_all(&dst)?;
    copy_dir_recursive(&src, &dst)?;
    Ok(())
}

fn write_copyright(stage: &Path, pkg_name: &str) -> Result<()> {
    let body = format!(
        "{pkg_name}\n\
         This package is part of the Node Lightning OS project.\n\
         Source: https://github.com/econ-v1/node\n\
         Licensed under the project's primary license (see https://github.com/econ-v1/node/blob/main/LICENSE).\n"
    );
    fs::write(
        stage.join(format!("usr/share/doc/{}/copyright", pkg_name)),
        body,
    )?;
    Ok(())
}

fn render_control(
    project_path: &Path,
    stage: &Path,
    manifest: &AppManifest,
    pkg_version: &str,
    target: &str,
) -> Result<()> {
    let template_path = project_path.join("debian/control.template");
    if !template_path.is_file() {
        bail!(
            "control.template not found: {} — create one with placeholders {{{{name}}}} {{{{version}}}} {{{{arch}}}} {{{{maintainer}}}} {{{{description}}}}",
            template_path.display()
        );
    }
    let template = fs::read_to_string(&template_path)
        .with_context(|| format!("read {}", template_path.display()))?;
    let maintainer = std::env::var("MAINTAINER")
        .ok()
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "Node Project <maintainer@node.example>".to_string());
    let description = manifest.description.clone().unwrap_or_default();
    let rendered = template
        .replace("{{name}}", &manifest.name)
        .replace("{{version}}", pkg_version)
        .replace("{{arch}}", target)
        .replace("{{maintainer}}", &maintainer)
        .replace("{{description}}", &description);

    // Sanity: no un-substituted placeholders.
    let leftovers = regex::Regex::new(r"\{\{[A-Za-z_]+\}\}").unwrap();
    if let Some(m) = leftovers.find(&rendered) {
        bail!(
            "un-substituted placeholder remains in rendered DEBIAN/control: {}",
            m.as_str()
        );
    }
    fs::write(stage.join("DEBIAN/control"), rendered)?;
    Ok(())
}

fn copy_maintainer_scripts(project_path: &Path, stage: &Path, app_name: &str) -> Result<()> {
    for script in ["postinst", "prerm", "postrm"] {
        let src = project_path.join("debian").join(script);
        if !src.is_file() {
            continue;
        }
        let dst = stage.join("DEBIAN").join(script);
        let content =
            fs::read_to_string(&src).with_context(|| format!("read {}", src.display()))?;
        let rendered = content.replace("<name>", app_name);
        fs::write(&dst, rendered)?;
        fs::set_permissions(&dst, fs::Permissions::from_mode(0o755))?;
    }
    Ok(())
}

fn inject_port_registry_snippet(stage: &Path, manifest: &AppManifest) -> Result<()> {
    let preferred_port = manifest
        .tcp
        .as_ref()
        .and_then(|t| t.preferred_port)
        .map(|p| p.to_string());
    let Some(preferred_port) = preferred_port else {
        return Ok(());
    };

    let app_name = &manifest.name;
    let postinst_head = format!(
        "#!/bin/sh\n\
# AUTO-GENERATED port-registry snippet (470). Allocates the app's TCP port\n\
# via node-ctl before any other postinst logic runs. DO NOT EDIT.\n\
set -e\n\
APP_NAME=\"{app_name}\"\n\
PREFERRED_PORT=\"{preferred_port}\"\n\
if command -v node-ctl >/dev/null 2>&1; then\n\
    retries=10\n\
    while [ $retries -gt 0 ] && [ ! -S /run/node/control.sock ]; do\n\
        sleep 1; retries=$((retries - 1))\n\
    done\n\
    if [ -S /run/node/control.sock ]; then\n\
        node-ctl ports allocate \"$APP_NAME\" --preferred \"$PREFERRED_PORT\" >/dev/null \\\n\
            || echo \"warning: port allocation for $APP_NAME failed; app may be disabled\" >&2\n\
    else\n\
        echo \"warning: /run/node/control.sock not ready within retry budget; port allocation skipped for $APP_NAME\" >&2\n\
    fi\n\
fi\n",
        app_name = app_name,
        preferred_port = preferred_port,
    );
    let prerm_head = format!(
        "#!/bin/sh\n\
# AUTO-GENERATED port-registry snippet (470). Releases the app's TCP port via\n\
# node-ctl. DO NOT EDIT.\n\
set -e\n\
APP_NAME=\"{app_name}\"\n\
if command -v node-ctl >/dev/null 2>&1 && [ -S /run/node/control.sock ]; then\n\
    node-ctl ports release \"$APP_NAME\" >/dev/null || true\n\
fi\n",
        app_name = app_name,
    );

    prepend_or_create(&stage.join("DEBIAN/postinst"), &postinst_head)?;
    prepend_or_create(&stage.join("DEBIAN/prerm"), &prerm_head)?;
    println!(
        "» injected port-registry postinst/prerm snippet (preferred_port={})",
        preferred_port
    );
    Ok(())
}

fn prepend_or_create(path: &Path, head: &str) -> Result<()> {
    let combined = if path.is_file() {
        let existing = fs::read_to_string(path)?;
        let stripped: String = existing
            .lines()
            .filter(|line| {
                let t = line.trim();
                t != "#!/bin/sh" && t != "set -e"
            })
            .collect::<Vec<_>>()
            .join("\n");
        format!("{head}\n{stripped}\n")
    } else {
        head.to_string()
    };
    fs::write(path, combined)?;
    fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
    Ok(())
}

// =============================================================================
// Packing
// =============================================================================

fn pack_deb(stage: &Path, deb_output: &Path, use_docker: bool) -> Result<()> {
    if !use_docker && command_available("dpkg-deb") {
        return run_step(
            "dpkg-deb --build",
            Command::new("dpkg-deb")
                .args(["--root-owner-group", "--build"])
                .arg(stage)
                .arg(deb_output),
        );
    }

    if !command_available("docker") {
        bail!(
            "neither `dpkg-deb` nor `docker` is available; install one to pack the .deb \
             (on macOS: `colima start` or Docker Desktop; on Debian: `apt install dpkg-dev`)"
        );
    }

    let out_dir = deb_output
        .parent()
        .ok_or_else(|| anyhow::anyhow!("deb output has no parent"))?;
    fs::create_dir_all(out_dir)?;
    let deb_filename = deb_output
        .file_name()
        .ok_or_else(|| anyhow::anyhow!("deb output has no filename"))?
        .to_string_lossy()
        .to_string();
    run_step(
        "dpkg-deb (docker)",
        Command::new("docker")
            .args(["run", "--rm"])
            .args([
                "-v",
                &format!("{}:/stage:ro", stage.display()),
                "-v",
                &format!("{}:/out", out_dir.display()),
                "debian:bookworm-slim",
                "sh",
                "-c",
            ])
            .arg(format!(
                "apt-get update -qq >/dev/null && apt-get install -y -qq dpkg-dev >/dev/null && \
                 cp -a /stage /tmp/stage && \
                 dpkg-deb --root-owner-group --build /tmp/stage /out/{deb_filename}",
                deb_filename = deb_filename
            )),
    )
}

fn sanity_size_check(deb_output: &Path) -> Result<()> {
    let meta = fs::metadata(deb_output)?;
    let max = 100 * 1024 * 1024;
    if meta.len() > max {
        bail!(
            "built .deb exceeds 100 MB sanity cap: {} bytes ({})",
            meta.len(),
            deb_output.display()
        );
    }
    Ok(())
}

// =============================================================================
// Docker cross-compile
// =============================================================================

fn decide_use_docker(manifest: &AppManifest, target: &str, mode: DockerMode) -> Result<bool> {
    match mode {
        DockerMode::Disable => Ok(false),
        DockerMode::Force => {
            if !command_available("docker") {
                bail!(
                    "--docker passed but `docker` is not available on PATH; install Docker Desktop or colima"
                );
            }
            Ok(true)
        }
        DockerMode::Auto => {
            // Bun apps: host-only path is enough unless dpkg-deb is missing.
            // We let dpkg-deb pack auto-fallback to Docker even in host mode,
            // so we only switch the *build* to Docker for native.
            if !matches!(manifest.app_type, AppType::Native) {
                return Ok(false);
            }
            if target == "all" {
                return Ok(false);
            }
            let host_ready = host_native_toolchain_ready(target)?;
            if !host_ready {
                if !command_available("docker") {
                    bail!(
                        "host cross-toolchain for --target {target} is missing AND `docker` is not on PATH.\n\
                         Either install Docker (recommended for cross-platform dev), or install:\n\
                           - rustup target add {triple}\n\
                           - the `{linker}` linker via your distro package manager",
                        target = target,
                        triple = rust_target_for(target)?,
                        linker = host_linker_for(target)?,
                    );
                }
                return Ok(true);
            }
            Ok(false)
        }
    }
}

fn host_linker_for(target: &str) -> Result<&'static str> {
    Ok(match target {
        "amd64" => "x86_64-linux-gnu-gcc",
        "arm64" => "aarch64-linux-gnu-gcc",
        other => bail!("unknown linker for --target {}", other),
    })
}

fn host_native_toolchain_ready(target: &str) -> Result<bool> {
    let triple = rust_target_for(target)?;
    let linker = host_linker_for(target)?;

    // `rustup target list --installed` lists installed targets, one per line.
    let installed = Command::new("rustup")
        .args(["target", "list", "--installed"])
        .output();
    let target_installed = match installed {
        Ok(o) if o.status.success() => {
            String::from_utf8_lossy(&o.stdout).lines().any(|l| l.trim() == triple)
        }
        _ => false,
    };
    if !target_installed {
        return Ok(false);
    }

    // Cross-arch builds also need a matching linker on PATH. Same-arch host
    // builds use the system `cc`, which we trust.
    if same_arch_as_host(target) {
        return Ok(true);
    }
    Ok(command_available(linker))
}

fn same_arch_as_host(target: &str) -> bool {
    let host = std::env::consts::ARCH;
    matches!(
        (target, host),
        ("amd64", "x86_64") | ("arm64", "aarch64") | ("arm64", "arm64")
    )
}

fn docker_cargo_build(project_path: &Path, rust_target: &str) -> Result<()> {
    let dockerfile = find_repo_dockerfile(project_path);
    let (compiler_pkg, linker) = match rust_target {
        "aarch64-unknown-linux-gnu" => (
            "gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev-arm64-cross",
            "aarch64-linux-gnu-gcc",
        ),
        "x86_64-unknown-linux-gnu" => (
            "gcc-x86-64-linux-gnu g++-x86-64-linux-gnu libc6-dev-amd64-cross",
            "x86_64-linux-gnu-gcc",
        ),
        other => bail!("docker cross-compile undefined for target {}", other),
    };

    if !command_available("docker") {
        bail!("docker is not available on PATH");
    }

    let image_tag = format!(
        "node-app-cross:{}",
        rust_target.replace('-', "_")
    );

    // Build the image (cached layers make re-runs cheap).
    if let Some(df) = dockerfile.as_deref() {
        let parent = df
            .parent()
            .ok_or_else(|| anyhow::anyhow!("dockerfile has no parent"))?;
        let context = parent
            .parent()
            .and_then(|p| p.parent())
            .unwrap_or(project_path);
        run_step(
            "docker build (cross-compile image)",
            Command::new("docker")
                .args(["build", "-f"])
                .arg(df)
                .args([
                    "--build-arg",
                    &format!("TARGET_TRIPLE={}", rust_target),
                    "--build-arg",
                    &format!("COMPILER_PACKAGE={}", compiler_pkg),
                    "--build-arg",
                    &format!("LINKER={}", linker),
                    "-t",
                    &image_tag,
                ])
                .arg(context),
        )?;
    } else {
        // Build the image from an inline Dockerfile piped on stdin.
        let inline = inline_dockerfile(rust_target, compiler_pkg, linker);
        let mut child = Command::new("docker")
            .args(["build", "-f", "-", "-t", &image_tag, "."])
            .current_dir(project_path)
            .stdin(std::process::Stdio::piped())
            .spawn()
            .context("spawn docker build")?;
        use std::io::Write;
        child
            .stdin
            .as_mut()
            .unwrap()
            .write_all(inline.as_bytes())
            .context("write inline Dockerfile to docker build stdin")?;
        let status = child.wait().context("wait for docker build")?;
        if !status.success() {
            bail!("docker build (inline cross-compile image) exited with {}", status);
        }
    }

    // Run cargo build with the project bind-mounted. Target dir lives on the
    // host so the staging step can locate the .so without an extra docker cp.
    let cargo_cmd = format!(
        "cargo build --release --target {target} \
         && ls -lh target/{target}/release/*.so 2>/dev/null || true",
        target = rust_target
    );
    run_step(
        "cargo build --release (docker)",
        Command::new("docker")
            .args(["run", "--rm"])
            .args([
                "-v",
                &format!("{}:/app", project_path.display()),
                "-w",
                "/app",
                &image_tag,
                "sh",
                "-c",
            ])
            .arg(cargo_cmd),
    )
}

/// Locate `infra/docker/Dockerfile.app-cross-compile` by walking upwards from
/// the project path. Returns None when invoked outside a monorepo checkout, in
/// which case we fall back to an inline Dockerfile.
fn find_repo_dockerfile(start: &Path) -> Option<PathBuf> {
    let mut cur = Some(start);
    while let Some(dir) = cur {
        let candidate = dir.join("infra/docker/Dockerfile.app-cross-compile");
        if candidate.is_file() {
            return Some(candidate);
        }
        cur = dir.parent();
    }
    None
}

fn inline_dockerfile(target_triple: &str, compiler_pkg: &str, linker: &str) -> String {
    // Minimal image: rust:bookworm + cross GCC + cargo target. Kept in sync
    // with infra/docker/Dockerfile.app-cross-compile so users developing
    // outside the monorepo get the same behaviour.
    let env_var = match target_triple {
        "aarch64-unknown-linux-gnu" => "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER",
        "x86_64-unknown-linux-gnu" => "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER",
        _ => "CARGO_LINKER",
    };
    format!(
        "FROM rust:bookworm\n\
         ARG TARGET_TRIPLE={target_triple}\n\
         ARG COMPILER_PACKAGE=\"{compiler_pkg}\"\n\
         ARG LINKER={linker}\n\
         RUN apt-get update && apt-get install -y pkg-config libssl-dev libudev-dev ${{COMPILER_PACKAGE}} && rm -rf /var/lib/apt/lists/*\n\
         RUN rustup target add ${{TARGET_TRIPLE}}\n\
         ENV {env_var}=${{LINKER}}\n\
         WORKDIR /app\n",
        target_triple = target_triple,
        compiler_pkg = compiler_pkg,
        linker = linker,
        env_var = env_var,
    )
}

// =============================================================================
// Helpers
// =============================================================================

fn run_step(label: &str, cmd: &mut Command) -> Result<()> {
    println!("» {}", label);
    let status = cmd.status().with_context(|| format!("spawning {}", label))?;
    if !status.success() {
        bail!("{} failed (exit {})", label, status);
    }
    Ok(())
}

fn command_available(name: &str) -> bool {
    Command::new(name)
        .arg("--version")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
    for entry in walkdir::WalkDir::new(src) {
        let entry = entry?;
        let rel = entry.path().strip_prefix(src)?;
        let target = dst.join(rel);
        if entry.file_type().is_dir() {
            fs::create_dir_all(&target)?;
        } else if entry.file_type().is_file() {
            if let Some(parent) = target.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::copy(entry.path(), &target)?;
        }
    }
    Ok(())
}

fn chmod_recursive_a_plus_rx(root: &Path) -> Result<()> {
    for entry in walkdir::WalkDir::new(root) {
        let entry = entry?;
        let meta = entry.metadata()?;
        let mut mode = meta.permissions().mode();
        // a+rX: always set read, and exec where the user already has exec or it's a dir.
        mode |= 0o444;
        if meta.is_dir() || (mode & 0o100) != 0 {
            mode |= 0o111;
        }
        fs::set_permissions(entry.path(), fs::Permissions::from_mode(mode))?;
    }
    Ok(())
}

struct TempDirGuard {
    path: PathBuf,
}

impl Drop for TempDirGuard {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.path);
    }
}

fn tempdir_prefixed(prefix: &str) -> Result<TempDirGuard> {
    let base = std::env::temp_dir();
    for _ in 0..16 {
        let suffix: String = std::iter::repeat_with(fastrand_alnum).take(8).collect();
        let candidate = base.join(format!("{}{}", prefix, suffix));
        match fs::create_dir(&candidate) {
            Ok(()) => return Ok(TempDirGuard { path: candidate }),
            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
            Err(e) => return Err(e.into()),
        }
    }
    bail!("failed to create temp dir after 16 attempts")
}

fn fastrand_alnum() -> char {
    use std::time::SystemTime;
    let nanos = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .map(|d| d.subsec_nanos())
        .unwrap_or(0);
    let pid = std::process::id();
    // Tiny non-crypto mixer; we only need uniqueness within /tmp.
    let idx = ((nanos.wrapping_mul(2654435761)).wrapping_add(pid)) % 36;
    let alphabet = b"abcdefghijklmnopqrstuvwxyz0123456789";
    alphabet[idx as usize] as char
}
