//! `node-app dev` — hot-reload inner loop for builtin app developers
//! (plan 457 phase C MVP).
//!
//! What it does, in order:
//!
//! 1. Reads `manifest.json` at the project path. Detects `app_type`
//!    (cdylib vs Bun).
//! 2. Builds the project (`cargo build --release` for cdylib,
//!    `bun build --target=bun --outdir dist` for Bun).
//! 3. Copies the build artifact + manifest to `<dev_dir>/<name>/`.
//! 4. Calls `app.dev_load` over the daemon's IPC socket.
//! 5. Optionally watches the `src/` directory and re-runs steps 2-4 on
//!    every save (`notify` recommended_watcher with a 500ms debounce).
//!
//! # Dependency apps (`--dep`)
//!
//! One or more `--dep PATH` flags declare dependency app repos that must be
//! built and staged alongside the primary app:
//!
//! ```sh
//! node-app dev --daemon monorepo --monorepo-path ../node \
//!     --dep ../node-app-example-cdylib \
//!     --dep ../node-app-other
//! ```

use anyhow::{anyhow, bail, Context, Result};
#[cfg(unix)]
use libc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc, OnceLock};
use std::time::{Duration, Instant};

use crate::commands::infra::{self as infra_cmd, InfraContext};
use crate::tui::{
    self, DevSignals, InfraChannelEntry, InfraGraphData, InfraLogLine, InfraLogService,
    InfraServiceHealth, InfraViewState, LogSource, LogTx, ServiceStatus, TuiEvent,
};
use host::{DaemonHandle, InstanceProfile};

static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);

/// Quit flag set by the TUI's `q` key.  Stored once at TUI setup so that
/// long-running build subprocesses can poll it without needing to thread the
/// `DevSignals` reference through every call frame.
static QUIT_FLAG: OnceLock<Arc<AtomicBool>> = OnceLock::new();

/// Returns true when either a SIGINT/SIGTERM was received or the TUI quit key
/// was pressed.  Called from build helpers to cancel in-flight subprocesses.
pub(crate) fn is_cancelled() -> bool {
    SHUTDOWN_REQUESTED.load(Ordering::SeqCst)
        || QUIT_FLAG.get().is_some_and(|f| f.load(Ordering::SeqCst))
}

#[cfg(unix)]
extern "C" fn handle_shutdown_signal(_: libc::c_int) {
    SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
}

pub mod agent;
pub mod autodetect;
pub mod host;
pub mod platform;
pub mod sources_resolver;

use host::Mode;

const WATCH_DEBOUNCE: Duration = Duration::from_millis(500);
const IPC_TIMEOUT: Duration = Duration::from_secs(60);

pub struct DevArgs<'a> {
    pub project_path: &'a Path,
    pub daemon: Option<&'a str>,
    pub monorepo_path: Option<&'a Path>,
    pub socket_override: Option<&'a Path>,
    pub dev_dir_override: Option<&'a Path>,
    pub once: bool,
    pub dep_paths: Vec<PathBuf>,
    pub no_tui: bool,
    /// Extra config key=value pairs passed via --config flags.
    /// Merged with [config] from node-app.toml (CLI flags win on conflict).
    pub config: Vec<(String, String)>,
    /// Instance names to run (e.g. ["alice"] or ["alice", "bob"]).
    /// Empty = default to ["alice"].
    pub instances: Vec<String>,
    /// AI-agent friendly mode: after first sideload of every instance,
    /// run BIP39 onboarding (or BIP39 login when the seed file already
    /// exists) and persist the JWT + key material to
    /// `<dev_dir>/<instance>-agent-session.json`. Cross-seeds peer
    /// IP-pool entries when ≥2 instances are up. Monorepo-only in v1.
    pub agent: bool,
}

