//! Platform-repo mode for `node-app dev`.
//!
//! When invoked from the node monorepo (detected by the presence of
//! `infra/debian/platform-depends`), boot the platform server here AND
//! auto-resolve every node-app the platform depends on, building & staging
//! each one before the daemon starts.
//!
//! The list of platform-side app deps is parsed from
//! `infra/debian/platform-depends`: any non-blank, non-comment line beginning
//! with `node-app-` becomes an app name (prefix stripped).
//!
//! Unlike the app-developer flow, this mode does NOT sideload via IPC and
//! does NOT watch source files — the platform's own inotify watcher picks up
//! pre-staged apps on boot, and contributor edits to the platform crates are
//! handled via their own cargo workflow.

use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};

use anyhow::{Context, Result};

use super::host::{self, InstanceProfile};
use super::sources_resolver;
use super::{build_and_stage, ipc_call, load_manifest, DevArgs, SHUTDOWN_REQUESTED};
use crate::tui::{self, BuildScope, DevSignals, LogSource, LogTx, ServiceStatus};

/// Entry point for platform-repo dev mode.
///
/// Returns once Ctrl-C / TUI quit is observed and the daemon has been asked
/// to shut down.
pub fn run(platform_root: &Path, args: &DevArgs<'_>) -> Result<()> {
    // ── Parse platform-depends FIRST so the TUI can seed its sidebar ──────────
    // (TUI runs in another thread; we have to know the app list before
    // constructing AppState. Errors here print to stderr — no TUI active yet.)
    let depends_path = platform_root.join("infra/debian/platform-depends");
    let app_names = parse_platform_depends(&depends_path)
        .with_context(|| format!("parse {}", depends_path.display()))?;

    // Install the OS signal handler up front so Ctrl+C interrupts in-flight
    // dep builds even before the TUI thread is running (no-TUI mode, or
    // before pre-boot staging completes in TUI mode).
    install_signal_handler();

    // ── TUI setup (mirrors app-mode in dev::run) ──────────────────────────────
    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();
        // Mirror dev::run line 147: register the TUI's quit flag globally so
        // build polling loops (`run_in_with_sink`, `run_build_cmd`) can
        // observe `q` / Ctrl+C-as-key and kill the cargo child. Without this
        // the TUI swallows Ctrl+C in raw mode and builds run to completion.
        let _ = super::QUIT_FLAG.set(signals.quit_requested.clone());
        // Repurpose `instance_names` as the dep-app list: MonorepoHost::tail_logs
        // tags each line with `[<app>] ` and AppState::push_log routes those
        // into per-app buffers. Mouse-click on an app in the sidebar sets
        // `instance_filter` so the App pane shows just that app's logs.
        let mut app_state = tui::state::AppState::new(
            "node-platform".to_string(),
            String::new(),
            app_names.clone(),
        );
        app_state.seed_app_list(&app_names);
        // Resolve node names up-front (same logic as the profile-resolution
        // block below) so the TUI's nodes sidebar can show every instance
        // immediately, even before the daemons have booted.
        let node_names: Vec<String> = if args.instances.is_empty() {
            vec!["alice".to_string()]
        } else {
            args.instances.clone()
        };
        // Single-instance runs don't need the nodes sidebar — it's just
        // visual noise. Skip seeding so the box stays hidden in that case.
        if node_names.len() > 1 {
            app_state.seed_node_list(&node_names);
        }
        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)
    };

    // Guard to flush TUI cleanly on every exit path.
    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,
    };

    tui::sys_log(
        log_tx_opt.as_ref(),
        format!(
            "→ platform mode (cwd = {})  reading infra/debian/platform-depends…",
            platform_root.display()
        ),
    );
    if app_names.is_empty() {
        tui::sys_log(
            log_tx_opt.as_ref(),
            "→ no node-app-* entries in platform-depends; will boot server with no extra apps.",
        );
    } else {
        tui::sys_log(
            log_tx_opt.as_ref(),
            format!("→ {} node-app dep(s) declared: {}", app_names.len(), app_names.join(", ")),
        );
    }

    // ── Resolve sources (sibling → cache → clone) ─────────────────────────────
    let resolved_paths = sources_resolver::resolve(
        platform_root,
        &app_names,
        &args.dep_paths,
        log_tx_opt.as_ref(),
    )?;

    // Merge with explicit --dep flags (user can pass extras not in
    // platform-depends, or override one of the listed apps).
    let mut all_dep_paths: Vec<PathBuf> = args.dep_paths.clone();
    all_dep_paths.extend(resolved_paths);

    // Publish each dep's resolved on-disk path to the TUI so the apps
    // sidebar can show *where* each app lives (sibling vs cache vs --dep
    // override) and the log-pane title can show the full path when an app
    // is focused. Best-effort: a dep with an unparseable manifest just
    // shows up without a path.
    for p in &all_dep_paths {
        if let Ok(m) = load_manifest(p) {
            tui::update_app_path(log_tx_opt.as_ref(), &m.name, p.clone());
        }
    }

    // ── Resolve instance profiles ─────────────────────────────────────────────
    // Platform mode now honours --instances alice,bob so contributors can
    // exercise multi-node flows (gossip, P2P, payments). The TUI's app
    // sidebar still works as a single filter axis (apps); per-node logs are
    // distinguishable via the `[<node>] ` prefix MonorepoHost::spawn_daemon
    // already injects into stdout/stderr.
    let 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<_>>>()?
    };
    tui::sys_log(
        log_tx_opt.as_ref(),
        format!(
            "→ booting {} instance(s): {}",
            profiles.len(),
            profiles.iter().map(|p| p.name.as_str()).collect::<Vec<_>>().join(", ")
        ),
    );

    // ── Build one MonorepoHost per instance, all targeted at the platform ────
    let mode = host::Mode::Monorepo {
        path: platform_root.to_path_buf(),
    };
    let hosts: Vec<Box<dyn host::DaemonHost>> = profiles
        .iter()
        .map(|p| {
            host::for_mode(
                mode.clone(),
                p.clone(),
                // socket_override / dev_dir_override only make sense for a
                // single instance — for multi-instance let MonorepoHost
                // derive its own per-instance paths.
                if profiles.len() == 1 { args.socket_override } else { None },
                if profiles.len() == 1 { args.dev_dir_override } else { None },
                log_tx_opt.clone(),
            )
        })
        .collect();

    // ── Pre-boot dep staging ─────────────────────────────────────────────────
    // Stage into EVERY host's dev-dir so each node loads the same set of
    // dep apps on first boot. We only build each dep once and copy from the
    // canonical staging location to the other instance dev-dirs.
    if !all_dep_paths.is_empty() {
        let pre_start_dev_dirs: Vec<PathBuf> = hosts
            .iter()
            .filter_map(|h| h.pre_start_dev_dir())
            .collect();
        if !pre_start_dev_dirs.is_empty() {
            tui::sys_log(
                log_tx_opt.as_ref(),
                format!(
                    "→ staging {} dep(s) into {} instance dev-dir(s) (pre-boot)…",
                    all_dep_paths.len(),
                    pre_start_dev_dirs.len()
                ),
            );
            stage_with_status(&all_dep_paths, &pre_start_dev_dirs, log_tx_opt.as_ref())?;
            // Standalone deps need a manifest with a per-instance socket_path
            // staged into each dev_dir BEFORE the daemon boots — that's how
            // standalone_discovery picks them up with the right UDS path.
            stage_standalone_manifests(
                &all_dep_paths,
                &pre_start_dev_dirs,
                log_tx_opt.as_ref(),
            )?;
        }
    }

    // ── Boot the platform daemons sequentially ───────────────────────────────
    // Sequential boot avoids cargo build-lock contention; each daemon's
    // logs are prefixed `[alice] ` / `[bob] ` so they're distinguishable.
    // (Signal handler is installed at function entry.)
    let mut handles: Vec<host::DaemonHandle> = Vec::with_capacity(hosts.len());
    for host_impl in &hosts {
        let handle = host_impl.ensure_running().context("start platform daemon")?;
        tui::sys_log(
            log_tx_opt.as_ref(),
            format!("✓ platform up — {}", handle.banner),
        );
        handles.push(handle);
    }

    // Post-boot fallback (covers any host without a pre-start dev-dir).
    if !all_dep_paths.is_empty() {
        let post_start_dirs: Vec<PathBuf> = hosts
            .iter()
            .zip(&handles)
            .filter(|(h, _)| h.pre_start_dev_dir().is_none())
            .map(|(_, handle)| handle.dev_dir.clone())
            .collect();
        if !post_start_dirs.is_empty() {
            tui::sys_log(
                log_tx_opt.as_ref(),
                format!(
                    "→ staging {} dep(s) into {} instance(s) (post-boot)…",
                    all_dep_paths.len(),
                    post_start_dirs.len()
                ),
            );
            stage_with_status(&all_dep_paths, &post_start_dirs, log_tx_opt.as_ref())?;
            stage_standalone_manifests(
                &all_dep_paths,
                &post_start_dirs,
                log_tx_opt.as_ref(),
            )?;
        }
    }

    // ── Sideload each staged dep via app.dev_load IPC ─────────────────────────
    // The platform's inotify watcher only fires on post-startup events, so
    // pre-staged apps would otherwise never be loaded — leaving them invisible
    // to /v2/node-apps and the UI. Call app.dev_load per-dep per-instance so
    // the daemon upserts the DB row and dlopens the artifact.
    if !all_dep_paths.is_empty() {
        tui::sys_log(
            log_tx_opt.as_ref(),
            format!(
                "→ sideloading {} dep(s) into {} instance(s) via app.dev_load…",
                all_dep_paths.len(),
                handles.len()
            ),
        );
        for handle in &handles {
            for dep_path in &all_dep_paths {
                let dep_manifest = match load_manifest(dep_path) {
                    Ok(m) => m,
                    Err(e) => {
                        tui::sys_log(
                            log_tx_opt.as_ref(),
                            format!(
                                "⚠ skip dev_load — could not read manifest for {}: {:#}",
                                dep_path.display(),
                                e
                            ),
                        );
                        continue;
                    }
                };
                // platform-runtime deps repackage upstream binaries and aren't
                // loaded by the app manager — same skip rule as stage_with_status.
                if dep_manifest.app_type.eq_ignore_ascii_case("platform-runtime") {
                    continue;
                }
                // Standalone apps run as their own OS process; NodeAppManager
                // rejects them via ManagerError::StandaloneNotManaged. They get
                // spawned + supervised by spawn_standalones below instead.
                if dep_manifest.app_type.eq_ignore_ascii_case("standalone") {
                    continue;
                }
                let dest = handle.dev_dir.join(&dep_manifest.name);
                let label = if handle.name.is_empty() {
                    dep_manifest.name.clone()
                } else {
                    format!("{} [{}]", dep_manifest.name, handle.name)
                };
                match ipc_call(
                    &handle.socket_path,
                    "app.dev_load",
                    serde_json::json!({
                        "name": dep_manifest.name,
                        "path": dest.to_string_lossy(),
                    }),
                ) {
                    Ok(_) => tui::sys_log(
                        log_tx_opt.as_ref(),
                        format!("✓ {} sideloaded", label),
                    ),
                    Err(e) => tui::sys_log(
                        log_tx_opt.as_ref(),
                        format!("✗ dev_load {}: {:#}", label, e),
                    ),
                }
            }
        }
    }

    // ── Spawn standalone deps per instance ────────────────────────────────────
    // Standalone apps aren't sideloaded into the daemon — they're independent
    // processes. Spawn each (instance × standalone dep) with env vars pointing
    // at the per-instance daemon socket + per-instance UDS + staged manifest.
    // Children are tracked so we can kill them on shutdown.
    let mut spawned_standalones = spawn_standalones(
        &all_dep_paths,
        &handles,
        &args.config,
        log_tx_opt.as_ref(),
    );

    if args.once {
        tui::sys_log(
            log_tx_opt.as_ref(),
            "✓ --once: platform booted and deps staged; shutting down.",
        );
        for s in spawned_standalones.iter_mut() {
            let _ = s.child.kill();
            let _ = s.child.wait();
        }
        for h in &hosts {
            h.shutdown();
        }
        return Ok(());
    }

    // Stream per-app logs from every node. tail_logs internally prefixes
    // lines with `[<app>] ` so the existing per-app routing aggregates
    // across nodes; the original `[<node>] ` prefix on daemon stdout keeps
    // node distinction visible in the unified Daemon pane.
    for h in &hosts {
        for name in &app_names {
            h.tail_logs(name);
        }
    }

    tui::sys_log(
        log_tx_opt.as_ref(),
        "→ platform running. Edit platform code in the monorepo and rerun to pick up changes. \
         Ctrl-C to stop.",
    );

    // ── Block until shutdown is requested ────────────────────────────────────
    loop {
        if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
            break;
        }
        if let Some(sigs) = &signals_opt {
            if sigs.should_quit() {
                break;
            }
            if sigs.take_restart() {
                let started = Instant::now();
                banner(log_tx_opt.as_ref(), "⟳ RESTART REQUESTED (r) — system daemon");
                for h in &hosts {
                    if let Err(e) = h.restart() {
                        tui::sys_log(log_tx_opt.as_ref(), format!("✗ restart failed: {:#}", e));
                    }
                }
                banner(
                    log_tx_opt.as_ref(),
                    format!("✓ RESTART COMPLETE ({:.1}s)", started.elapsed().as_secs_f32()),
                );
            }
            if let Some(scope) = sigs.take_build_scope() {
                let started = Instant::now();
                banner(
                    log_tx_opt.as_ref(),
                    format!("⟳ MANUAL REBUILD ({}) TRIGGERED", scope_label(scope)),
                );

                let do_apps = matches!(scope, BuildScope::Apps | BuildScope::All);
                let do_system = matches!(scope, BuildScope::System | BuildScope::All);
                let do_ui = matches!(scope, BuildScope::Ui | BuildScope::All);

                // 1. Apps: restage every node-app dep (build_and_stage rebuilds each).
                if do_apps {
                    if all_dep_paths.is_empty() {
                        tui::sys_log(
                            log_tx_opt.as_ref(),
                            "→ apps: no node-app deps declared; nothing to rebuild.",
                        );
                    } else {
                        let dev_dirs: Vec<PathBuf> =
                            handles.iter().map(|h| h.dev_dir.clone()).collect();
                        tui::sys_log(
                            log_tx_opt.as_ref(),
                            format!("→ rebuilding {} app dep(s)…", all_dep_paths.len()),
                        );
                        if let Err(e) =
                            stage_with_status(&all_dep_paths, &dev_dirs, log_tx_opt.as_ref())
                        {
                            tui::sys_log(
                                log_tx_opt.as_ref(),
                                format!("✗ app rebuild failed: {:#}", e),
                            );
                        }
                    }
                }

                // 2. System daemon: host.restart() runs `cargo build -p node-server`
                //    and respawns the daemon, surfacing Daemon-pane status updates.
                if do_system {
                    for h in &hosts {
                        if let Err(e) = h.restart() {
                            tui::sys_log(
                                log_tx_opt.as_ref(),
                                format!("✗ system rebuild/restart failed: {:#}", e),
                            );
                        }
                    }
                }

                // 3. UI: Vite already hot-reloads on source changes, so a
                //    forced rebuild here would just churn the browser. For now
                //    we just acknowledge the lane in the UiServer tab; if a
                //    hard restart is needed later, restart the spawned UI
                //    child via MonorepoHost. We don't expose that yet because
                //    it would tear down the user's open Vite session.
                if do_ui {
                    tui::sys_log(
                        log_tx_opt.as_ref(),
                        "→ ui: Vite HMR is active — no rebuild needed. \
                         Touch a source file to trigger a hot reload.",
                    );
                    tui::update_status(
                        log_tx_opt.as_ref(),
                        LogSource::UiServer,
                        ServiceStatus::Ready,
                        Some("HMR — no rebuild needed".into()),
                    );
                }

                banner(
                    log_tx_opt.as_ref(),
                    format!(
                        "✓ REBUILD COMPLETE ({:.1}s)",
                        started.elapsed().as_secs_f32()
                    ),
                );
            }
        }
        std::thread::sleep(Duration::from_millis(200));
    }

    tui::sys_log(log_tx_opt.as_ref(), "→ shutting down…");
    for s in spawned_standalones.iter_mut() {
        tui::sys_log(
            log_tx_opt.as_ref(),
            format!("→ killing standalone {}", s.label),
        );
        let _ = s.child.kill();
        let _ = s.child.wait();
    }
    for h in &hosts {
        h.shutdown();
    }
    drop(log_tx_opt);
    Ok(())
}

