123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
// Self-enforcing rails for adom-chip-fetcher: heartbeat the dashboard, check that
// state is healthy before the next pup action, and emit `HINT:` lines that
// the calling AI reads back into context — so the binary itself becomes the
// source of truth for "what to do next" instead of relying on skill prose.

use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

const HEARTBEAT_PATH: &str = "/tmp/adom-chip-fetcher-last-heartbeat.json";
const STALE_AFTER_SECS: u64 = 60;
const DEFAULT_PORT: u16 = 8786;
const DASHBOARD_SURFACE_PATH: &str = ".config/adom-chip-fetcher/dashboard.json";

/// Canonical sourcing-ladder priority. Single source of truth — `adom-chip-fetcher
/// sources` and the playbook prose both read from here. **Always traverse in
/// order; never skip to LCSC.** Direct user rule (2026-05-06): *"after Mouser
/// you should do digikey, and then only after that try LCSC. I really want to
/// avoid LCSC at all costs, but it is in the list if we're desperate."*
pub const SOURCE_LADDER: &[(&str, &str)] = &[
    ("adom-wiki",    "wiki.adom.inc — reuse an already-published, Adom-vetted CAD bundle. TRY FIRST: if the part exists here we skip the external vendors entirely (free, fast, Adom-consistent geometry + provenance)."),
    ("manufacturer", "vendor.com — STEP, datasheet, lifecycle. ALWAYS try first, even when confident."),
    ("snapmagic",    "snapeda.com — free CAD bundles, login-gated. Right after manufacturer."),
    ("mouser",       "mouser.com — stock + pricing always; sometimes ECAD Models / CSE deep-links."),
    ("digikey",      "digikey.com — broader catalog; STEP + datasheet on every product page."),
    ("arrow",        "arrow.com — niche distributor."),
    ("cse_ul",       "componentsearchengine.com / ultralibrarian.com — only when mfr links there."),
    ("lcsc",         "datasheet.lcsc.com / lcsc.com — LAST RESORT. Avoid if possible; tell user we couldn't find it on the manufacturer's site instead."),
];

/// Where the user wants to see the dashboard. Pup is the default — adom-chip-fetcher
/// needs pup anyway to drive vendor sites, so the dashboard tagging along is
/// the lowest-friction path. Hydrogen webview is the alternative for users on
/// big monitors who prefer the workspace-integrated panel.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum DashboardSurface {
    Pup,
    Webview,
}

impl DashboardSurface {
    fn parse(s: &str) -> Self {
        match s.trim().to_lowercase().as_str() {
            "webview" | "hydrogen" => Self::Webview,
            _ => Self::Pup,
        }
    }
    fn as_str(&self) -> &'static str {
        match self { Self::Pup => "pup", Self::Webview => "webview" }
    }
}

#[derive(Serialize, Deserialize, Clone)]
struct DashboardConfig { surface: String }

fn dashboard_config_path() -> Option<std::path::PathBuf> {
    let home = std::env::var("HOME").ok()?;
    Some(std::path::PathBuf::from(home).join(DASHBOARD_SURFACE_PATH))
}

pub fn get_dashboard_surface() -> DashboardSurface {
    let path = match dashboard_config_path() { Some(p) => p, None => return DashboardSurface::Pup };
    match std::fs::read_to_string(&path) {
        Ok(s) => match serde_json::from_str::<DashboardConfig>(&s) {
            Ok(c) => DashboardSurface::parse(&c.surface),
            Err(_) => DashboardSurface::Pup,
        },
        Err(_) => DashboardSurface::Pup,
    }
}

pub fn set_dashboard_surface(surface: DashboardSurface) -> Result<()> {
    let path = dashboard_config_path()
        .ok_or_else(|| anyhow::anyhow!("HOME not set"))?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let cfg = DashboardConfig { surface: surface.as_str().to_string() };
    std::fs::write(&path, serde_json::to_string_pretty(&cfg)?)?;
    println!("OK: dashboard surface set to {:?} ({})", surface, path.display());
    Ok(())
}

#[derive(Serialize, Deserialize, Clone)]
pub struct Heartbeat {
    pub mpn: String,
    pub stage: String,
    pub ts: u64,
}