pub fn run(args: DevArgs<'_>) -> Result<()> {
    let project_path = args.project_path.canonicalize().with_context(|| {
        format!("canonicalize project path '{}'", args.project_path.display())
    })?;

    // ── Platform-repo mode dispatch ───────────────────────────────────────────
    // If we're sitting in the node monorepo (detected by the presence of
    // `infra/debian/platform-depends`), branch to a separate orchestrator that
    // boots the platform server here AND auto-resolves every node-app dep
    // declared in that file. App-repo mode (the rest of this function) is
    // detected by `manifest.json` at the project root.
    let has_platform_depends = project_path.join("infra/debian/platform-depends").is_file();
    let has_manifest = project_path.join("manifest.json").is_file();
    if has_platform_depends && has_manifest {
        bail!(
            "{} contains both manifest.json and infra/debian/platform-depends — \
             ambiguous. cd into the app or platform subdirectory, or pass --path.",
            project_path.display()
        );
    }
    if has_platform_depends {
        return platform::run(&project_path, &args);
    }

    let manifest = load_manifest(&project_path)?;

    // ── platform-runtime: packaging-only, not a dev-loopable app ──────────────
    // These projects (e.g. node-app-bun-runtime) repackage upstream binaries as
    // .debs and are not loaded by the node app loader. There is no source to
    // watch and nothing to sideload — exit cleanly so multi-instance invocations
    // don't fail when a platform-runtime project sits alongside real apps.
    if manifest.app_type.eq_ignore_ascii_case("platform-runtime") {
        eprintln!(
            "→ '{}' (v{}) has app_type=platform-runtime — packaging-only target, \
             not loaded by the node app loader. Use `make build` to produce the .deb.",
            manifest.name, manifest.version
        );
        return Ok(());
    }

    let app_kind = AppKindDetected::detect(&project_path, &manifest)?;

    // ── Standalone apps: bypass platform/sideload entirely ────────────────────
    // Standalone apps run as their own OS process — no sideloading into the
    // node platform. Build, spawn directly, watch src/, restart on change.
    if let AppKindDetected::Standalone(ref runtime) = app_kind {
        return run_standalone_dev(
            &project_path,
            &manifest,
            runtime,
            args.once,
            args.no_tui,
            &args.config,
        );
    }

    // ── Load dev config (node-app.toml [config] + --config flags) ─────────────
    let dev_config = load_dev_config(&project_path, &args.config);

    // ── Resolve infrastructure (non-fatal) ────────────────────────────────────
    let infra_ctx = InfraContext::try_resolve_from(
        Some(&project_path),
        args.monorepo_path,
    );

    // ── TUI setup ─────────────────────────────────────────────────────────────
    let use_tui = !args.no_tui && !args.once && tui::is_tty();
    let (log_tx_opt, signals_opt, tui_handle) = if use_tui {
        let (tx, rx, signals) = tui::setup();
        // Store quit flag globally so build subprocesses can poll it.
        let _ = QUIT_FLAG.set(signals.quit_requested.clone());
        let tui_instance_names: Vec<String> = if args.instances.is_empty() {
            vec!["alice".to_string()]
        } else {
            args.instances.clone()
        };
        let mut app_state = tui::state::AppState::new(
            manifest.name.clone(),
            manifest.version.clone(),
            tui_instance_names,
        );
        // Wire infra state into TUI if infra was found.
        if let Some(ref ctx) = infra_ctx {
            let rgs_url = ctx.rgs_url();
            app_state.infra = Some(InfraViewState::new(
                rgs_url,
                "mainnet".to_string(),
                vec![],
                false,
            ));
            // Start background infra threads.
            start_infra_threads(tx.clone(), ctx.clone());
        }
        let signals_clone = signals.clone();
        let handle = std::thread::spawn(move || {
            if let Err(e) = tui::run(rx, app_state, signals_clone) {
                eprintln!("TUI error: {e}");
            }
        });
        (Some(tx), Some(signals), Some(handle))
    } else {
        (None, None, None)
    };

    // RAII guard: on every exit path (normal return, early `?` errors, panics):
    //   1. signals mark_shutdown_complete so the TUI thread wakes up
    //   2. joins the TUI thread so terminal cleanup finishes before we return
    // Without the join the process may exit while the TUI thread is restoring
    // the terminal, leaving it frozen in raw / alternate-screen mode.
    struct TuiGuard {
        signals: Option<DevSignals>,
        handle: Option<std::thread::JoinHandle<()>>,
    }
    impl Drop for TuiGuard {
        fn drop(&mut self) {
            if let Some(sigs) = &self.signals {
                sigs.mark_shutdown_complete();
            }
            if let Some(h) = self.handle.take() {
                let _ = h.join();
            }
        }
    }
    let _tui_guard = TuiGuard {
        signals: signals_opt.clone(),
        handle: tui_handle,
    };

    // ── Resolve daemon-host mode ───────────────────────────────────────────────
    let mode = resolve_mode(args.daemon, args.monorepo_path, args.socket_override)?;

    // ── Parse instance profiles ───────────────────────────────────────────────
    let mut profiles: Vec<InstanceProfile> = if args.instances.is_empty() {
        vec![InstanceProfile::alice()]
    } else {
        args.instances
            .iter()
            .map(|n| InstanceProfile::from_name(n))
            .collect::<anyhow::Result<Vec<_>>>()?
    };

    // Multi-instance only supported in monorepo mode; other modes fall back to alice.
    if profiles.len() > 1 && !matches!(mode, Mode::Monorepo { .. }) {
        tui::sys_log(
            log_tx_opt.as_ref(),
            "⚠ --instances with multiple values only supported with --daemon monorepo; running alice only",
        );
        profiles.truncate(1);
    }

    tui::sys_log(
        log_tx_opt.as_ref(),
        format!(
            "→ daemon-host mode: {}  instances: {}",
            mode.label(),
            profiles.iter().map(|p| p.name.as_str()).collect::<Vec<_>>().join(", ")
        ),
    );

    // ── Create one host per instance ──────────────────────────────────────────
    let hosts: Vec<Box<dyn host::DaemonHost>> = profiles
        .iter()
        .map(|p| {
            host::for_mode(
                mode.clone(),
                p.clone(),
                args.socket_override,
                args.dev_dir_override,
                log_tx_opt.clone(),
            )
        })
        .collect();

    // ── Pre-boot dep staging (use first host with a computable dev-dir) ───────
    if !args.dep_paths.is_empty() {
        if let Some(early_dev_dir) = hosts[0].pre_start_dev_dir() {
            tui::sys_log(
                log_tx_opt.as_ref(),
                format!(
                    "→ staging {} dep(s) into dev-dir (pre-boot)…",
                    args.dep_paths.len()
                ),
            );
            for dep_path in &args.dep_paths {
                build_and_stage(dep_path, &early_dev_dir, log_tx_opt.as_ref())?;
            }
        }
    }

    // ── Bring up all daemon instances ─────────────────────────────────────────
    let mut handles: Vec<DaemonHandle> = Vec::with_capacity(hosts.len());
    for host_impl in &hosts {
        let handle = host_impl
            .ensure_running()
            .context("bring up daemon-host")?;
        handles.push(handle);
    }

    #[cfg(unix)]
    unsafe {
        libc::signal(libc::SIGINT, handle_shutdown_signal as *const () as libc::sighandler_t);
        libc::signal(libc::SIGTERM, handle_shutdown_signal as *const () as libc::sighandler_t);
    }

    for handle in &handles {
        tui::sys_log(
            log_tx_opt.as_ref(),
            format!(
                "→ {} app '{}' (v{})\n  project = {}\n  {}",
                app_kind.label(),
                manifest.name,
                manifest.version,
                project_path.display(),
                handle.banner,
            ),
        );
    }

    // ── Post-boot dep staging (non-monorepo modes, first handle only) ─────────
    if !args.dep_paths.is_empty() && hosts[0].pre_start_dev_dir().is_none() {
        let handle = &handles[0];
        tui::sys_log(
            log_tx_opt.as_ref(),
            format!(
                "→ staging {} dep(s) into dev-dir (post-boot)…",
                args.dep_paths.len()
            ),
        );
        for dep_path in &args.dep_paths {
            build_and_stage(dep_path, &handle.dev_dir, log_tx_opt.as_ref())?;
            let dep_manifest = load_manifest(dep_path)?;
            let dest = handle.dev_dir.join(&dep_manifest.name);
            ipc_call(
                &handle.socket_path,
                "app.dev_load",
                serde_json::json!({
                    "name": dep_manifest.name,
                    "path": dest.to_string_lossy(),
                }),
            )?;
            tui::sys_log(
                log_tx_opt.as_ref(),
                format!("✓ dep '{}' sideloaded", dep_manifest.name),
            );
        }
    }

    // ── First build + sideload to all instances ───────────────────────────────
    let build_result = build_app(
        &project_path,
        &manifest,
        &app_kind,
        log_tx_opt.as_ref(),
    );
    if let Err(e) = build_result {
        for host_impl in &hosts { host_impl.shutdown(); }
        return Err(e);
    }
    for handle in &handles {
        let sl_result = sideload_to_handle(
            &project_path,
            &manifest,
            &app_kind,
            handle,
            log_tx_opt.as_ref(),
            0,
            &dev_config,
        );
        if let Err(e) = sl_result {
            for host_impl in &hosts { host_impl.shutdown(); }
            return Err(e);
        }
    }

    // Agent mode bootstrap: onboard / login each instance and persist the
    // JWT + key material so an AI agent can drive authed endpoints
    // without re-implementing the BIP39 handshake. Runs after every
    // instance has been sideloaded so the app's capabilities are
    // registered before the agent issues its first authed request.
    // Best-effort: failures are logged per-instance and never crash the
    // dev loop.
    if args.agent {
        agent::run_agent_setup(&handles, log_tx_opt.as_ref());
    }

    if args.once {
        tui::sys_log(
            log_tx_opt.as_ref(),
            "✓ --once mode: built + sideloaded; exiting without watcher.",
        );
        for host_impl in &hosts { host_impl.shutdown(); }
        return Ok(());
    }

    // Start log streaming (docker / deb modes). No-op for monorepo since
    // daemon stdio is already piped in ensure_running().
    for (host_impl, _handle) in hosts.iter().zip(handles.iter()) {
        host_impl.tail_logs(&manifest.name);
    }

    tui::sys_log(
        log_tx_opt.as_ref(),
        format!(
            "✓ Initial sideload complete. Watching {}/src/ for changes (Ctrl-C to stop)…",
            project_path.display()
        ),
    );

    let watch_result = watch_loop(
        &project_path,
        &manifest,
        &app_kind,
        &handles,
        log_tx_opt.as_ref(),
        signals_opt.as_ref(),
        &hosts,
        &dev_config,
    );
    tui::sys_log(log_tx_opt.as_ref(), "→ shutting down…");
    for host_impl in &hosts { host_impl.shutdown(); }
    // Drop the log channel first so any pending shutdown log lines are
    // flushed into the TUI before the guard calls mark_shutdown_complete().
    drop(log_tx_opt);
    // _tui_guard drops here: signals mark_shutdown_complete, then joins the
    // TUI thread — blocking until it has fully restored the terminal.
    watch_result
}

// ── Standalone dev loop ───────────────────────────────────────────────────────