/// Build & stage each dep, publishing per-app status to the TUI sidebar.
/// Building → Loaded { reloads: 1 } on success; Failed(message) on error.
///
/// `dev_dirs` is the list of all instance dev-dirs that should receive a
/// copy of the staged dep. Each dep is built ONCE (against the first dev-dir
/// via `build_and_stage`); the staged output is then mirrored byte-for-byte
/// into every subsequent dev-dir via `copy_dir_recursive`. Returns the first
/// error encountered (after marking that app failed).
fn stage_with_status(
    dep_paths: &[PathBuf],
    dev_dirs: &[PathBuf],
    log_tx: Option<&crate::tui::LogTx>,
) -> Result<()> {
    if dev_dirs.is_empty() {
        return Ok(());
    }
    for dep_path in dep_paths {
        // Best-effort read of the manifest name so per-app status events line
        // up with the sidebar entries seeded from platform-depends. If the
        // manifest is unreadable we still attempt the stage; build_and_stage
        // will surface the real error.
        let manifest = load_manifest(dep_path).ok();
        let app_name = manifest.as_ref().map(|m| m.name.clone());

        // platform-runtime deps (e.g. bun-runtime) are packaging-only targets
        // — nothing to build, stage, or mirror. Mark the sidebar entry as a
        // no-op and continue to the next dep.
        if manifest
            .as_ref()
            .map(|m| m.app_type.eq_ignore_ascii_case("platform-runtime"))
            .unwrap_or(false)
        {
            if let Some(ref n) = app_name {
                tui::update_app_status(
                    log_tx,
                    n,
                    ServiceStatus::Loaded { reloads: 0 },
                    Some("platform-runtime (not staged)".to_string()),
                );
            }
            continue;
        }

        if let Some(ref n) = app_name {
            tui::update_app_status(log_tx, n, ServiceStatus::Building, None);
        }

        // Build once + stage into the first dev-dir.
        let first_dir = &dev_dirs[0];
        if let Err(e) = build_and_stage(dep_path, first_dir, log_tx) {
            if let Some(ref n) = app_name {
                let short = e.to_string().chars().take(60).collect::<String>();
                tui::update_app_status(log_tx, n, ServiceStatus::Failed(short), None);
            }
            return Err(e);
        }

        // Mirror the staged output to every other dev-dir.
        if let Some(name) = &app_name {
            let src = first_dir.join(name);
            for dst_root in &dev_dirs[1..] {
                let dst = dst_root.join(name);
                if dst.exists() {
                    std::fs::remove_dir_all(&dst).ok();
                }
                if let Err(e) = super::copy_dir_recursive(&src, &dst) {
                    let short = e.to_string().chars().take(60).collect::<String>();
                    tui::update_app_status(log_tx, name, ServiceStatus::Failed(short), None);
                    return Err(e);
                }
            }
        }

        if let Some(ref n) = app_name {
            let detail = if dev_dirs.len() > 1 {
                format!("staged ×{}", dev_dirs.len())
            } else {
                "staged".into()
            };
            tui::update_app_status(
                log_tx,
                n,
                ServiceStatus::Loaded { reloads: 1 },
                Some(detail),
            );
        }
    }
    Ok(())
}