/// First-heartbeat stub: create an empty `library/<MPN>/info.json` so the
/// dashboard's `inventory::scan` walks find the new chip and render an empty
/// card the user can scroll to while we work. No-op if `info.json` already
/// exists (we don't want to clobber a partial fetch's data). Returns `true`
/// when a new stub was created (i.e. this is the very first heartbeat for
/// this MPN), so the caller can surface the manufacturer-first hint.
fn ensure_stub_card(mpn: &str, stage: &str) -> Result<bool> {
    if mpn.is_empty() || mpn == "ZZZ" {
        return Ok(false);
    }
    let dir = crate::library::root().join(mpn);
    let info_path = dir.join("info.json");
    if info_path.exists() {
        return Ok(false);
    }
    std::fs::create_dir_all(&dir)?;
    let info = serde_json::json!({
        "mpn": mpn,
        "state": "in_progress",
        "stage": stage,
        "started_at": now_secs(),
    });
    std::fs::write(info_path, serde_json::to_string_pretty(&info)?)?;
    Ok(true)
}

fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

pub fn heartbeat(mpn: &str, stage: &str) -> Result<()> {
    let hb = Heartbeat {
        mpn: mpn.to_string(),
        stage: stage.to_string(),
        ts: now_secs(),
    };
    let body = serde_json::to_string(&hb)?;
    let _ = std::fs::write(HEARTBEAT_PATH, &body);

    // Stub-card on first heartbeat: if the chip has no library/<MPN>/ directory
    // yet, create one with an in-progress info.json so the dashboard renders an
    // empty placeholder card immediately. Without this, the user has nothing
    // to scroll to during a fresh fetch — the chip is invisible until the
    // first file lands.
    let was_new = ensure_stub_card(mpn, stage).unwrap_or(false);

    let url = format!("http://127.0.0.1:{DEFAULT_PORT}/api/activity");
    let dashboard_ok = Command::new("curl")
        .args([
            "-fs", "-m", "2", "-X", "POST", &url,
            "-H", "Content-Type: application/json",
            "-d", &body,
        ])
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false);

    println!("OK: heartbeat {mpn} → {stage:?}");
    if !dashboard_ok {
        println!("WARN: dashboard unreachable at {url}. Heartbeat cached locally.");
        print_hint("start the dashboard with `adom-chip-fetcher app` so users can see live progress");
    }

    // Auto-scroll the MPN's dashboard card into view in the adom-chip-fetcher pup
    // session. This is the rule John asked for — the binary enforces "scroll
    // the active card into focus" automatically rather than relying on the AI
    // to remember it on every heartbeat. No-op if pup isn't running.
    match auto_scroll_card(mpn) {
        Ok(true)  => println!("OK: scrolled card {mpn} into focus on dashboard"),
        Ok(false) => {} // pup not up, or dashboard surface is webview — silently skip
        Err(e)    => println!("WARN: could not scroll card {mpn} into focus: {e}"),
    }

    // First heartbeat for this MPN: print the sourcing-ladder reminder so the
    // AI doesn't skip ahead to LCSC. This is "rules made programmatic" —
    // every fresh fetch sees the canonical order in stdout, not buried in a
    // skill file the AI may not have re-read this turn.
    if was_new {
        println!();
        print_hint(&format!("new stub created for {mpn}. ALWAYS start at the manufacturer's site, then walk the ladder IN ORDER (no skipping). See `adom-chip-fetcher sources` for the full priority list."));
        print_hint("Order: Mfr → SnapMagic → Mouser → DigiKey → Arrow → CSE/UL → LCSC. LCSC is LAST RESORT — surface 'no manufacturer CAD' to user before reaching for it.");
    }

    // Card-completeness check on every heartbeat. The AI must not claim "done"
    // while the active card is missing core CAD. Surface what's missing now,
    // and also count how many other cards in the library are still stubs so
    // the AI can't quietly walk away from a half-finished batch.
    let _ = print_completeness_warnings(mpn);

    // Auto-record source attempts in info.json.fetched_via_chain. The AI
    // doesn't have to remember to write the chain; the binary parses the
    // stage label and logs it. `adom-chip-fetcher next-source <MPN>` then knows
    // what's been tried.
    if let Some((source, outcome)) = crate::sourcing::detect_source_attempt(stage) {
        let added = crate::sourcing::append_chain(mpn, source, &outcome, Some(stage)).unwrap_or(false);
        if added {
            println!("OK: chain {} → {} recorded for {mpn} (run `adom-chip-fetcher next-source {mpn}` to see what's next)", source.as_slug(), outcome);
        }
    }

    // Stage-order state machine. Warn if the AI seems to skip a phase
    // (e.g. heartbeats "imported" without ever heartbeating "downloading").
    let _ = check_stage_order(mpn, stage);
    Ok(())
}