/// Development loop for standalone apps (app_type: standalone).
///
/// Unlike bun/cdylib apps, standalone apps are NOT sideloaded into the node
/// platform. Instead they run as their own OS process. This loop:
///   1. Builds the binary (cargo build / bun install)
///   2. Spawns it as a child process
///   3. Watches src/ for changes
///   4. On change: kills the process, rebuilds, restarts
fn run_standalone_dev(
    project_path: &Path,
    manifest: &Manifest,
    runtime: &StandaloneRuntime,
    once: bool,
    no_tui: bool,
    config: &[(String, String)],
) -> Result<()> {
    let use_tui = !no_tui && !once && tui::is_tty();
    let (log_tx_opt, signals_opt, tui_handle) = if use_tui {
        let (tx, rx, signals) = tui::setup();
        let app_state = tui::state::AppState::new(
            manifest.name.clone(),
            manifest.version.clone(),
            vec![],
        );
        let signals_clone = signals.clone();
        let handle = std::thread::spawn(move || {
            if let Err(e) = tui::run(rx, app_state, signals_clone) {
                eprintln!("TUI error: {e}");
            }
        });
        (Some(tx), Some(signals), Some(handle))
    } else {
        (None, None, None)
    };

    struct TuiGuard {
        signals: Option<DevSignals>,
        handle: Option<std::thread::JoinHandle<()>>,
    }
    impl Drop for TuiGuard {
        fn drop(&mut self) {
            if let Some(sigs) = &self.signals {
                sigs.mark_shutdown_complete();
            }
            if let Some(h) = self.handle.take() {
                let _ = h.join();
            }
        }
    }
    let _tui_guard = TuiGuard {
        signals: signals_opt.clone(),
        handle: tui_handle,
    };

    #[cfg(unix)]
    unsafe {
        libc::signal(libc::SIGINT, handle_shutdown_signal as *const () as libc::sighandler_t);
        libc::signal(libc::SIGTERM, handle_shutdown_signal as *const () as libc::sighandler_t);
    }

    tui::sys_log(
        log_tx_opt.as_ref(),
        format!(
            "→ standalone ({}) app '{}' (v{})\n  project = {}",
            match runtime {
                StandaloneRuntime::Rust => "Rust",
                StandaloneRuntime::Bun => "Bun",
            },
            manifest.name,
            manifest.version,
            project_path.display(),
        ),
    );

    // Initial build
    standalone_build(project_path, runtime, log_tx_opt.as_ref())?;

    // Spawn the process
    let mut child = standalone_spawn(project_path, manifest, runtime, config)?;
    tui::sys_log(
        log_tx_opt.as_ref(),
        format!("✓ '{}' started (pid {})", manifest.name, child.id()),
    );

    if once {
        let _ = child.wait();
        return Ok(());
    }

    tui::sys_log(
        log_tx_opt.as_ref(),
        format!(
            "✓ Watching {}/src/ for changes (Ctrl-C to stop)…",
            project_path.display()
        ),
    );

    // File watcher
    let (watcher_tx, watcher_rx) = mpsc::channel();
    let mut watcher =
        notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
            if let Ok(event) = res {
                use notify::EventKind::*;
                if matches!(
                    event.kind,
                    Create(_) | Modify(_) | Remove(_)
                ) {
                    let _ = watcher_tx.send(());
                }
            }
        })
        .context("create file watcher")?;

    use notify::Watcher;
    let src_dir = project_path.join("src");
    if src_dir.is_dir() {
        watcher
            .watch(&src_dir, notify::RecursiveMode::Recursive)
            .ok();
    }

    let mut last_event = Instant::now();

    loop {
        if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
            break;
        }
        let mut force_rebuild = false;
        if let Some(sigs) = &signals_opt {
            if sigs.should_quit() {
                break;
            }
            // `r` (restart) and `b` (build) both map to the same action for a
            // standalone app: kill the child, rebuild from source, respawn.
            // There is no separate daemon to "just restart" — the only artifact
            // is the app process itself. Standalone mode has no apps/ui/system
            // separation, so any scope chosen from the popup is collapsed to
            // a single rebuild.
            let restart = sigs.take_restart();
            let scope = sigs.take_build_scope();
            if restart || scope.is_some() {
                force_rebuild = true;
                standalone_banner(
                    log_tx_opt.as_ref(),
                    "⟳ MANUAL REBUILD TRIGGERED",
                );
            }
        }

        // Drain watcher events with debounce
        let got_event = watcher_rx
            .recv_timeout(Duration::from_millis(200))
            .is_ok();

        if got_event {
            last_event = Instant::now();
        } else if force_rebuild
            || (last_event.elapsed() > WATCH_DEBOUNCE
                && last_event.elapsed() < Duration::from_secs(1))
        {
            // Debounce fired (or manual trigger): rebuild and restart
            let rebuild_started = Instant::now();
            if !force_rebuild {
                tui::sys_log(log_tx_opt.as_ref(), "→ change detected, rebuilding…");
            }

            let _ = child.kill();
            let _ = child.wait();

            tui::update_status(
                log_tx_opt.as_ref(),
                LogSource::Build,
                ServiceStatus::Building,
                None,
            );

            match standalone_build(project_path, runtime, log_tx_opt.as_ref()) {
                Ok(()) => {
                    match standalone_spawn(project_path, manifest, runtime, config) {
                        Ok(new_child) => {
                            child = new_child;
                            if force_rebuild {
                                standalone_banner(
                                    log_tx_opt.as_ref(),
                                    format!(
                                        "✓ REBUILD COMPLETE ({:.1}s) — pid {}",
                                        rebuild_started.elapsed().as_secs_f32(),
                                        child.id()
                                    ),
                                );
                            } else {
                                tui::sys_log(
                                    log_tx_opt.as_ref(),
                                    format!("✓ restarted '{}' (pid {})", manifest.name, child.id()),
                                );
                            }
                            tui::update_status(
                                log_tx_opt.as_ref(),
                                LogSource::Build,
                                ServiceStatus::Watching,
                                None,
                            );
                        }
                        Err(e) => {
                            tui::sys_log(
                                log_tx_opt.as_ref(),
                                format!("✗ spawn failed: {}", e),
                            );
                        }
                    }
                }
                Err(e) => {
                    tui::sys_log(
                        log_tx_opt.as_ref(),
                        format!("✗ build failed: {}", e),
                    );
                    tui::update_status(
                        log_tx_opt.as_ref(),
                        LogSource::Build,
                        ServiceStatus::Failed("build error".into()),
                        None,
                    );
                }
            }

            last_event = Instant::now() - WATCH_DEBOUNCE - Duration::from_secs(1);
        }
    }

    let _ = child.kill();
    let _ = child.wait();
    tui::sys_log(log_tx_opt.as_ref(), "→ shutting down standalone process…");
    drop(log_tx_opt);
    Ok(())
}

/// Bracketed banner for standalone-mode manual rebuild/restart so the user
/// can spot the event in the log stream.
fn standalone_banner(log_tx: Option<&LogTx>, label: impl Into<String>) {
    const RULE: &str =
        "════════════════════════════════════════════════════════════════════";
    tui::sys_log(log_tx, "");
    tui::sys_log(log_tx, RULE);
    tui::sys_log(log_tx, format!("  {}", label.into()));
    tui::sys_log(log_tx, RULE);
}

fn standalone_build(
    project_path: &Path,
    runtime: &StandaloneRuntime,
    log_tx: Option<&LogTx>,
) -> Result<()> {
    tui::update_status(log_tx, LogSource::Build, ServiceStatus::Building, None);
    let result = match runtime {
        StandaloneRuntime::Rust => run_in_with_sink(
            project_path,
            "cargo",
            &["build"],
            "cargo build",
            log_tx,
            LogSource::Build,
        ),
        StandaloneRuntime::Bun => run_in_with_sink(
            project_path,
            "bun",
            &["install"],
            "bun install",
            log_tx,
            LogSource::Build,
        ),
    };
    if result.is_ok() {
        tui::update_status(log_tx, LogSource::Build, ServiceStatus::Watching, None);
    }
    result
}