/// Tracks a standalone child spawned by `spawn_standalones` so we can kill
/// it on shutdown. `label` is "<app> [<instance>]" for log output.
struct SpawnedStandalone {
    label: String,
    child: std::process::Child,
}

/// For each standalone dep, write a per-instance copy of `manifest.json` into
/// `<dev_dir>/<app>/manifest.json` with `standalone.socket_path` rewritten to
/// `<env_dir>/<app>.sock` (env_dir = dev_dir.parent). Also copies the UI dist
/// for apps with `has_ui`. This is what makes the daemon's boot-time
/// `standalone_discovery` scan find dev-staged standalones with the correct
/// socket path (instead of the manifest's hardcoded `/run/...`).
///
/// `stage_with_status` already builds the binaries; this only handles the
/// metadata side. Non-standalone deps are silently skipped.
fn stage_standalone_manifests(
    dep_paths: &[PathBuf],
    dev_dirs: &[PathBuf],
    log_tx: Option<&LogTx>,
) -> Result<()> {
    for dep_path in dep_paths {
        let manifest_path = dep_path.join("manifest.json");
        let raw = match std::fs::read_to_string(&manifest_path) {
            Ok(s) => s,
            Err(e) => {
                tui::sys_log(
                    log_tx,
                    format!(
                        "⚠ standalone stage: read {} failed: {}",
                        manifest_path.display(),
                        e
                    ),
                );
                continue;
            }
        };
        let mut value: serde_json::Value = match serde_json::from_str(&raw) {
            Ok(v) => v,
            Err(e) => {
                tui::sys_log(
                    log_tx,
                    format!(
                        "⚠ standalone stage: parse {} failed: {}",
                        manifest_path.display(),
                        e
                    ),
                );
                continue;
            }
        };
        let app_type = value
            .get("app_type")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        if !app_type.eq_ignore_ascii_case("standalone") {
            continue;
        }
        let app_name = value
            .get("name")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        if app_name.is_empty() {
            continue;
        }
        let has_ui = value.get("has_ui").and_then(|v| v.as_bool()).unwrap_or(false);
        let ui_relpath = value
            .get("ui_path")
            .and_then(|v| v.as_str())
            .unwrap_or("dist")
            .to_string();

        for dev_dir in dev_dirs {
            let env_dir = match dev_dir.parent() {
                Some(p) => p.to_path_buf(),
                None => {
                    tui::sys_log(
                        log_tx,
                        format!(
                            "⚠ {}: dev_dir {} has no parent — can't derive socket path",
                            app_name,
                            dev_dir.display()
                        ),
                    );
                    continue;
                }
            };
            let socket_path = env_dir.join(format!("{app_name}.sock"));
            value["standalone"] = serde_json::json!({
                "socket_path": socket_path.display().to_string()
            });

            let dest_dir = dev_dir.join(&app_name);
            if let Err(e) = std::fs::create_dir_all(&dest_dir) {
                tui::sys_log(
                    log_tx,
                    format!("✗ mkdir {}: {}", dest_dir.display(), e),
                );
                continue;
            }
            let dest_manifest = dest_dir.join("manifest.json");
            let serialized = match serde_json::to_string_pretty(&value) {
                Ok(s) => s,
                Err(e) => {
                    tui::sys_log(
                        log_tx,
                        format!("✗ serialize manifest for {}: {}", app_name, e),
                    );
                    continue;
                }
            };
            if let Err(e) = std::fs::write(&dest_manifest, serialized) {
                tui::sys_log(
                    log_tx,
                    format!("✗ write {}: {}", dest_manifest.display(), e),
                );
                continue;
            }
            if has_ui {
                let src_ui = dep_path.join(&ui_relpath);
                if src_ui.exists() {
                    let dst_ui = dest_dir.join(&ui_relpath);
                    if dst_ui.exists() {
                        let _ = std::fs::remove_dir_all(&dst_ui);
                    }
                    if let Err(e) = super::copy_dir_recursive(&src_ui, &dst_ui) {
                        tui::sys_log(
                            log_tx,
                            format!("✗ copy UI {} → {}: {}", src_ui.display(), dst_ui.display(), e),
                        );
                    }
                }
            }
        }
    }
    Ok(())
}