/// A QUIET heartbeat: refresh the dashboard's activity timestamp + stage only.
/// No stdout, no stub creation, no auto-scroll, no chain/stage bookkeeping.
/// This is what the background ticker fires every few seconds so the "working
/// on" bar stays LIVE during a long blocking op — without spamming the log or
/// re-running the heavy per-heartbeat side-effects.
pub fn heartbeat_quiet(mpn: &str, stage: &str) {
    let hb = Heartbeat { mpn: mpn.to_string(), stage: stage.to_string(), ts: now_secs() };
    if let Ok(body) = serde_json::to_string(&hb) {
        let _ = std::fs::write(HEARTBEAT_PATH, &body);
        let url = format!("http://127.0.0.1:{DEFAULT_PORT}/api/activity");
        let _ = Command::new("curl")
            .args(["-fs", "-m", "2", "-X", "POST", &url, "-H", "Content-Type: application/json", "-d", &body])
            .output();
    }
}

/// Keeps the dashboard "working on" bar LIVE during a long blocking call (a 40s
/// browser XHR, a slow import). Emits one loud heartbeat up front, then a quiet
/// one every ~6s on a background thread until stopped (or dropped). Without this
/// the bar goes stale after ACTIVITY_STALE_SECS mid-operation and the user sees
/// no live status — exactly John's complaint.
pub struct HeartbeatTicker {
    stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
    handle: Option<std::thread::JoinHandle<()>>,
}

impl HeartbeatTicker {
    pub fn start(mpn: &str, stage: &str) -> Self {
        use std::sync::atomic::Ordering;
        let _ = heartbeat(mpn, stage); // one loud heartbeat (stub card, scroll, log)
        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
        let (mpn, stage, s2) = (mpn.to_string(), stage.to_string(), stop.clone());
        let handle = std::thread::spawn(move || {
            let mut elapsed_ms = 0u64;
            while !s2.load(Ordering::Relaxed) {
                std::thread::sleep(std::time::Duration::from_millis(500));
                elapsed_ms += 500;
                if elapsed_ms >= 6000 {
                    elapsed_ms = 0;
                    if !s2.load(Ordering::Relaxed) {
                        heartbeat_quiet(&mpn, &stage);
                    }
                }
            }
        });
        HeartbeatTicker { stop, handle: Some(handle) }
    }

    pub fn stop(mut self) { self.shutdown(); }

    fn shutdown(&mut self) {
        self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
        if let Some(h) = self.handle.take() { let _ = h.join(); }
    }
}

impl Drop for HeartbeatTicker {
    fn drop(&mut self) { self.shutdown(); }
}

/// Track stage-order per MPN in /tmp/adom-chip-fetcher-stage-<MPN>.json. Warn
/// when the AI heartbeats a later phase (e.g. 5=importing) without having
/// hit the prior phase (3=downloading). Doesn't block — just nudges.
fn check_stage_order(mpn: &str, stage: &str) -> Result<()> {
    let phase = match crate::sourcing::stage_phase(stage) {
        Some(p) => p,
        None => return Ok(()),
    };
    let path = std::path::PathBuf::from(format!("/tmp/adom-chip-fetcher-stage-{mpn}.json"));
    // Did we ever record a phase before? Distinguish "no prev recorded"
    // from "prev was phase 0 (queued)" — both are u8 0 but only one is
    // a legitimate prior state.
    let had_prev = path.exists();
    let prev: u8 = if had_prev {
        std::fs::read_to_string(&path)
            .ok()
            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
            .and_then(|v| v.get("phase").and_then(|p| p.as_u64()).map(|n| n as u8))
            .unwrap_or(0)
    } else {
        0
    };

    if had_prev && phase > prev + 1 {
        let phase_names = ["queued", "probing", "found", "downloading", "pulling", "importing", "imported"];
        let prev_name = phase_names.get(prev as usize).copied().unwrap_or("?");
        let cur_name  = phase_names.get(phase as usize).copied().unwrap_or("?");
        println!("WARN: stage jumped {prev}→{phase} for {mpn} ({prev_name}→{cur_name}) — skipped intermediate stages. Did the file actually land? Did you heartbeat \"downloading\" / \"pulling\" / \"importing\"?");
    }

    let _ = std::fs::write(&path, serde_json::json!({"phase": phase, "stage": stage}).to_string());
    Ok(())
}