fn standalone_spawn(
    project_path: &Path,
    manifest: &Manifest,
    runtime: &StandaloneRuntime,
    config: &[(String, String)],
) -> Result<std::process::Child> {
    let mut cmd = match runtime {
        StandaloneRuntime::Rust => {
            // Use the debug binary from target/debug/<binary-name>.
            let bin_name = format!("node-app-{}", manifest.name);
            let bin_path = project_path.join("target").join("debug").join(&bin_name);
            let mut c = Command::new(&bin_path);
            c.current_dir(project_path);
            c
        }
        StandaloneRuntime::Bun => {
            let mut c = Command::new("bun");
            c.args(["run", "src/index.ts"]);
            c.current_dir(project_path);
            c
        }
    };

    // Pass --config KEY=VALUE as env vars (KEY=VALUE → NODE_APP_<KEY>=VALUE)
    let mut explicit_http_port = false;
    for (k, v) in config {
        if k == "NODE_APP_HTTP_PORT" {
            explicit_http_port = true;
        }
        cmd.env(k, v);
    }

    // Feature 470 (T046): honor manifest.tcp.preferred_port when --config
    // NODE_APP_HTTP_PORT was NOT explicitly set. Try to bind the preferred
    // port; if EADDRINUSE, fall back to a kernel-assigned ephemeral and log
    // a warning so the author isn't surprised by a port mismatch.
    if !explicit_http_port {
        if let Some(preferred) = manifest.tcp.as_ref().and_then(|t| t.preferred_port) {
            let port = resolve_dev_port(preferred, &manifest.name);
            cmd.env("NODE_APP_HTTP_PORT", port.to_string());
        }
    }

    cmd.spawn().with_context(|| {
        format!(
            "spawn standalone '{}' — ensure the binary is built",
            manifest.name
        )
    })
}

/// Probe whether `preferred` is bindable on loopback. If yes, return it. If
/// it's taken (EADDRINUSE), ask the kernel for an ephemeral free port via
/// `bind(0)` and return that. Always emits a stderr warning when falling
/// back so the dev author sees the port mismatch.
///
/// There's a tiny race window between the probe-drop and the app binding,
/// but for an interactive dev loop this is acceptable — the app will fail
/// to start in the racy case and the author can retry.
fn resolve_dev_port(preferred: u16, app_name: &str) -> u16 {
    use std::net::{Ipv4Addr, SocketAddrV4, TcpListener};
    let preferred_addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, preferred);
    match TcpListener::bind(preferred_addr) {
        Ok(l) => {
            drop(l);
            preferred
        }
        Err(_) => {
            // Ask the kernel for any free port.
            match TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) {
                Ok(l) => {
                    let actual = l.local_addr().map(|a| a.port()).unwrap_or(preferred);
                    drop(l);
                    eprintln!(
                        "warning: tcp.preferred_port {preferred} taken on this host; \
                         dev server for '{app_name}' bound to {actual} instead. \
                         To override, pass --config NODE_APP_HTTP_PORT=<N>."
                    );
                    actual
                }
                Err(_) => {
                    // Failed even to get an ephemeral; surrender and let
                    // the app try preferred (will likely fail at bind time).
                    eprintln!(
                        "warning: could not reserve any TCP port; \
                         dev server for '{app_name}' will try preferred ({preferred}) — \
                         expect bind failure."
                    );
                    preferred
                }
            }
        }
    }
}

fn build_and_stage(dep_path: &Path, dev_dir: &Path, log_tx: Option<&LogTx>) -> Result<()> {
    let dep_path = dep_path
        .canonicalize()
        .with_context(|| format!("canonicalize dep path '{}'", dep_path.display()))?;
    let dep_manifest = load_manifest(&dep_path)?;

    // platform-runtime deps (e.g. bun-runtime) are packaging-only targets that
    // produce .debs of upstream binaries — they are not loaded by the node app
    // loader and have nothing to build or stage into a dev-dir. Skip silently.
    if dep_manifest.app_type.eq_ignore_ascii_case("platform-runtime") {
        tui::sys_log(
            log_tx,
            format!(
                "→ dep '{}' (platform-runtime): skipped — packaging-only, not staged",
                dep_manifest.name
            ),
        );
        return Ok(());
    }

    let dep_kind = AppKindDetected::detect(&dep_path, &dep_manifest)?;

    tui::sys_log(
        log_tx,
        format!(
            "→ dep '{}' ({}): building…",
            dep_manifest.name,
            dep_kind.label()
        ),
    );
    dep_kind.build_with_manifest(&dep_path, Some(&dep_manifest), log_tx)?;

    let dest = dev_dir.join(&dep_manifest.name);
    std::fs::create_dir_all(&dest)
        .with_context(|| format!("mkdir -p {}", dest.display()))?;
    dep_kind.copy_artifacts(&dep_path, &dep_manifest, &dest)?;

    tui::sys_log(log_tx, format!("✓ dep '{}' staged", dep_manifest.name));
    Ok(())
}

fn resolve_mode(
    daemon: Option<&str>,
    monorepo_path: Option<&Path>,
    socket_override: Option<&Path>,
) -> Result<Mode> {
    if let Some(d) = daemon {
        return match d {
            "deb" => Ok(Mode::Deb),
            "docker" => Ok(Mode::Docker),
            "clone" => Ok(Mode::Clone),
            "monorepo" => {
                let path = monorepo_path.ok_or_else(|| {
                    anyhow!("--daemon monorepo requires --monorepo-path PATH")
                })?;
                Ok(Mode::Monorepo {
                    path: path.to_path_buf(),
                })
            }
            // clap value_parser restricts to the four named modes above;
            // this arm is unreachable in normal use but kept as a safety net.
            other => Err(anyhow!(
                "unknown --daemon value '{}'. Allowed: deb | docker | clone | monorepo",
                other
            )),
        };
    }
    if let Some(p) = socket_override {
        return Ok(Mode::Remote {
            socket_path: p.to_path_buf(),
        });
    }
    autodetect::detect()
}

/// Merge `[config]` from `node-app.toml` (if present) with CLI `--config`
/// overrides. CLI flags win on conflict.
fn load_dev_config(
    project_path: &Path,
    cli_overrides: &[(String, String)],
) -> std::collections::HashMap<String, String> {
    let mut map = std::collections::HashMap::new();
    // Best-effort TOML parse — missing file or parse errors are silently ignored.
    if let Ok(text) = std::fs::read_to_string(project_path.join("node-app.toml")) {
        if let Ok(doc) = text.parse::<toml::Table>() {
            if let Some(toml::Value::Table(cfg)) = doc.get("config") {
                for (k, v) in cfg {
                    if let toml::Value::String(s) = v {
                        // Resolve relative paths against the project directory so
                        // values like "firmware/build" work regardless of where
                        // the daemon's working directory is.
                        let resolved = resolve_config_path(s, project_path);
                        map.insert(k.clone(), resolved);
                    }
                }
            }
        }
    }
    for (k, v) in cli_overrides {
        map.insert(k.clone(), v.clone());
    }
    map
}

/// Resolve a config value that looks like a relative path against `base`.
/// Values that are absolute, empty, or don't contain a path separator are
/// returned unchanged (e.g. port numbers, device names like "/dev/serial0").
fn resolve_config_path(value: &str, base: &Path) -> String {
    let p = std::path::Path::new(value);
    // Only resolve if clearly a relative filesystem path
    if p.is_relative() && (value.starts_with('.') || value.contains('/')) {
        if let Ok(abs) = base.join(p).canonicalize() {
            return abs.to_string_lossy().into_owned();
        }
        // Path doesn't exist yet (e.g. build dir before first build) — still
        // make it absolute so the app can check existence later.
        return base.join(p).to_string_lossy().into_owned();
    }
    value.to_string()
}

/// Build the app (compile step only). No artifact copying or IPC sideload.
fn build_app(
    project_path: &Path,
    manifest: &Manifest,
    app_kind: &AppKindDetected,
    log_tx: Option<&LogTx>,
) -> Result<()> {
    tui::sys_log(log_tx, format!("→ building ({})…", app_kind.build_label()));
    tui::update_status(
        log_tx,
        LogSource::Build,
        ServiceStatus::Building,
        Some(app_kind.build_label().to_string()),
    );
    tui::update_status(log_tx, LogSource::App, ServiceStatus::Changed, None);
    app_kind.build_with_manifest(project_path, Some(manifest), log_tx)?;
    tui::update_status(log_tx, LogSource::Build, ServiceStatus::Ready, None);
    Ok(())
}