/// Spawn each standalone dep as a child process per instance, with env vars
/// pointing at the per-instance daemon socket + standalone UDS + staged
/// manifest. Returns the live child handles so the caller can kill them on
/// shutdown. Non-standalone deps are silently skipped.
fn spawn_standalones(
    dep_paths: &[PathBuf],
    handles: &[host::DaemonHandle],
    config: &[(String, String)],
    log_tx: Option<&LogTx>,
) -> Vec<SpawnedStandalone> {
    let mut spawned = Vec::new();
    for dep_path in dep_paths {
        let manifest_path = dep_path.join("manifest.json");
        let raw = match std::fs::read_to_string(&manifest_path) {
            Ok(s) => s,
            Err(_) => continue,
        };
        let value: serde_json::Value = match serde_json::from_str(&raw) {
            Ok(v) => v,
            Err(_) => continue,
        };
        let app_type = value
            .get("app_type")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        if !app_type.eq_ignore_ascii_case("standalone") {
            continue;
        }
        let app_name = value
            .get("name")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        if app_name.is_empty() {
            continue;
        }

        let is_rust = dep_path.join("Cargo.toml").exists();
        let bin_name = format!("node-app-{}", app_name);
        let rust_bin = dep_path.join("target").join("debug").join(&bin_name);

        for handle in handles {
            let env_dir = match handle.dev_dir.parent() {
                Some(p) => p.to_path_buf(),
                None => continue,
            };
            let socket_path = env_dir.join(format!("{app_name}.sock"));
            // Remove stale socket so the new bind succeeds (UnixListener::bind
            // fails with EADDRINUSE on an existing path).
            let _ = std::fs::remove_file(&socket_path);
            let staged_manifest = handle.dev_dir.join(&app_name).join("manifest.json");

            let mut cmd = if is_rust {
                let mut c = std::process::Command::new(&rust_bin);
                c.current_dir(dep_path);
                c
            } else {
                let mut c = std::process::Command::new("bun");
                c.args(["run", "src/index.ts"]);
                c.current_dir(dep_path);
                c
            };

            cmd.env("NODE_APP_SOCKET", &socket_path);
            cmd.env("NODE_IPC_SOCKET", &handle.socket_path);
            cmd.env("NODE_APP_MANIFEST_PATH", &staged_manifest);
            // The standalone process itself doesn't validate socket paths, but
            // forwarding the flag keeps env parity with the daemon in case the
            // app calls back into shared validation code.
            cmd.env("NODE_ALLOW_DEV_SOCKET_PATH", "1");
            for (k, v) in config {
                cmd.env(k, v);
            }

            cmd.stdin(std::process::Stdio::null());
            if log_tx.is_some() {
                cmd.stdout(std::process::Stdio::piped());
                cmd.stderr(std::process::Stdio::piped());
            } else {
                cmd.stdout(std::process::Stdio::inherit());
                cmd.stderr(std::process::Stdio::inherit());
            }

            let label = format!("{} [{}]", app_name, handle.name);
            match cmd.spawn() {
                Ok(mut child) => {
                    if let Some(tx) = log_tx.cloned() {
                        let prefix = format!("[{}][{}] ", handle.name, app_name);
                        if let Some(stdout) = child.stdout.take() {
                            tail_to_tui(stdout, prefix.clone(), tx.clone());
                        }
                        if let Some(stderr) = child.stderr.take() {
                            tail_to_tui(stderr, prefix, tx);
                        }
                    }
                    tui::sys_log(
                        log_tx,
                        format!(
                            "✓ {} spawned (pid {}, socket={})",
                            label,
                            child.id(),
                            socket_path.display()
                        ),
                    );
                    spawned.push(SpawnedStandalone { label, child });
                }
                Err(e) => {
                    let hint = if is_rust && !rust_bin.exists() {
                        format!(
                            " — binary not found at {}; was the dep built?",
                            rust_bin.display()
                        )
                    } else {
                        String::new()
                    };
                    tui::sys_log(
                        log_tx,
                        format!("✗ spawn {}: {}{}", label, e, hint),
                    );
                }
            }
        }
    }
    spawned
}