/// Walk the active chip's directory and print explicit `WARN:` + `HINT:` lines
/// for each missing core artifact. Also counts how many OTHER chips in the
/// library are incomplete so the AI sees the batch picture, not just one chip.
/// Pure check; never errors. The deterministic gate is `adom-chip-fetcher gate` —
/// this is the inline version that fires on every heartbeat.
fn print_completeness_warnings(mpn: &str) -> Result<()> {
    let lib = match crate::inventory::scan() {
        Ok(l) => l,
        Err(_) => return Ok(()),
    };
    let active = lib.chips.iter().find(|c| c.mpn == mpn);
    let total = lib.chips.len();
    let incomplete = lib.chips.iter()
        .filter(|c| !(c.has_pdf && c.has_step && c.has_sym && c.has_mod))
        .count();

    if let Some(c) = active {
        let mut missing: Vec<&'static str> = Vec::new();
        if !c.has_pdf  { missing.push("datasheet.pdf"); }
        if !c.has_step { missing.push("STEP"); }
        if !c.has_sym  { missing.push("KiCad .kicad_sym"); }
        if !c.has_mod  { missing.push("KiCad .kicad_mod"); }
        if !missing.is_empty() {
            println!();
            println!("WARN: card {mpn} is INCOMPLETE — missing: {}", missing.join(", "));
            print_hint(&format!("do NOT claim done on {mpn}. Walk the ladder for each missing piece. `adom-chip-fetcher gate` for full status."));
        }
    }
    if incomplete > 0 {
        println!("WARN: {incomplete}/{total} cards in library are still incomplete (run `adom-chip-fetcher gate` to see all).");
    }
    Ok(())
}

/// Scroll the dashboard pup window so the active MPN's card is centered in
/// view, with a brief outline highlight so the user's eye lands on it. Returns
/// `Ok(true)` when the scroll was issued, `Ok(false)` when pup isn't the
/// active surface (no error, no work needed), `Err` only on adom-desktop
/// failure.
fn auto_scroll_card(mpn: &str) -> Result<bool> {
    let safe_mpn = mpn.replace('"', "");
    let expr = format!(
        "(()=>{{const m={:?};const c=document.querySelector(`[data-mpn=\"${{m}}\"]`);\
         if(c){{c.scrollIntoView({{behavior:'smooth',block:'center'}});\
         c.style.outline='3px solid #5cf';\
         setTimeout(()=>c.style.outline='',2500);return 'card';}}\
         const b=document.querySelector('.activity-banner,.heartbeat-banner,[data-activity]');\
         if(b){{b.scrollIntoView({{behavior:'smooth',block:'start'}});return 'banner';}}\
         window.scrollTo({{top:0,behavior:'smooth'}});return 'top';}})()",
        safe_mpn
    );
    let body = serde_json::json!({
        "sessionId": "adom-chip-fetcher-dashboard",
        "expr": expr,
    }).to_string();
    let out = Command::new("adom-desktop")
        .args(["browser_eval", &body])
        .output()?;
    Ok(out.status.success())
}

pub fn last() -> Option<Heartbeat> {
    let s = std::fs::read_to_string(HEARTBEAT_PATH).ok()?;
    serde_json::from_str(&s).ok()
}

pub fn age_secs(hb: &Heartbeat) -> u64 {
    now_secs().saturating_sub(hb.ts)
}