/// Copy build artifacts into one daemon's dev-dir and IPC sideload.
fn sideload_to_handle(
    project_path: &Path,
    manifest: &Manifest,
    app_kind: &AppKindDetected,
    handle: &DaemonHandle,
    log_tx: Option<&LogTx>,
    reload_count: usize,
    dev_config: &std::collections::HashMap<String, String>,
) -> Result<()> {
    let started = Instant::now();
    let dest = handle.dev_dir.join(&manifest.name);
    std::fs::create_dir_all(&dest)
        .with_context(|| format!("mkdir -p {}", dest.display()))?;
    app_kind.copy_artifacts(project_path, manifest, &dest)?;

    tui::sys_log(log_tx, "→ sideloading via app.dev_load…");
    let mut params = serde_json::json!({
        "name": manifest.name,
        "path": dest.to_string_lossy(),
    });
    if !dev_config.is_empty() {
        params["config"] = serde_json::to_value(dev_config).unwrap_or_default();
    }
    let result = ipc_call(&handle.socket_path, "app.dev_load", params)?;

    let elapsed_ms = started.elapsed().as_millis();
    let label = if handle.name.is_empty() {
        manifest.name.clone()
    } else {
        format!("{} [{}]", manifest.name, handle.name)
    };
    tui::sys_log(
        log_tx,
        format!("✓ {} reloaded ({}ms) — daemon: {}", label, elapsed_ms, result),
    );
    tui::update_status(
        log_tx,
        LogSource::App,
        ServiceStatus::Loaded { reloads: reload_count + 1 },
        Some(format!("last reload: {elapsed_ms}ms")),
    );
    Ok(())
}

/// Build UI bundle only (no Rust compile). For UI-only file changes.
fn build_ui_only(
    project_path: &Path,
    manifest: &Manifest,
    log_tx: Option<&LogTx>,
) -> Result<PathBuf> {
    tui::sys_log(log_tx, "→ building UI…");
    tui::update_status(log_tx, LogSource::Build, ServiceStatus::Building, Some("bun build (ui)".into()));
    let ui_dir = manifest
        .ui_path
        .as_deref()
        .and_then(|p| p.strip_suffix("/dist"))
        .map(|p| project_path.join(p))
        .unwrap_or_else(|| project_path.join("ui"));
    if ui_dir.join("package.json").exists() {
        let pkg_mgr = if ui_dir.join("package-lock.json").exists() { "npm" } else { "bun" };
        run_in_with_sink(&ui_dir, pkg_mgr, &["run", "build"], "ui build", log_tx, LogSource::Build)?;
    }
    tui::update_status(log_tx, LogSource::Build, ServiceStatus::Ready, None);
    Ok(ui_dir)
}

/// Copy the rebuilt UI dist + manifest into one daemon's dev-dir and IPC sideload.
#[allow(clippy::too_many_arguments)]
fn sideload_ui_to_handle(
    project_path: &Path,
    manifest: &Manifest,
    ui_dir: &Path,
    handle: &DaemonHandle,
    log_tx: Option<&LogTx>,
    reload_count: usize,
    dev_config: &std::collections::HashMap<String, String>,
    started: Instant,
) -> Result<()> {
    let dest = handle.dev_dir.join(&manifest.name);
    std::fs::create_dir_all(&dest)
        .with_context(|| format!("mkdir -p {}", dest.display()))?;

    let manifest_src = project_path.join("manifest.json");
    std::fs::copy(&manifest_src, dest.join("manifest.json"))
        .with_context(|| format!("copy manifest → {}", dest.display()))?;

    let ui_dist_src = ui_dir.join("dist");
    if ui_dist_src.exists() {
        let ui_path_str = manifest.ui_path.as_deref().unwrap_or("dist");
        let ui_dst = dest.join(ui_path_str);
        if ui_dst.exists() { std::fs::remove_dir_all(&ui_dst).ok(); }
        copy_dir_recursive(&ui_dist_src, &ui_dst)
            .with_context(|| format!("copy ui dist → {}", ui_dst.display()))?;
    }

    tui::sys_log(log_tx, "→ sideloading via app.dev_load…");
    let mut params = serde_json::json!({ "name": manifest.name, "path": dest.to_string_lossy() });
    if !dev_config.is_empty() {
        params["config"] = serde_json::to_value(dev_config).unwrap_or_default();
    }
    let result = ipc_call(&handle.socket_path, "app.dev_load", params)?;

    let elapsed_ms = started.elapsed().as_millis();
    tui::sys_log(log_tx, format!("✓ UI reloaded ({}ms) — daemon: {}", elapsed_ms, result));
    tui::update_status(log_tx, LogSource::App, ServiceStatus::Loaded { reloads: reload_count + 1 },
        Some(format!("last UI reload: {elapsed_ms}ms")));
    Ok(())
}