/// Forward child stdio lines to the TUI as `LogSource::App` entries with the
/// `[<instance>][<app>] ` prefix that AppState::push_log already understands
/// (it peels both prefixes and routes lines into per-node + per-app buffers).
fn tail_to_tui<R: std::io::Read + Send + 'static>(reader: R, prefix: String, tx: LogTx) {
    std::thread::spawn(move || {
        for line in std::io::BufReader::new(reader).lines().map_while(Result::ok) {
            let _ = tx.send(crate::tui::TuiEvent::Log(crate::tui::LogEntry {
                source: LogSource::App,
                line: format!("{prefix}{line}"),
            }));
        }
    });
}

/// Parse `infra/debian/platform-depends`: yield each `node-app-<name>` line's
/// stripped name. Blank lines and comments (`#`) are ignored. Lines that
/// don't start with `node-app-` are skipped (they're system packages).
pub fn parse_platform_depends(path: &Path) -> Result<Vec<String>> {
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("read {}", path.display()))?;
    let mut out = Vec::new();
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        // Strip dpkg version constraint trailers, e.g. "node-app-foo (>= 1.0)".
        let pkg = line
            .split_whitespace()
            .next()
            .unwrap_or("")
            .trim();
        if let Some(rest) = pkg.strip_prefix("node-app-") {
            if !rest.is_empty() {
                out.push(rest.to_string());
            }
        }
    }
    Ok(out)
}