/// Make sure the dashboard is up AND visible to the user. Idempotent —
/// running this every time is the point. Three checks, each with a fix:
///   1. HTTP server reachable on $DEFAULT_PORT? If not, spawn `adom-chip-fetcher
///      serve` detached.
///   2. Pup window with sessionId "adom-chip-fetcher-dashboard" pointing at the dashboard?
///      If not, open one (or open Hydrogen tab, per surface preference).
///   3. (best-effort) Foreground the pup window so the user sees it.
///
/// `force_open` skips check (2)'s "already open" path and re-opens — useful
/// when the user explicitly wants the dashboard front-and-center.
pub fn dashboard_ensure(force_open: bool) -> Result<()> {
    // Step 1 — HTTP server
    if !dashboard_reachable() {
        println!("WARN: dashboard server not running; starting it...");
        let exe = std::env::current_exe()
            .unwrap_or_else(|_| std::path::PathBuf::from("adom-chip-fetcher"));
        // Detach: own session, stdout/stderr to a log file, parent doesn't wait.
        let log = std::fs::OpenOptions::new()
            .create(true).append(true)
            .open("/tmp/cf-dashboard.log")
            .unwrap_or_else(|_| std::fs::File::create("/dev/null").unwrap());
        let log_err = log.try_clone().unwrap();
        let _ = std::process::Command::new(exe)
            .args(["serve", "--port", &DEFAULT_PORT.to_string()])
            .stdout(log)
            .stderr(log_err)
            .stdin(std::process::Stdio::null())
            .spawn()
            .map_err(|e| anyhow::anyhow!("spawn serve: {e}"))?;
        // Give it a moment to bind.
        for _ in 0..20 {
            std::thread::sleep(std::time::Duration::from_millis(150));
            if dashboard_reachable() { break; }
        }
        if dashboard_reachable() {
            println!("OK: dashboard server up on http://127.0.0.1:{DEFAULT_PORT}/");
        } else {
            anyhow::bail!("dashboard server failed to start within 3s — see /tmp/cf-dashboard.log");
        }
    } else {
        println!("OK: dashboard server already running on :{DEFAULT_PORT}");
    }

    // Step 2 — visible surface
    let surface = get_dashboard_surface();
    match surface {
        DashboardSurface::Pup => ensure_pup_dashboard_window(force_open)?,
        DashboardSurface::Webview => ensure_hydrogen_dashboard_tab(force_open)?,
    }
    Ok(())
}

fn dashboard_proxy_url() -> String {
    // The dashboard pup window runs on the user's Windows desktop — it can't
    // reach 127.0.0.1 on the Docker container. Use the Adom cloud proxy URL
    // so the pup browser can access the dashboard through HTTPS.
    if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {
        format!("https://{host}/proxy/{DEFAULT_PORT}/")
    } else if let Ok(slug) = std::env::var("ADOM_CONTAINER_SLUG") {
        format!("https://{slug}.adom.cloud/proxy/{DEFAULT_PORT}/")
    } else if let Ok(tmpl) = std::env::var("VSCODE_PROXY_URI") {
        tmpl.replace("{{port}}", &DEFAULT_PORT.to_string())
    } else {
        format!("http://127.0.0.1:{DEFAULT_PORT}/")
    }
}

fn ensure_pup_dashboard_window(force_open: bool) -> Result<()> {
    // Native browser: the dashboard is TAB 1 of the single "adom-chip-fetcher" window
    // (scrape sites are tabs beside it). Never spawn a second window — that's
    // the "why do I have a native browser AND a pup?" bug.
    if crate::pup::is_native() {
        let url = dashboard_proxy_url();
        if !force_open && crate::pup::session_has_url("adom-chip-fetcher", &format!("/proxy/{DEFAULT_PORT}/")) {
            println!("OK: dashboard is tab 1 of the adom-chip-fetcher window (native browser)");
            return Ok(());
        }
        crate::pup::open_window("adom-chip-fetcher", "adom-chip-fetcher", &url)?;
        println!("OK: opened the adom-chip-fetcher dashboard as tab 1 of your native browser window");
        return Ok(());
    }
    let already_open = pup_session_open("adom-chip-fetcher-dashboard").unwrap_or(false);
    if already_open && !force_open {
        println!("OK: dashboard pup window 'adom-chip-fetcher-dashboard' already open");
        let _ = Command::new("adom-desktop")
            .args(["browser_focus_window", "{\"sessionId\":\"adom-chip-fetcher-dashboard\"}"])
            .output();
        return Ok(());
    }
    let url = dashboard_proxy_url();
    let body = serde_json::json!({
        "sessionId": "adom-chip-fetcher-dashboard",
        "profile": "adom-chip-fetcher",
        "url": url,
    }).to_string();
    let out = Command::new("adom-desktop")
        .args(["browser_open_window", &body])
        .output();
    match out {
        Ok(o) if o.status.success() => {
            println!("OK: opened dashboard in pup at {}", dashboard_proxy_url());
            let _ = Command::new("adom-desktop")
                .args(["browser_focus_window", "{\"sessionId\":\"adom-chip-fetcher-dashboard\"}"])
                .output();
            Ok(())
        }
        Ok(o) => {
            let stdout = String::from_utf8_lossy(&o.stdout).to_string();
            let stderr = String::from_utf8_lossy(&o.stderr).to_string();
            let detail = if stdout.is_empty() { stderr } else { stdout };
            anyhow::bail!("browser_open_window failed: {detail}")
        }
        Err(e) => anyhow::bail!("could not run adom-desktop: {e}"),
    }
}