#[allow(clippy::too_many_arguments)]
fn watch_loop(
    project_path: &Path,
    manifest: &Manifest,
    app_kind: &AppKindDetected,
    handles: &[DaemonHandle],
    log_tx: Option<&LogTx>,
    signals: Option<&DevSignals>,
    hosts: &[Box<dyn host::DaemonHost>],
    dev_config: &std::collections::HashMap<String, String>,
) -> Result<()> {
    use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher as _};

    let (tx, rx) = mpsc::channel::<notify::Result<Event>>();
    let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| {
        let _ = tx.send(res);
    })
    .context("create notify watcher")?;

    let src_dir = project_path.join("src");
    if src_dir.exists() {
        watcher
            .watch(&src_dir, RecursiveMode::Recursive)
            .with_context(|| format!("watch {}", src_dir.display()))?;
    }
    if manifest.has_ui {
        let ui_dir = manifest
            .ui_path
            .as_deref()
            .and_then(|p| p.strip_suffix("/dist"))
            .map(|p| project_path.join(p))
            .unwrap_or_else(|| project_path.join("ui"));
        let ui_src = ui_dir.join("src");
        let watch_target = if ui_src.exists() { ui_src } else { ui_dir };
        if watch_target.exists() {
            let _ = watcher.watch(&watch_target, RecursiveMode::Recursive);
        }
    }
    let manifest_path = project_path.join("manifest.json");
    if manifest_path.exists() {
        let _ = watcher.watch(&manifest_path, RecursiveMode::NonRecursive);
    }

    tui::update_status(
        log_tx,
        LogSource::App,
        ServiceStatus::Watching,
        Some("watching src/ for changes".into()),
    );

    // Compute the ui/src path so we can tell UI-only changes from Rust changes.
    let ui_src_dir: Option<PathBuf> = if manifest.has_ui {
        let ui_dir = manifest
            .ui_path
            .as_deref()
            .and_then(|p| p.strip_suffix("/dist"))
            .map(|p| project_path.join(p))
            .unwrap_or_else(|| project_path.join("ui"));
        let candidate = ui_dir.join("src");
        if candidate.exists() { Some(candidate) } else { Some(ui_dir) }
    } else {
        None
    };

    let mut last_event = Instant::now();
    let mut pending = false;
    let mut pending_ui_only = false; // true when all pending changes are in ui/src/
    let mut reload_count = 0usize;

    loop {
        if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
            return Ok(());
        }
        if let Some(sigs) = signals {
            if sigs.should_quit() {
                return Ok(());
            }
            if sigs.take_restart() {
                tui::sys_log(log_tx, "→ restart requested…");
                for host_impl in hosts {
                    if let Err(e) = host_impl.restart() {
                        tui::sys_log(log_tx, format!("✗ restart failed: {:#}", e));
                    }
                }
            }
            if let Some(_scope) = sigs.take_build_scope() {
                // App-developer mode has just one artifact (the app itself), so
                // every popup scope collapses to a single rebuild + sideload.
                tui::sys_log(log_tx, "→ manual build triggered…");
                pending = false;
                pending_ui_only = false;
                match build_app(project_path, manifest, app_kind, log_tx) {
                    Ok(()) => {
                        for handle in handles {
                            match sideload_to_handle(project_path, manifest, app_kind, handle, log_tx, reload_count, dev_config) {
                                Ok(()) => {}
                                Err(e) => tui::sys_log(log_tx, format!("✗ sideload failed: {:#}", e)),
                            }
                        }
                        reload_count += 1;
                    }
                    Err(e) => tui::sys_log(log_tx, format!("✗ build failed: {:#}", e)),
                }
            }
        }

        match rx.recv_timeout(WATCH_DEBOUNCE) {
            Ok(Ok(event)) => {
                if !is_rebuild_trigger(&event) {
                    continue;
                }
                // Determine if this change is UI-only.
                let is_ui_change = ui_src_dir.as_ref().is_some_and(|ui| {
                    event.paths.iter().all(|p| p.starts_with(ui))
                });
                last_event = Instant::now();
                if pending {
                    // If any prior or new change is non-UI, escalate to full rebuild.
                    if !is_ui_change { pending_ui_only = false; }
                } else {
                    pending = true;
                    pending_ui_only = is_ui_change;
                }
                tui::update_status(
                    log_tx,
                    LogSource::App,
                    ServiceStatus::Changed,
                    event.paths.first().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned()),
                );
            }
            Ok(Err(e)) => tui::sys_log(log_tx, format!("watch error: {}", e)),
            Err(mpsc::RecvTimeoutError::Timeout) => {
                if pending && last_event.elapsed() >= WATCH_DEBOUNCE {
                    let ui_only = pending_ui_only && manifest.has_ui;
                    pending = false;
                    pending_ui_only = false;
                    if ui_only {
                        tui::sys_log(log_tx, "→ UI change detected, rebuilding frontend…");
                        let started = Instant::now();
                        match build_ui_only(project_path, manifest, log_tx) {
                            Ok(ui_dir) => {
                                for handle in handles {
                                    match sideload_ui_to_handle(project_path, manifest, &ui_dir, handle, log_tx, reload_count, dev_config, started) {
                                        Ok(()) => {}
                                        Err(e) => tui::sys_log(log_tx, format!("✗ UI sideload failed: {:#}", e)),
                                    }
                                }
                                reload_count += 1;
                            }
                            Err(e) => tui::sys_log(log_tx, format!("✗ UI build failed: {:#}", e)),
                        }
                    } else {
                        tui::sys_log(log_tx, "→ change detected, rebuilding…");
                        match build_app(project_path, manifest, app_kind, log_tx) {
                            Ok(()) => {
                                for handle in handles {
                                    match sideload_to_handle(project_path, manifest, app_kind, handle, log_tx, reload_count, dev_config) {
                                        Ok(()) => {}
                                        Err(e) => tui::sys_log(log_tx, format!("✗ sideload failed: {:#}", e)),
                                    }
                                }
                                reload_count += 1;
                            }
                            Err(e) => tui::sys_log(log_tx, format!("✗ cycle failed: {:#}", e)),
                        }
                    }
                }
            }
            Err(mpsc::RecvTimeoutError::Disconnected) => {
                bail!("watcher channel closed unexpectedly");
            }
        }
    }
}

fn is_rebuild_trigger(event: &notify::Event) -> bool {
    use notify::EventKind;
    matches!(
        event.kind,
        EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
    )
}

// ── Manifest + app-kind detection ─────────────────────────────────────────────

#[derive(Debug, Deserialize)]
struct Manifest {
    name: String,
    version: String,
    app_type: String,
    #[serde(default)]
    has_ui: bool,
    #[serde(default)]
    ui_path: Option<String>,
    /// Optional TCP-port preferences (feature 470). Honored by `node-app dev`
    /// per Clarification Q6: bind preferred when free, fall back to ephemeral
    /// with a warning when taken, explicit `--config NODE_APP_HTTP_PORT=N`
    /// always wins.
    #[serde(default)]
    tcp: Option<TcpManifestSection>,
}

#[derive(Debug, Deserialize, Default)]
struct TcpManifestSection {
    #[serde(default)]
    preferred_port: Option<u16>,
    #[allow(dead_code)] // dev mode only needs preferred_port; direct_bind is install-time
    #[serde(default)]
    direct_bind: Option<bool>,
}

fn load_manifest(project_path: &Path) -> Result<Manifest> {
    let path = project_path.join("manifest.json");
    let content = std::fs::read_to_string(&path)
        .with_context(|| format!("read {}", path.display()))?;
    let manifest: Manifest =
        serde_json::from_str(&content).with_context(|| format!("parse {}", path.display()))?;
    if manifest.name.is_empty() {
        bail!("manifest.name is empty");
    }
    if manifest.version.is_empty() {
        bail!("manifest.version is empty");
    }
    Ok(manifest)
}

#[derive(Debug, Clone, Copy)]
enum StandaloneRuntime {
    Rust,
    Bun,
}

enum AppKindDetected {
    Cdylib,
    Bun,
    Standalone(StandaloneRuntime),
}

impl AppKindDetected {
    fn detect(project_path: &Path, manifest: &Manifest) -> Result<Self> {
        match manifest.app_type.to_ascii_lowercase().as_str() {
            "native" | "cdylib" => Ok(AppKindDetected::Cdylib),
            "bun" => Ok(AppKindDetected::Bun),
            "standalone" => {
                // Detect sub-runtime from project files.
                if project_path.join("Cargo.toml").exists() {
                    Ok(AppKindDetected::Standalone(StandaloneRuntime::Rust))
                } else {
                    Ok(AppKindDetected::Standalone(StandaloneRuntime::Bun))
                }
            }
            other => bail!(
                "unsupported manifest.app_type '{}' (expected 'native' | 'cdylib' | 'bun' | 'standalone'); \
                 project={}",
                other,
                project_path.display()
            ),
        }
    }