#[cfg(unix)]
fn install_signal_handler() {
    use std::sync::atomic::AtomicBool;
    static INSTALLED: AtomicBool = AtomicBool::new(false);
    if INSTALLED.swap(true, Ordering::SeqCst) {
        return;
    }
    unsafe {
        libc::signal(
            libc::SIGINT,
            super::handle_shutdown_signal as *const () as libc::sighandler_t,
        );
        libc::signal(
            libc::SIGTERM,
            super::handle_shutdown_signal as *const () as libc::sighandler_t,
        );
    }
}

#[cfg(not(unix))]
fn install_signal_handler() {}

/// Short human label for a rebuild scope, used inside the banner line so the
/// user can tell at a glance which slice they asked for.
fn scope_label(scope: BuildScope) -> &'static str {
    match scope {
        BuildScope::All => "all",
        BuildScope::System => "system",
        BuildScope::Apps => "apps",
        BuildScope::Ui => "ui",
    }
}

/// Emit a bracketed banner via the TUI System pane so manual rebuild / restart
/// activity is impossible to miss in the log stream. Three lines (rule, label,
/// rule) keep the boundary visually distinct from regular `→` / `✓` lines.
fn banner(log_tx: Option<&crate::tui::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);
}

// ── Tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn parse_strips_prefix_and_skips_non_app_lines() {
        let tmp = TempDir::new().unwrap();
        let p = tmp.path().join("platform-depends");
        fs::write(
            &p,
            "# header comment\n\
             jq\n\
             curl\n\
             \n\
             # Built-in node apps\n\
             node-app-esp32-bridge\n\
             node-app-discovery\n\
             # trailing comment\n",
        )
        .unwrap();

        let names = parse_platform_depends(&p).unwrap();
        assert_eq!(names, vec!["esp32-bridge", "discovery"]);
    }

    #[test]
    fn parse_handles_version_constraints() {
        let tmp = TempDir::new().unwrap();
        let p = tmp.path().join("platform-depends");
        fs::write(&p, "node-app-foo (>= 1.2.3)\n").unwrap();
        assert_eq!(parse_platform_depends(&p).unwrap(), vec!["foo"]);
    }

    #[test]
    fn parse_skips_empty_node_app_prefix() {
        let tmp = TempDir::new().unwrap();
        let p = tmp.path().join("platform-depends");
        fs::write(&p, "node-app-\nnode-app-real\n").unwrap();
        assert_eq!(parse_platform_depends(&p).unwrap(), vec!["real"]);
    }

    #[test]
    fn parse_missing_file_errors() {
        let tmp = TempDir::new().unwrap();
        let p = tmp.path().join("nope");
        assert!(parse_platform_depends(&p).is_err());
    }
}