fn ensure_hydrogen_dashboard_tab(_force_open: bool) -> Result<()> {
    let url = format!("http://127.0.0.1:{DEFAULT_PORT}/");
    // adom-cli is the canonical Hydrogen-tab driver. If it's missing, fall
    // back to pup so the user still sees the dashboard.
    let status = Command::new("adom-cli")
        .args(["workspace", "tab-open", "--url", &url, "--title", "adom-chip-fetcher"])
        .status();
    match status {
        Ok(s) if s.success() => {
            println!("OK: opened dashboard in Hydrogen webview tab");
            Ok(())
        }
        _ => {
            println!("WARN: adom-cli workspace tab-open failed — falling back to pup");
            ensure_pup_dashboard_window(false)
        }
    }
}

fn dashboard_reachable() -> bool {
    let url = format!("http://127.0.0.1:{DEFAULT_PORT}/api/library");
    Command::new("curl")
        .args(["-fs", "-m", "2", "-o", "/dev/null", &url])
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

fn pup_session_open(name: &str) -> Option<bool> {
    if list_contains_session(name)? {
        return Some(true);
    }
    // First lookup miss: maybe the bridge dropped + reconnected and lost the
    // session reference even though Chrome is still alive (the v1.5.0 bug).
    // v1.5.1's browser_rescan rebuilds the session map from the actual
    // Chrome targets — call it once before declaring the session gone.
    let rescan = Command::new("adom-desktop")
        .args(["browser_rescan", "{}"])
        .output()
        .ok()?;
    if !rescan.status.success() { return Some(false); }
    let r: serde_json::Value = serde_json::from_slice(&rescan.stdout).ok()?;
    let recovered = r.get("liveSessions")
        .and_then(|l| l.as_array())
        .map(|arr| arr.iter().any(|v| v.as_str() == Some(name)))
        .unwrap_or(false);
    if recovered {
        println!("OK: browser_rescan recovered session {name:?}");
        return Some(true);
    }
    Some(false)
}

fn list_contains_session(name: &str) -> Option<bool> {
    let out = Command::new("adom-desktop")
        .args(["browser_list_windows", "{}"])
        .output()
        .ok()?;
    let s = String::from_utf8_lossy(&out.stdout);
    let compact = format!("\"sessionId\":\"{name}\"");
    let spaced = format!("\"sessionId\": \"{name}\"");
    Some(s.contains(&compact) || s.contains(&spaced))
}

pub fn selfcheck() -> Result<()> {
    // Dashboard rail: auto-(re)open if missing rather than just complaining.
    // The user shouldn't have to remember "is the dashboard up?" — that's
    // the binary's job.
    if let Err(e) = dashboard_ensure(false) {
        println!("WARN: dashboard_ensure: {e}");
    }

    let mut errs: Vec<String> = vec![];
    let mut warns: Vec<String> = vec![];

    if dashboard_reachable() {
        println!("OK: dashboard reachable at http://127.0.0.1:{DEFAULT_PORT}/");
    } else {
        errs.push(format!(
            "dashboard NOT reachable at http://127.0.0.1:{DEFAULT_PORT}/"
        ));
    }

    match last() {
        Some(hb) => {
            let age = age_secs(&hb);
            if age <= STALE_AFTER_SECS {
                println!(
                    "OK: heartbeat {age}s old — {} → {:?}",
                    hb.mpn, hb.stage
                );
            } else {
                warns.push(format!(
                    "heartbeat is {age}s old (stale-after = {STALE_AFTER_SECS}s) — last was {} → {:?}",
                    hb.mpn, hb.stage
                ));
            }
        }
        None => warns.push(
            "no heartbeat on file — the dashboard hasn't seen any activity from this session".into(),
        ),
    }

    match pup_session_open("adom-chip-fetcher") {
        Some(true) => println!("OK: pup session 'adom-chip-fetcher' is open"),
        Some(false) => warns.push(
            "pup session 'adom-chip-fetcher' is NOT open — fetches need this exact sessionId".into(),
        ),
        None => warns.push("could not query adom-desktop browser_list_windows".into()),
    }

    if !errs.is_empty() {
        println!();
        for e in &errs {
            println!("ERROR: {e}");
        }
    }
    if !warns.is_empty() {
        println!();
        for w in &warns {
            println!("WARN: {w}");
        }
    }

    println!();
    if errs.is_empty() && warns.is_empty() {
        print_hint("all checks passed — continue with your fetch. Heartbeat each stage transition with `adom-chip-fetcher heartbeat <MPN> \"<stage>\"` before any pup action.");
    } else {
        if !dashboard_reachable() {
            print_hint("start the dashboard FIRST: `adom-chip-fetcher app` (Hydrogen tab) or `adom-chip-fetcher serve` (HTTP only) — every other rule depends on the dashboard being up.");
        }
        match last() {
            None => print_hint("send your first heartbeat: `adom-chip-fetcher heartbeat <MPN> \"starting fetch\"`"),
            Some(hb) if age_secs(&hb) > STALE_AFTER_SECS => print_hint(&format!(
                "heartbeat is stale — refresh with `adom-chip-fetcher heartbeat {} \"<current stage>\"`",
                hb.mpn
            )),
            _ => {}
        }
        if matches!(pup_session_open("adom-chip-fetcher"), Some(false)) {
            print_hint("open the pup window before fetching: navigate to the vendor URL with `sessionId:\"adom-chip-fetcher\", profile:\"adom-chip-fetcher\"`");
        }
    }

    if !errs.is_empty() {
        anyhow::bail!("selfcheck failed with {} error(s)", errs.len());
    }
    Ok(())
}

pub fn print_hint(line: &str) {
    println!("HINT: {line}");
}

/// Emit a "what to do next" line based on current state. Used as the tail of
/// every subcommand's stdout — the AI reads this back into context and the
/// next action becomes mechanical instead of remembered.
pub fn print_next_after(stage: &str, mpn: Option<&str>) {
    match (stage, mpn) {
        ("fetched", Some(m)) => {
            print_hint(&format!("heartbeat the result: `adom-chip-fetcher heartbeat {m} imported`"));
            print_hint(&format!("verify on disk: `adom-chip-fetcher status {m}`"));
        }
        ("imported", Some(m)) => {
            print_hint(&format!("heartbeat: `adom-chip-fetcher heartbeat {m} imported`"));
            print_hint(&format!("dashboard view: `adom-chip-fetcher status {m}`"));
        }
        ("pulled", Some(m)) => {
            print_hint(&format!("heartbeat: `adom-chip-fetcher heartbeat {m} \"pulled to incoming\"`"));
            print_hint("import next: `adom-chip-fetcher import <path> --mpn <MPN>`");
        }
        ("listed", _) => {
            print_hint("inspect one: `adom-chip-fetcher status <MPN>`");
            print_hint("fetch a missing one: `adom-chip-fetcher fetch <MPN>`");
        }
        ("status", Some(m)) => {
            print_hint(&format!("if anything is missing, fetch with `adom-chip-fetcher fetch {m}`"));
            print_hint(&format!("heartbeat any user-visible action with `adom-chip-fetcher heartbeat {m} \"<stage>\"`"));
        }
        ("login", _) => {
            print_hint("after sign-in, heartbeat: `adom-chip-fetcher heartbeat <MPN> \"logged in to <source>\"`");
            print_hint("verify rails: `adom-chip-fetcher selfcheck`");
        }
        _ => {}
    }
}