    fn label(&self) -> &'static str {
        match self {
            AppKindDetected::Cdylib => "cdylib (Rust)",
            AppKindDetected::Bun => "Bun",
            AppKindDetected::Standalone(StandaloneRuntime::Rust) => "standalone (Rust)",
            AppKindDetected::Standalone(StandaloneRuntime::Bun) => "standalone (Bun)",
        }
    }

    fn build_label(&self) -> &'static str {
        match self {
            AppKindDetected::Cdylib => "cargo build --release",
            AppKindDetected::Bun => "bun build src/index.ts --outdir dist --target=bun",
            AppKindDetected::Standalone(StandaloneRuntime::Rust) => "cargo build",
            AppKindDetected::Standalone(StandaloneRuntime::Bun) => "bun install",
        }
    }

    fn build_with_manifest(
        &self,
        project_path: &Path,
        manifest: Option<&Manifest>,
        log_tx: Option<&LogTx>,
    ) -> Result<()> {
        match self {
            AppKindDetected::Standalone(StandaloneRuntime::Rust) => run_in_with_sink(
                project_path,
                "cargo",
                &["build"],
                "cargo build",
                log_tx,
                LogSource::Build,
            ),
            AppKindDetected::Standalone(StandaloneRuntime::Bun) => run_in_with_sink(
                project_path,
                "bun",
                &["install"],
                "bun install",
                log_tx,
                LogSource::Build,
            ),
            AppKindDetected::Cdylib => {
                // Build the UI bundle first when the app declares has_ui, so the
                // cdylib release build and the UI dist end up ready together for
                // sideload (mirrors the Bun arm below).
                if let Some(m) = manifest {
                    if m.has_ui {
                        let ui_dir = m
                            .ui_path
                            .as_deref()
                            .and_then(|p| p.strip_suffix("/dist"))
                            .map(|p| project_path.join(p))
                            .unwrap_or_else(|| project_path.join("ui"));
                        if ui_dir.join("package.json").exists() {
                            let pkg_mgr =
                                if ui_dir.join("package-lock.json").exists() { "npm" } else { "bun" };
                            let _ = run_in_with_sink(
                                &ui_dir,
                                pkg_mgr,
                                &["install"],
                                &format!("{pkg_mgr} install (ui)"),
                                log_tx,
                                LogSource::Build,
                            );
                            let _ = run_in_with_sink(
                                &ui_dir,
                                pkg_mgr,
                                &["run", "build"],
                                &format!("{pkg_mgr} run build (ui)"),
                                log_tx,
                                LogSource::Build,
                            );
                        }
                    }
                }

                run_in_with_sink(
                    project_path,
                    "cargo",
                    &["build", "--release", "--lib"],
                    "cargo build",
                    log_tx,
                    LogSource::Build,
                )
            }
            AppKindDetected::Bun => {
                let _ = run_in_with_sink(
                    project_path,
                    "bun",
                    &["install"],
                    "bun install",
                    log_tx,
                    LogSource::Build,
                );

                if let Some(m) = manifest {
                    if m.has_ui {
                        let ui_dir = m
                            .ui_path
                            .as_deref()
                            .and_then(|p| p.strip_suffix("/dist"))
                            .map(|p| project_path.join(p))
                            .unwrap_or_else(|| project_path.join("ui"));
                        if ui_dir.join("package.json").exists() {
                            let pkg_mgr =
                                if ui_dir.join("package-lock.json").exists() { "npm" } else { "bun" };
                            let _ = run_in_with_sink(
                                &ui_dir,
                                pkg_mgr,
                                &["install"],
                                &format!("{pkg_mgr} install (ui)"),
                                log_tx,
                                LogSource::Build,
                            );
                            let _ = run_in_with_sink(
                                &ui_dir,
                                pkg_mgr,
                                &["run", "build"],
                                &format!("{pkg_mgr} run build (ui)"),
                                log_tx,
                                LogSource::Build,
                            );
                        }
                    }
                }

                run_in_with_sink(
                    project_path,
                    "bun",
                    &["build", "src/index.ts", "--outdir", "dist", "--target=bun"],
                    "bun build",
                    log_tx,
                    LogSource::Build,
                )
            }
        }
    }

    fn copy_artifacts(&self, project_path: &Path, manifest: &Manifest, dest: &Path) -> Result<()> {
        let manifest_src = project_path.join("manifest.json");
        let manifest_dst = dest.join("manifest.json");
        std::fs::copy(&manifest_src, &manifest_dst).with_context(|| {
            format!(
                "copy {} → {}",
                manifest_src.display(),
                manifest_dst.display()
            )
        })?;

        match self {
            AppKindDetected::Cdylib => {
                let release_dir = project_path.join("target").join("release");
                let so = find_cdylib_artifact(&release_dir).with_context(|| {
                    format!("locate cdylib artifact under {}", release_dir.display())
                })?;
                let dst = dest.join("app.so");
                std::fs::copy(&so, &dst)
                    .with_context(|| format!("copy {} → {}", so.display(), dst.display()))?;

                // Sideload the built UI bundle if the app declares has_ui.
                // Mirrors `sideload_ui_to_handle` so that initial startup and
                // manual `b` rebuilds also refresh the UI for cdylib apps.
                if manifest.has_ui {
                    let ui_dir = manifest
                        .ui_path
                        .as_deref()
                        .and_then(|p| p.strip_suffix("/dist"))
                        .map(|p| project_path.join(p))
                        .unwrap_or_else(|| project_path.join("ui"));
                    let ui_dist_src = ui_dir.join("dist");
                    if ui_dist_src.exists() {
                        let ui_path_str = manifest.ui_path.as_deref().unwrap_or("dist");
                        let ui_dst = dest.join(ui_path_str);
                        if ui_dst.exists() {
                            std::fs::remove_dir_all(&ui_dst).ok();
                        }
                        copy_dir_recursive(&ui_dist_src, &ui_dst).with_context(|| {
                            format!("copy ui dist → {}", ui_dst.display())
                        })?;
                    }
                }
            }
            AppKindDetected::Bun => {
                let dist_src = project_path.join("dist");
                let dist_dst = dest.join("dist");
                if dist_dst.exists() {
                    std::fs::remove_dir_all(&dist_dst).ok();
                }
                copy_dir_recursive(&dist_src, &dist_dst).with_context(|| {
                    format!("copy {} → {}", dist_src.display(), dist_dst.display())
                })?;

                if manifest.has_ui {
                    let ui_path_str = manifest.ui_path.as_deref().unwrap_or("ui");
                    let ui_src = project_path.join(ui_path_str);
                    if ui_src.exists() {
                        let ui_dst = dest.join(ui_path_str);
                        if ui_dst.exists() {
                            std::fs::remove_dir_all(&ui_dst).ok();
                        }
                        copy_dir_recursive(&ui_src, &ui_dst).ok();
                    }
                }
            }
            // Standalone apps run as their own process — nothing to copy into a dev_dir.
            AppKindDetected::Standalone(_) => {}
        }
        Ok(())
    }
}

fn find_cdylib_artifact(release_dir: &Path) -> Result<PathBuf> {
    let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
    let entries = std::fs::read_dir(release_dir)
        .with_context(|| format!("read_dir {}", release_dir.display()))?;
    for entry in entries.flatten() {
        let path = entry.path();
        let fname = match path.file_name().and_then(|s| s.to_str()) {
            Some(n) => n.to_string(),
            None => continue,
        };
        let is_lib =
            fname.starts_with("lib") && (fname.ends_with(".so") || fname.ends_with(".dylib"));
        if !is_lib {
            continue;
        }
        let mtime = entry
            .metadata()
            .ok()
            .and_then(|m| m.modified().ok())
            .unwrap_or(std::time::UNIX_EPOCH);
        match &best {
            Some((_, prev)) if *prev >= mtime => {}
            _ => best = Some((path, mtime)),
        }
    }
    best.map(|(p, _)| p)
        .ok_or_else(|| anyhow!("no lib*.so / lib*.dylib found in {}", release_dir.display()))
}

fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
    std::fs::create_dir_all(dst).with_context(|| format!("mkdir -p {}", dst.display()))?;
    for entry in
        std::fs::read_dir(src).with_context(|| format!("read_dir {}", src.display()))?
    {
        let entry = entry?;
        let from = entry.path();
        let to = dst.join(entry.file_name());
        if entry.file_type()?.is_dir() {
            copy_dir_recursive(&from, &to)?;
        } else {
            std::fs::copy(&from, &to)
                .with_context(|| format!("copy {} → {}", from.display(), to.display()))?;
        }
    }
    Ok(())
}

// ── Process helpers ────────────────────────────────────────────────────────────

fn run_in_with_sink(
    dest: &Path,
    cmd: &str,
    args: &[&str],
    label: &str,
    log_tx: Option<&LogTx>,
    log_source: LogSource,
) -> Result<()> {
    if let Some(tx) = log_tx {
        let mut command = Command::new(cmd);
        command
            .current_dir(dest)
            .args(args)
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());

        let mut child = command
            .spawn()
            .with_context(|| format!("invoke `{}`", label))?;

        let stdout = child.stdout.take().unwrap();
        let stderr = child.stderr.take().unwrap();

        let tx1 = tx.clone();
        let t1 = std::thread::spawn(move || {
            for line in BufReader::new(stdout).lines().map_while(Result::ok) {
                let _ = tx1.send(TuiEvent::Log(crate::tui::LogEntry {
                    source: log_source,
                    line,
                }));
            }
        });
        let tx2 = tx.clone();
        let t2 = std::thread::spawn(move || {
            for line in BufReader::new(stderr).lines().map_while(Result::ok) {
                let _ = tx2.send(TuiEvent::Log(crate::tui::LogEntry {
                    source: log_source,
                    line,
                }));
            }
        });

        let status = loop {
            match child.try_wait().with_context(|| format!("`{}` try_wait", label))? {
                Some(s) => break s,
                None => {
                    if is_cancelled() {
                        let _ = child.kill();
                        let _ = child.wait();
                        t1.join().ok();
                        t2.join().ok();
                        bail!("build cancelled");
                    }
                    std::thread::sleep(Duration::from_millis(50));
                }
            }
        };
        t1.join().ok();
        t2.join().ok();

        if !status.success() {
            bail!("`{}` exited {}", label, status.code().unwrap_or(-1));
        }
    } else {
        let mut child = Command::new(cmd)
            .current_dir(dest)
            .args(args)
            .stdin(Stdio::null())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .spawn()
            .with_context(|| format!("invoke `{}`", label))?;
        let status = loop {
            match child.try_wait().with_context(|| format!("`{}` try_wait", label))? {
                Some(s) => break s,
                None => {
                    if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
                        let _ = child.kill();
                        let _ = child.wait();
                        bail!("build cancelled");
                    }
                    std::thread::sleep(Duration::from_millis(50));
                }
            }
        };
        if !status.success() {
            bail!("`{}` exited {}", label, status.code().unwrap_or(-1));
        }
    }
    Ok(())
}

// ── Minimal IPC client ─────────────────────────────────────────────────────────

#[derive(Serialize)]
struct IpcRequest {
    jsonrpc: &'static str,
    id: u64,
    method: String,
    params: Value,
}

#[derive(Deserialize, Debug)]
struct IpcResponse {
    #[allow(dead_code)]
    jsonrpc: String,
    #[allow(dead_code)]
    id: Option<Value>,
    result: Option<Value>,
    error: Option<IpcRpcError>,
}

#[derive(Deserialize, Debug)]
struct IpcRpcError {
    code: i32,
    message: String,
}

fn ipc_call(socket_path: &Path, method: &str, params: Value) -> Result<Value> {
    let stream = UnixStream::connect(socket_path).with_context(|| {
        format!(
            "connect to daemon IPC socket at '{}'.\n\
             Is the node daemon running? Is your user in the 'node' group? \
             Override with --socket if you used a custom path.",
            socket_path.display()
        )
    })?;
    stream
        .set_read_timeout(Some(IPC_TIMEOUT))
        .context("set read timeout")?;
    stream
        .set_write_timeout(Some(IPC_TIMEOUT))
        .context("set write timeout")?;

    let req = IpcRequest {
        jsonrpc: "2.0",
        id: 1,
        method: method.to_string(),
        params,
    };
    let line = serde_json::to_string(&req).context("serialize request")?;
    let mut stream = stream;
    writeln!(stream, "{}", line).context("write request")?;
    stream.flush().ok();

    let reader = BufReader::new(stream);
    let response_line = reader
        .lines()
        .next()
        .ok_or_else(|| anyhow!("daemon closed connection without responding"))?
        .context("read response")?;

    let response: IpcResponse =
        serde_json::from_str(&response_line).context("parse JSON-RPC response")?;
    if let Some(err) = response.error {
        bail!("daemon RPC error {}: {}", err.code, err.message);
    }
    response
        .result
        .ok_or_else(|| anyhow!("response missing both 'result' and 'error'"))
}

// ── Infra background threads ──────────────────────────────────────────────────

/// Convert an infra health status to the TUI equivalent.
fn convert_health_status(
    s: infra_cmd::health::InfraHealthStatus,
) -> tui::InfraHealthStatus {
    match s {
        infra_cmd::health::InfraHealthStatus::Healthy => tui::InfraHealthStatus::Healthy,
        infra_cmd::health::InfraHealthStatus::Running => tui::InfraHealthStatus::Running,
        infra_cmd::health::InfraHealthStatus::Degraded => tui::InfraHealthStatus::Degraded,
        infra_cmd::health::InfraHealthStatus::Down => tui::InfraHealthStatus::Down,
        infra_cmd::health::InfraHealthStatus::External => tui::InfraHealthStatus::External,
    }
}

/// Spawn background threads that poll infra health/data and stream logs.
fn start_infra_threads(tx: LogTx, ctx: InfraContext) {
    // ── Health + data poller (every 10 s) ─────────────────────────────────────
    let tx_p = tx.clone();
    let ctx_p = ctx.clone();
    std::thread::spawn(move || {
        loop {
            // Health check.
            let services: Vec<InfraServiceHealth> =
                infra_cmd::health::check_all(&ctx_p)
                    .into_iter()
                    .map(|h| InfraServiceHealth {
                        name: h.name,
                        status: convert_health_status(h.status),
                        detail: h.detail,
                    })
                    .collect();
            let _ = tx_p.send(TuiEvent::InfraHealth(services));

            // DB summary + channel list → graph.
            if let Ok(db) = infra_cmd::db::query_summary(&ctx_p) {
                let raw_channels = infra_cmd::db::query_channel_list(&ctx_p, 200);
                let channels: Vec<InfraChannelEntry> = raw_channels
                    .into_iter()
                    .map(|(scid, node1)| InfraChannelEntry { scid, node1 })
                    .collect();
                let _ = tx_p.send(TuiEvent::InfraGraph(InfraGraphData {
                    node_count: db.node_announcements,
                    channel_count: db.channel_announcements,
                    channels,
                }));
                let _ = tx_p.send(TuiEvent::InfraDb(tui::state::InfraDbSummary {
                    node_announcements: db.node_announcements,
                    channel_announcements: db.channel_announcements,
                    channel_updates: db.channel_updates,
                    config_rows: db.config_rows,
                }));
            }

            // Snapshot summary.
            if let Ok(s) = infra_cmd::snapshot::fetch_snapshot_summary(&ctx_p, None) {
                let _ = tx_p.send(TuiEvent::InfraSnapshot(tui::state::InfraSnapshotInfo {
                    version: s.version,
                    chain_hash: s.chain_hash,
                    timestamp: s.timestamp,
                    timestamp_str: s.timestamp_str,
                    node_count: s.node_count,
                    channel_count: s.channel_count,
                    update_count: s.update_count,
                }));
            }

            std::thread::sleep(Duration::from_secs(10));
        }
    });

    // ── Log streamers — one thread per container using `docker logs -f` ───────
    // NOTE: We use `docker logs -f <container>` directly, NOT `docker compose logs`,
    // because the latter requires environment variables (like LN_PEERS) to be set.
    for (container, service) in [
        (infra_cmd::ops::rgs_container_name(&ctx), InfraLogService::Rgs),
        (
            infra_cmd::ops::postgres_container_name(&ctx),
            InfraLogService::Postgres,
        ),
    ] {
        let tx_l = tx.clone();
        std::thread::spawn(move || {
            let Ok(mut child) = std::process::Command::new("docker")
                .args(["logs", "-f", "--tail=100", &container])
                .stdout(std::process::Stdio::piped())
                .stderr(std::process::Stdio::piped())
                .spawn()
            else {
                return;
            };

            // Stream stdout in a sub-thread.
            let tx2 = tx_l.clone();
            let svc2 = service;
            if let Some(stdout) = child.stdout.take() {
                std::thread::spawn(move || {
                    use std::io::BufRead;
                    for line in BufReader::new(stdout).lines().map_while(Result::ok) {
                        let _ = tx2.send(TuiEvent::InfraLog(InfraLogLine {
                            line,
                            service: svc2,
                        }));
                    }
                });
            }

            // Stream stderr (docker logs mixes both streams here).
            if let Some(stderr) = child.stderr.take() {
                use std::io::BufRead;
                for line in BufReader::new(stderr).lines().map_while(Result::ok) {
                    let _ = tx_l.send(TuiEvent::InfraLog(InfraLogLine {
                        line,
                        service,
                    }));
                }
            }
        });
    }
}
