1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021
mod describe;
mod image;
mod open;
mod server;
mod store;

use clap::{CommandFactory, Parser, Subcommand};
use std::path::PathBuf;
use std::sync::Arc;

fn ok(msg: &str) { println!("OK: {msg}"); }
fn err(msg: &str) { eprintln!("ERROR: {msg}"); }
fn warn(msg: &str) { eprintln!("WARN: {msg}"); }
fn hint(msg: &str) { eprintln!("Hint: {msg}"); }

/// De-duplicate viewer surfaces into a compact label, e.g. ["pup","pup","mobile"] -> "pup ×2, mobile".
fn surface_label(surfaces: &[String]) -> String {
    let mut counts: Vec<(String, usize)> = Vec::new();
    for s in surfaces {
        if let Some(e) = counts.iter_mut().find(|(k, _)| k == s) { e.1 += 1; }
        else { counts.push((s.clone(), 1)); }
    }
    counts.iter()
        .map(|(k, n)| if *n > 1 { format!("{k} ×{n}") } else { k.clone() })
        .collect::<Vec<_>>()
        .join(", ")
}

const SKILL_MD: &str = include_str!("../SKILL.md");
const READ_HOOK_SH: &str = include_str!("../hooks/shotlog-on-read.sh");

/// Install/verify the shipped PostToolUse(Read) hook: the hook FILE and its
/// REGISTRATION in ~/.claude/settings.json. Runs at `install` and at every
/// `serve` startup, so the auto-capture pipeline can never silently rot.
/// Surgical + idempotent: never touches other hooks; backs up settings once
/// per change to settings.json.shotlog-bak.
fn ensure_read_hook() {
    let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());
    let dir = format!("{home}/.claude/hooks");
    let path = format!("{dir}/shotlog-on-read.sh");
    let _ = std::fs::create_dir_all(&dir);
    let mut file_changed = false;
    if std::fs::read_to_string(&path).unwrap_or_default() != READ_HOOK_SH {
        if std::fs::write(&path, READ_HOOK_SH).is_ok() {
            use std::os::unix::fs::PermissionsExt;
            let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755));
            file_changed = true;
        }
    }

    let sp = format!("{home}/.claude/settings.json");
    let raw = std::fs::read_to_string(&sp).unwrap_or_else(|_| "{}".to_string());
    let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&raw) else {
        warn("read-hook: ~/.claude/settings.json is not valid JSON - hook file installed but NOT registered.");
        return;
    };
    let mut reg_changed = false;
    let mut registered = false;
    if let Some(obj) = v.as_object_mut() {
        let hooks = obj.entry("hooks").or_insert_with(|| serde_json::json!({}));
        if let Some(hobj) = hooks.as_object_mut() {
            let ptu = hobj.entry("PostToolUse").or_insert_with(|| serde_json::json!([]));
            if let Some(arr) = ptu.as_array_mut() {
                let mut found_read_group = false;
                for g in arr.iter_mut() {
                    if g.get("matcher").and_then(|m| m.as_str()) == Some("Read") {
                        found_read_group = true;
                        if let Some(gobj) = g.as_object_mut() {
                            let hs = gobj.entry("hooks").or_insert_with(|| serde_json::json!([]));
                            if let Some(list) = hs.as_array_mut() {
                                if list.iter().any(|h| h.get("command").and_then(|c| c.as_str())
                                    .map(|c| c.contains("shotlog-on-read.sh")).unwrap_or(false)) {
                                    registered = true;
                                } else {
                                    list.push(serde_json::json!({"type": "command", "command": path}));
                                    registered = true;
                                    reg_changed = true;
                                }
                            }
                        }
                        break;
                    }
                }
                if !found_read_group {
                    arr.push(serde_json::json!({"matcher": "Read",
                        "hooks": [{"type": "command", "command": path}]}));
                    registered = true;
                    reg_changed = true;
                }
            }
        }
    }
    if reg_changed {
        let _ = std::fs::write(format!("{sp}.shotlog-bak"), &raw);
        if let Ok(pretty) = serde_json::to_string_pretty(&v) {
            let _ = std::fs::write(&sp, pretty);
        }
    }
    match (file_changed || reg_changed, registered) {
        (true, _) => ok("read-hook installed/updated: every image any AI thread Reads is auto-captured to the 'read-captures' channel (PostToolUse hook + settings registration)."),
        (false, true) => ok("read-hook verified: auto-capture of every image any AI Reads is active."),
        (false, false) => warn("read-hook file present but could not verify registration in settings.json."),
    }
}

#[derive(Parser)]
#[command(name = "adom-shotlog", version, about = "Screenshot log viewer and CLI")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Start the shotlog HTTP server
    Serve {
        /// Port to listen on
        #[arg(short, long, default_value_t = 8820)]
        port: u16,
        /// Data directory for screenshot storage
        #[arg(short, long, default_value = "./screenshots/shotlog")]
        data_dir: PathBuf,
    },
    /// Inject a screenshot into the log (posts to running server).
    ///
    /// shotlog must always know the ORIGINAL capture resolution. Pass a full-res
    /// image (shotlog resizes >1500px itself and records the original), or for a
    /// pre-shrunk image declare the original via --orig-file / --orig-w+--orig-h,
    /// or assert it was never resized with --native. An inject that establishes
    /// none of these is REJECTED — so the viewer never has to show "orig missing".
    Inject {
        /// Channel name
        #[arg(short, long)]
        channel: String,
        /// Description of why this screenshot was taken (becomes the filename)
        #[arg(short, long)]
        desc: Option<String>,
        /// Source identifier (e.g., av_capture, pup_screenshot)
        #[arg(short, long, default_value = "cli")]
        source: String,
        /// Port of the running shotlog server
        #[arg(short, long, default_value_t = 8820)]
        port: u16,
        /// Path to the ORIGINAL (pre-shrink) image — shotlog reads its
        /// dimensions and records them. Use this when FILE is already resized.
        #[arg(long)]
        orig_file: Option<String>,
        /// Original width before your resize (alternative to --orig-file)
        #[arg(long)]
        orig_w: Option<u32>,
        /// Original height before your resize (alternative to --orig-file)
        #[arg(long)]
        orig_h: Option<u32>,
        /// Assert FILE is at original capture resolution (never resized). Use
        /// only when true — it records the original as the image's own size.
        #[arg(long)]
        native: bool,
        /// Path to an Adom Desktop screenshot-response JSON (from
        /// desktop_screenshot_window / _screen). shotlog reads coordMap + DPI +
        /// origWidth/Height + the screenshots[] CHILD WINDOWS (owned popups —
        /// dropdowns/dialogs of the parent window, by localSafePath) and stores
        /// them on this entry: the child windows become viewable thumbnails and
        /// the coordMap/screen metadata a hover panel.
        #[arg(long)]
        meta: Option<String>,
        /// Which AI thread produced this shot (human-readable title). The
        /// read-hook fills this automatically from the session transcript.
        #[arg(long)]
        thread: Option<String>,
        /// WHY the shot exists: the user prompt being served (the read-hook
        /// fills this automatically; pass your task summary when injecting).
        #[arg(long)]
        context: Option<String>,
        /// PNG file path (use - for stdin)
        file: String,
    },
    /// Open a shotlog webview tab in the Adom workspace
    Open {
        /// Channel name
        #[arg(short, long)]
        channel: String,
        /// Display name for the tab
        #[arg(short, long)]
        name: Option<String>,
        /// MDI icon for the tab
        #[arg(short, long)]
        icon: Option<String>,
        /// Port of the running shotlog server
        #[arg(short, long, default_value_t = 8820)]
        port: u16,
        /// Open in a slim right-side panel (the preferred shotlog layout): a
        /// ~20%-wide auto-updating column. Extra `open --skinny` calls stack
        /// their tabs in the same panel (they auto-switch/blink on update).
        #[arg(long)]
        skinny: bool,
        /// Width of the skinny panel as a fraction of the workspace. Default
        /// comes from the broker's webview_width preference (20% unless changed).
        #[arg(long)]
        ratio: Option<f64>,
        /// Explicitly open the WEBVIEW canvas (same as --skinny). Without any
        /// canvas flag, `open` follows the human's default_canvas preference.
        #[arg(long)]
        webview: bool,
        /// Open in the FULL-BROWSER pup canvas instead: one shared pup window
        /// ('shotlog') with a tab per channel. Real zoom at near-full screen,
        /// and the broker flashes the window's taskbar orange on every update.
        #[arg(long)]
        pup: bool,
        /// SWAP canvases: with --pup, also close the channel's webview tab;
        /// without --pup, also close the channel's pup tab. One surface at a time.
        #[arg(long)]
        swap: bool,
    },
    /// Resize (if needed) and inject a screenshot (like inject but applies 1400px max-width)
    Paste {
        /// Channel name
        #[arg(short, long)]
        channel: String,
        /// Description (becomes the filename)
        #[arg(short, long)]
        desc: Option<String>,
        /// Source identifier
        #[arg(short, long, default_value = "cli")]
        source: String,
        /// Port of the running shotlog server
        #[arg(short, long, default_value_t = 8820)]
        port: u16,
        /// PNG file path (use - for stdin)
        file: String,
    },
    /// Resize an image to max 1400px wide with maximum PNG compression (for AI submission)
    Resize {
        /// Input PNG file
        file: String,
        /// Output file (default: overwrites input)
        #[arg(short, long)]
        output: Option<String>,
        /// Max width in pixels
        #[arg(short, long, default_value_t = 1400)]
        width: u32,
    },
    /// Check if the shotlog server is running
    Health {
        /// Port of the running shotlog server
        #[arg(short, long, default_value_t = 8820)]
        port: u16,
    },
    /// Verify a HUMAN is actually watching a channel's viewer (live WebSocket
    /// presence + Page Visibility). Exit 0 = ≥1 viewer ON-SCREEN, 4 = viewer(s)
    /// connected but ALL backgrounded/minimized, 3 = nobody connected — so an AI
    /// can gate precisely without guessing. Prints the surfaces (webview / pup /
    /// mobile / browser).
    Viewers {
        /// Channel name
        #[arg(short, long)]
        channel: String,
        /// Port of the running shotlog server
        #[arg(short, long, default_value_t = 8820)]
        port: u16,
        /// Optional viewer endpoint the AI opened (e.g. a pup/proxy URL) —
        /// shotlog HTTP-GETs it and reports whether it's reachable, on top of
        /// the authoritative WebSocket presence check.
        #[arg(long)]
        url: Option<String>,
    },
    /// Overview of ALL channels — shot counts, last activity, source, and who's
    /// watching. See how other AI threads are using shotlog on an ongoing basis.
    Channels {
        /// Port of the running shotlog server
        #[arg(short, long, default_value_t = 8820)]
        port: u16,
        /// Only channels with activity in the last N minutes
        #[arg(long)]
        since_min: Option<i64>,
        /// Max rows to show (default 25)
        #[arg(long, default_value_t = 25)]
        limit: usize,
    },
    /// Install everything: skill, bash completions
    Install,
    /// Generate shell completions (bash, zsh, fish)
    Completions {
        /// Shell type
        shell: clap_complete::Shell,
    },
}

#[tokio::main]
async fn main() {
    let cli = Cli::parse();

    match cli.command {
        Commands::Serve { port, data_dir } => {
            run_server(port, data_dir).await;
        }
        Commands::Inject {
            channel, desc, source, port, orig_file, orig_w, orig_h, native, meta, thread, context,
            file,
        } => {
            let desc = match desc {
                Some(d) if d.len() > 5 => d,
                Some(d) => {
                    err(&format!("Description too short: \"{d}\""));
                    hint("Write a specific description of what this screenshot shows and why you took it.");
                    hint("Example: --desc \"PCB board layout after routing power traces\"");
                    hint("The description becomes the filename, so make it meaningful.");
                    std::process::exit(1);
                }
                None => {
                    err("Missing --desc flag. Every screenshot needs a description.");
                    hint("The description becomes the filename and helps the user understand what they're looking at.");
                    hint("Example: shotlog inject -c my-project -d \"Homepage after nav redesign\" -s pup_screenshot file.png");
                    std::process::exit(1);
                }
            };

            // --meta: ingest an Adom Desktop screenshot-response JSON. Pulls the
            // coordMap/DPI/screen metadata, the original W×H (so the shot is never
            // "orig missing"), and the child-window array (reads each child PNG by
            // its localSafePath and attaches it). All additive.
            let (cap_meta, popups, meta_ow, meta_oh) = match &meta {
                None => (None, None, None, None),
                Some(mp) => {
                    let raw = std::fs::read_to_string(mp).unwrap_or_else(|e| {
                        err(&format!("Failed to read --meta {mp}: {e}")); std::process::exit(1);
                    });
                    let ad: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| {
                        err(&format!("--meta {mp} is not valid JSON: {e}")); std::process::exit(1);
                    });
                    let cm = serde_json::json!({
                        "shotId": ad.get("shotId"), "coordMap": ad.get("coordMap"),
                        "ownedPopupCount": ad.get("ownedPopupCount"),
                        "width": ad.get("width"), "height": ad.get("height"),
                        "origWidth": ad.get("origWidth"), "origHeight": ad.get("origHeight"),
                        "note": ad.get("_popups_note"),
                    });
                    // Child windows (owned popups) → read each PNG by localSafePath, base64 it.
                    let mut pops = Vec::new();
                    if let Some(arr) = ad.get("screenshots").and_then(|v| v.as_array()) {
                        for s in arr {
                            let path = s.get("localSafePath").or_else(|| s.get("localFullPath")).and_then(|v| v.as_str());
                            let Some(path) = path else { continue };
                            match std::fs::read(path) {
                                Ok(b) => {
                                    let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &b);
                                    pops.push(serde_json::json!({
                                        "image": format!("data:image/png;base64,{b64}"),
                                        "hwnd": s.get("hwnd"), "title": s.get("title"),
                                        "kind": s.get("kind"), "shotId": s.get("shotId"),
                                        "coordMap": s.get("coordMap"),
                                    }));
                                }
                                Err(e) => err(&format!("WARN: child-window img {path} unreadable ({e}) — skipped")),
                            }
                        }
                    }
                    let ow = ad.get("origWidth").and_then(|v| v.as_u64()).map(|n| n as u32);
                    let oh = ad.get("origHeight").and_then(|v| v.as_u64()).map(|n| n as u32);
                    (Some(cm), if pops.is_empty() { None } else { Some(pops) }, ow, oh)
                }
            };
            // --meta's origWidth/Height count as the declared original (so it
            // satisfies the orig-resolution requirement below).
            let (orig_w, orig_h) = (orig_w.or(meta_ow), orig_h.or(meta_oh));

            // Resolve the original dimensions. Two ways to supply them for a
            // pre-shrunk image: --orig-file (measure it) or --orig-w/--orig-h.
            let orig_path_keep = orig_file.clone();
            let (orig_w, orig_h) = match (orig_file, orig_w, orig_h) {
                (Some(of), _, _) => {
                    let bytes = std::fs::read(&of).unwrap_or_else(|e| {
                        err(&format!("Failed to read --orig-file {of}: {e}"));
                        std::process::exit(1);
                    });
                    let (w, h) = image::read_png_dimensions(&bytes);
                    if w == 0 {
                        err(&format!("--orig-file {of} is not a readable PNG."));
                        std::process::exit(1);
                    }
                    (Some(w), Some(h))
                }
                (None, Some(w), Some(h)) => (Some(w), Some(h)),
                (None, None, None) => (None, None),
                (None, _, _) => {
                    err("Give BOTH --orig-w and --orig-h, or use --orig-file, or --native.");
                    hint("These record the original resolution of a pre-shrunk screenshot.");
                    std::process::exit(1);
                }
            };

            // Enforce: shotlog must always know the original resolution, so the
            // viewer never shows "orig missing".
            //   - explicit --orig-* already resolved above → trust it
            //   - stdin: can't peek the file → trust caller
            //   - otherwise read FILE's dims:
            //       --native            → record orig = the image's own size
            //       >1500px (any edge)  → leave None; the server resizes + records orig
            //       ≤1500px, nothing    → REJECT (forces the AI to declare the original)
            let (orig_w, orig_h) = if orig_w.is_some() || file == "-" {
                (orig_w, orig_h)
            } else {
                let bytes = std::fs::read(&file).unwrap_or_else(|e| {
                    err(&format!("Failed to read {file}: {e}"));
                    std::process::exit(1);
                });
                let (fw, fh) = image::read_png_dimensions(&bytes);
                if native {
                    if fw == 0 { (None, None) } else { (Some(fw), Some(fh)) }
                } else if fw != 0 && fw <= 1500 && fh <= 1500 {
                    err(&format!("Original resolution required — refusing to inject {fw}x{fh} blind."));
                    hint("Resizing before inject is fine and expected — oversized images crash Claude's");
                    hint("vision analysis, so most screenshot verbs return a shrunk copy. The original");
                    hint("size just has to travel WITH the shot. Provide it, ONE of:");
                    hint("  --orig-w <W> --orig-h <H>            (the original dimensions)");
                    hint("  --orig-file <the original image>     (shotlog measures it — keep the original around)");
                    hint("  --native                             (only if this image was NOT resized)");
                    hint("If your screenshot tool doesn't surface the original W×H yet, fix it to report");
                    hint("the pre-resize dimensions (and keep the full-res file available for --orig-file).");
                    std::process::exit(1);
                } else {
                    (None, None) // >1500 → server records the original on resize
                }
            };

            run_inject(&channel, &desc, &source, port, &file, false, orig_w, orig_h, cap_meta, popups, thread.as_deref(), context.as_deref(), orig_path_keep.as_deref());
        }
        Commands::Open {
            channel, name, icon, port, skinny, ratio, pup, swap, webview,
        } => {
            // NOTE: a CLI open does NOT record a canvas choice. Only the human's
            // explicit clicks in the viewer (canvas buttons) set the in-memory
            // note; everything else follows the global default. AI threads
            // opening channels must never masquerade as user preference.
            //
            // CONSOLIDATION FIRST: if the app is already open ANYWHERE, "open a
            // channel" means "switch that window's internal tab" - one shotlog
            // window for the human, tabs inside, never a second surface. The
            // broker bypasses this (SHOTLOG_FORCE_SURFACE=1) for its own
            // surface management (canvas switches, force-opens, recovery).
            if std::env::var("SHOTLOG_FORCE_SURFACE").is_err() {
                if let Ok(v) = http_post(port, "/api/show", &serde_json::json!({"channel": channel})) {
                    if v.get("consolidated").and_then(|c| c.as_bool()).unwrap_or(false) {
                        let surfaces = v.get("surfaces").and_then(|s| s.as_array())
                            .map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))
                            .unwrap_or_default();
                        ok(&format!("'{channel}' shown in the ALREADY-OPEN shotlog window ({surfaces}) - switched its internal tab, no new surface."));
                        hint("One shotlog window holds every channel as a tab. The human sees a toast explaining the switch. To move the whole window between webview and pup, the human uses the header button (or changes the default in prefs).");
                        return;
                    }
                }
            }
            let prefs = http_get(port, "/api/prefs").ok();
            let default_canvas = prefs.as_ref()
                .and_then(|v| v.get("default_canvas").and_then(|c| c.as_str()).map(String::from))
                .unwrap_or_else(|| "webview".into());
            // Canvas resolution: explicit flag wins; otherwise the HUMAN'S
            // default. A plain `open` must never bypass their setting.
            let use_pup = pup || (!skinny && !webview && default_canvas == "pup");
            let _ = http_post(port, "/api/log", &serde_json::json!({"line": format!(
                "CLI open '{}' canvas={} ({})", channel,
                if use_pup {"pup"} else {"webview"},
                if pup {"--pup"} else if skinny || webview {"explicit webview"} else {"followed default"})}));
            if use_pup {
                open::open_pup(&channel, port, swap);
            } else {
                // Webview is ALWAYS the managed slim panel at the preferred
                // width - an unmanaged add-tab once landed at 50% of the screen.
                let ratio = ratio.unwrap_or_else(|| {
                    prefs.as_ref()
                        .and_then(|v| v.get("webview_width").and_then(|w| w.as_u64()))
                        .map(|w| (w as f64 / 100.0).clamp(0.10, 0.50))
                        .unwrap_or(0.2)
                });
                open::open_webview(&channel, name.as_deref(), icon.as_deref(), port, true, ratio);
                if swap {
                    if open::close_pup_tab(&channel) {
                        ok(&format!("closed the pup tab for '{channel}' - webview is now this channel's canvas."));
                    }
                }
                hint("Skinny webview too small for the human to inspect images? `shotlog open -c <ch> --pup` opens the full-browser pup canvas (add --swap to close the webview tab). Zoom there uses nearly the whole screen.");
            }
        }
        Commands::Paste {
            channel, desc, source, port, file,
        } => {
            let desc = match desc {
                Some(d) if d.len() > 5 => d,
                Some(d) => {
                    err(&format!("Description too short: \"{d}\""));
                    hint("Write a specific description. Example: --desc \"Schematic after adding decoupling caps\"");
                    std::process::exit(1);
                }
                None => {
                    err("Missing --desc flag. Every screenshot needs a description.");
                    hint("Example: shotlog paste -c my-project -d \"Full page screenshot resized for AI\" file.png");
                    std::process::exit(1);
                }
            };
            run_inject(&channel, &desc, &source, port, &file, true, None, None, None, None, None, None, None);
        }
        Commands::Resize { file, output, width } => {
            run_resize(&file, output.as_deref(), width);
        }
        Commands::Health { port } => {
            run_health(port);
        }
        Commands::Viewers { channel, port, url } => {
            run_viewers(&channel, port, url.as_deref());
        }
        Commands::Channels { port, since_min, limit } => {
            run_channels(port, since_min, limit);
        }
        Commands::Install => {
            run_install();
        }
        Commands::Completions { shell } => {
            let mut cmd = Cli::command();
            clap_complete::generate(shell, &mut cmd, "adom-shotlog", &mut std::io::stdout());
        }
    }
}

async fn run_server(port: u16, data_dir: PathBuf) {
    let store = store::Store::new(data_dir);
    store.recover().await;

    let channel_count = store.channel_names().await.len();
    ok(&format!("Recovered {channel_count} channel(s) from disk"));

    // Retention: cap shots per channel on disk (SHOTLOG_MAX_SHOTS, default 200,
    // enforced on every inject + at recovery) and prune idle channels
    // (SHOTLOG_RETENTION_DAYS, default 30, 0 disables) at startup + every 6h.
    {
        let sweeper = store.clone();
        tokio::spawn(async move {
            loop {
                let pruned = sweeper.retention_sweep().await;
                if !pruned.is_empty() {
                    ok(&format!("Retention: pruned {} idle channel(s): {}", pruned.len(), pruned.join(", ")));
                }
                tokio::time::sleep(std::time::Duration::from_secs(6 * 3600)).await;
            }
        });
        ok("Retention active: max 200 shots/channel on disk (SHOTLOG_MAX_SHOTS), idle channels pruned after 30d (SHOTLOG_RETENTION_DAYS, 0=off)");
    }

    // The auto-capture read-hook is part of shotlog: verify (or heal) it at
    // every launch so the pipeline can never silently rot.
    ensure_read_hook();

    let state = Arc::new(server::AppState { store, port, next_viewer_id: std::sync::atomic::AtomicU64::new(1) });
    let app = server::create_router(state);

    let addr = format!("0.0.0.0:{port}");
    ok(&format!("Listening on {addr}"));

    let listener = match tokio::net::TcpListener::bind(&addr).await {
        Ok(l) => l,
        Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
            // SINGLE-INSTANCE: exactly one broker per port. If a healthy broker
            // already owns it, defer gracefully (racing self-heals are normal).
            match http_get(port, "/health") {
                Ok(v) => {
                    let rv = v["version"].as_str().unwrap_or("unknown").to_string();
                    ok(&format!("Broker already running on port {port} (v{rv}) - single instance enforced, deferring to it."));
                    if rv != env!("CARGO_PKG_VERSION") {
                        warn(&format!("Running broker is v{rv}, this binary is v{}. To upgrade: kill that `adom-shotlog serve` pid (targeted, never pkill) and start serve again.", env!("CARGO_PKG_VERSION")));
                    }
                    std::process::exit(0);
                }
                Err(_) => {
                    err(&format!("Port {port} is held by something that is NOT a shotlog broker."));
                    hint(&format!("Identify it: ss -ltnp | grep {port}"));
                    std::process::exit(1);
                }
            }
        }
        Err(e) => {
            err(&format!("Failed to bind {addr}: {e}"));
            std::process::exit(1);
        }
    };
    axum::serve(listener, app)
        .await
        .expect("Server error");
}

#[allow(clippy::too_many_arguments)]
fn run_inject(
    channel: &str,
    desc: &str,
    source: &str,
    port: u16,
    file: &str,
    resize: bool,
    orig_w: Option<u32>,
    orig_h: Option<u32>,
    cap_meta: Option<serde_json::Value>,
    popups: Option<Vec<serde_json::Value>>,
    thread: Option<&str>,
    context: Option<&str>,
    orig_path: Option<&str>,
) {
    let bytes = if file == "-" {
        use std::io::Read;
        let mut buf = Vec::new();
        std::io::stdin()
            .read_to_end(&mut buf)
            .expect("Failed to read stdin");
        buf
    } else {
        std::fs::read(file).unwrap_or_else(|e| {
            err(&format!("Failed to read {file}: {e}"));
            std::process::exit(1);
        })
    };

    let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);
    let data_url = format!("data:image/png;base64,{b64}");

    let body = if resize {
        serde_json::json!({
            "channel": channel,
            "dataUrl": data_url,
            "description": desc,
        })
    } else {
        let mut b = serde_json::json!({
            "channel": channel,
            "image": data_url,
            "description": desc,
            "source": source,
        "thread": thread,
        "context": context,
        "orig_path": orig_path.map(|p| std::fs::canonicalize(p).map(|c| c.to_string_lossy().to_string()).unwrap_or_else(|_| p.to_string())),
        });
        if let (Some(ow), Some(oh)) = (orig_w, orig_h) {
            b["orig_w"] = serde_json::json!(ow);
            b["orig_h"] = serde_json::json!(oh);
        }
        if let Some(m) = cap_meta { b["meta"] = m; }
        if let Some(p) = popups { b["popups"] = serde_json::json!(p); }
        b
    };

    let endpoint = if resize { "/api/paste" } else { "/api/shots" };
    match http_post(port, endpoint, &body) {
        Ok(val) => {
            if val.get("ok").and_then(|v| v.as_bool()) == Some(true) {
                let w = val["w"].as_u64().unwrap_or(0);
                let h = val["h"].as_u64().unwrap_or(0);
                let ow = val["original_w"].as_u64();
                let oh = val["original_h"].as_u64();
                let dim_str = match (ow, oh) {
                    (Some(ow), Some(oh)) => format!("{w}x{h}, orig {ow}x{oh}"),
                    _ => format!("{w}x{h}"),
                };
                ok(&format!(
                    "Injected {} ({dim_str}, {} KB) into channel {channel}\n     Saved to: {}",
                    val["name"].as_str().unwrap_or("?"),
                    val["kb"],
                    val["path"].as_str().unwrap_or("?"),
                ));
                // shotlog resized a full-res image itself and recorded the original.
                if let (Some(ow), Some(oh)) = (ow, oh) {
                    if orig_w.is_none() {
                        hint(&format!("Auto-resized from {ow}x{oh} → {w}x{h}; original size recorded."));
                    }
                }
                // Presence check — is the human ACTUALLY seeing this, and where?
                let getvec = |key: &str| -> Vec<String> {
                    val.get(key).and_then(|v| v.as_array())
                        .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())
                        .unwrap_or_default()
                };
                if let Some(viewers) = val.get("viewers").and_then(|v| v.as_u64()) {
                    let surfaces = getvec("surfaces");
                    let visible = val.get("visible_viewers").and_then(|v| v.as_u64()).unwrap_or(0);
                    let vis_surf = getvec("visible_surfaces");
                    // Two reminders ride on EVERY inject:
                    let verify = "Are you sure the human is seeing the shotlog? Is it open in a webview or pup? Have you verified it's truly showing?";
                    // The core reason shotlog exists — image-blindness in text threads.
                    let cant_show = "You're in Claude Code / a text thread: you CANNOT display images to the human. Reading or capturing a screenshot loads it into YOUR context, not their view — so NEVER write \"see the screenshot above\" / \"look at this image\". This viewer is the ONLY way they see it: point them to the viewer or a URL.";
                    // Status line (state-specific), then the two universal reminders,
                    // then any state-specific follow-up.
                    if visible > 0 {
                        let s = if vis_surf.is_empty() { String::new() } else { format!(" [{}]", surface_label(&vis_surf)) };
                        ok(&format!("{visible} of {viewers} viewer(s) ON-SCREEN{s} — a human can see this shot."));
                    } else if viewers > 0 {
                        let s = if surfaces.is_empty() { String::new() } else { format!(" [{}]", surface_label(&surfaces)) };
                        warn(&format!("{viewers} viewer(s) connected to '{channel}'{s} but ALL are BACKGROUNDED / minimized — the human is NOT looking right now."));
                    } else {
                        warn(&format!("NO live viewer is connected to '{channel}' — the human is NOT seeing this shot."));
                    }
                    // What the BROKER is doing about it — so the calling AI never
                    // duplicates surfacing work or foregrounds anything itself.
                    if let Some(acts) = val.get("surfacing").and_then(|s| s.get("actions")).and_then(|a| a.as_array()) {
                        for a in acts {
                            match a.get("action").and_then(|x| x.as_str()) {
                                Some("webview_surface") => ok(&format!(
                                    "Broker: '{}' webview: {} — surfacing is handled, do NOT open/foreground anything yourself.",
                                    a.get("tab").and_then(|t| t.as_str()).unwrap_or("Shots"),
                                    a.get("cue").and_then(|c| c.as_str()).unwrap_or("tab pulled to front"))),
                                Some("pup_flash") => ok(&format!(
                                    "Broker: flashing the pup taskbar orange (session{}) — the human's gentle cue is handled.",
                                    a.get("sessions").and_then(|s| s.as_array()).map(|v| format!(" {}", v.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))).unwrap_or_default())),
                                Some("force_open") => ok(&format!(
                                    "Broker: nobody was watching — force-opening this channel's chosen canvas ({}) now. Re-check with `shotlog viewers` before assuming the human sees it.",
                                    a.get("canvas").and_then(|c| c.as_str()).unwrap_or("webview"))),
                                _ => {}
                            }
                        }
                    }
                    // Progressive mini-skill hints from the broker (shown a few
                    // times each, then quiet).
                    if let Some(hs) = val.get("hints").and_then(|h| h.as_array()) {
                        for h in hs { if let Some(t) = h.as_str() { hint(t); } }
                    }
                    hint(cant_show);
                    hint(verify);
                    // Anything the broker did NOT handle is the caller's job.
                    let broker_actions: Vec<String> = val.get("surfacing")
                        .and_then(|s| s.get("actions")).and_then(|a| a.as_array())
                        .map(|a| a.iter().filter_map(|x| x.get("action").and_then(|v| v.as_str()).map(String::from)).collect())
                        .unwrap_or_default();
                    let has_pup = surfaces.iter().any(|s| s == "pup");
                    if visible == 0 && viewers > 0 {
                        // Backgrounded — surface it GENTLY. NEVER force the window to the front.
                        if has_pup && !broker_actions.iter().any(|a| a == "pup_flash") {
                            hint("A pup viewer is backgrounded and I could NOT flash it (no ?session= handle). Nudge it yourself: `adom-desktop browser_alert_window {sessionId}` — never force the pup window to the front.");
                        }
                    }
                    if viewers == 0 && !broker_actions.iter().any(|a| a == "force_open") {
                        hint(&format!("Open it, then RE-CHECK: `shotlog open -c {channel} --skinny` (preferred: a slim right-side auto-updating panel) — or load it in pup / on the phone — then `shotlog viewers -c {channel}`."));
                    }
                }
            } else {
                err(&format!("Server error: {}", serde_json::to_string(&val).unwrap_or_default()));
            }
        }
        Err(e) => {
            err(&e);
            hint("Is `shotlog serve` running? Start with: shotlog serve &");
        }
    }
}

fn run_resize(file: &str, output: Option<&str>, max_width: u32) {
    let bytes = std::fs::read(file).unwrap_or_else(|e| {
        err(&format!("Failed to read {file}: {e}"));
        std::process::exit(1);
    });

    let (orig_w, orig_h) = image::read_png_dimensions(&bytes);
    let orig_kb = bytes.len() / 1024;

    match image::resize_png(bytes, max_width) {
        Ok((resized, new_w, new_h)) => {
            let new_kb = resized.len() / 1024;
            let out_path = output.unwrap_or(file);
            std::fs::write(out_path, &resized).unwrap_or_else(|e| {
                err(&format!("Failed to write {out_path}: {e}"));
                std::process::exit(1);
            });

            if orig_w != new_w {
                ok(&format!(
                    "Resized {file}: {orig_w}x{orig_h} ({orig_kb} KB) -> {new_w}x{new_h} ({new_kb} KB) saved to {out_path}"
                ));
            } else {
                ok(&format!(
                    "Already within {max_width}px: {file} {orig_w}x{orig_h} ({new_kb} KB)"
                ));
            }
        }
        Err(e) => {
            err(&format!("Resize failed: {e}"));
            std::process::exit(1);
        }
    }
}

fn run_health(port: u16) {
    match http_get(port, "/health") {
        Ok(val) => {
            let channels = val["channels"].as_array().map(|a| a.len()).unwrap_or(0);
            let rv = val["version"].as_str().unwrap_or("unknown");
            ok(&format!(
                "Shotlog broker v{rv} running on port {port} with {channels} channel(s)"
            ));
            if rv != "unknown" && rv != env!("CARGO_PKG_VERSION") {
                warn(&format!("Broker is v{rv} but this CLI is v{} - restart the broker to match (kill its pid, then `adom-shotlog serve &`).", env!("CARGO_PKG_VERSION")));
            }
        }
        Err(e) => {
            err(&format!("Cannot connect to shotlog on port {port}"));
            hint("Start with: shotlog serve &");
            eprintln!("{e}");
            std::process::exit(1);
        }
    }
}

/// Verify a human is actually watching. Exit 0 = someone's live viewer is
/// connected; exit 3 = nobody is (the shot is landing unseen). Lets an AI gate
/// on "did the human actually see it" in one call instead of guessing.
fn run_viewers(channel: &str, port: u16, url: Option<&str>) {
    let endpoint = format!("/api/viewer-status?channel={}", channel.replace(' ', "%20"));
    let val = match http_get(port, &endpoint) {
        Ok(v) => v,
        Err(e) => {
            err(&format!("Cannot connect to shotlog on port {port}: {e}"));
            hint("Start it first: shotlog serve &");
            std::process::exit(1);
        }
    };
    let viewers = val["viewers"].as_u64().unwrap_or(0);
    let visible = val["visible"].as_u64().unwrap_or(0);
    let getvec = |key: &str| -> Vec<String> {
        val[key].as_array()
            .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())
            .unwrap_or_default()
    };
    let surfaces = getvec("surfaces");
    let vis_surf = getvec("visible_surfaces");

    // Optional: the AI passes the endpoint it opened; we confirm it's reachable.
    if let Some(u) = url {
        match std::process::Command::new("curl")
            .args(["-sS", "-o", "/dev/null", "-m", "8", "-w", "%{http_code}", u])
            .output()
        {
            Ok(o) => {
                let code = String::from_utf8_lossy(&o.stdout);
                let code = code.trim();
                if code.starts_with('2') || code.starts_with('3') {
                    ok(&format!("Endpoint reachable (HTTP {code}): {u}"));
                } else {
                    warn(&format!("Endpoint NOT reachable (HTTP {code}): {u} — that surface can't be showing the viewer."));
                }
            }
            Err(e) => warn(&format!("Could not probe endpoint {u}: {e}")),
        }
    }

    // Three states: on-screen (0), open-but-backgrounded (4), nobody (3).
    if visible > 0 {
        let surf = if vis_surf.is_empty() { String::new() } else { format!(" [{}]", surface_label(&vis_surf)) };
        ok(&format!("{visible} of {viewers} viewer(s) ON-SCREEN{surf} watching '{channel}' — a human can see it."));
        std::process::exit(0);
    } else if viewers > 0 {
        let surf = if surfaces.is_empty() { String::new() } else { format!(" [{}]", surface_label(&surfaces)) };
        warn(&format!("{viewers} viewer(s) connected to '{channel}'{surf} but ALL are BACKGROUNDED / minimized — the human isn't looking right now."));
        hint(&format!("Bring the viewer to the FOREGROUND (pup window / webview tab / phone), then re-run `shotlog viewers -c {channel}`."));
        std::process::exit(4);
    } else {
        warn(&format!("NO live viewer connected to '{channel}' — nothing you inject here is being seen."));
        hint(&format!("Open it: `shotlog open -c {channel}` (Hydrogen webview), or load the viewer in pup / on the phone, then re-run `shotlog viewers -c {channel}`."));
        std::process::exit(3);
    }
}

/// Print an overview of every channel: last activity, shot count, who's watching,
/// and the source of the last shot. Newest-active first.
fn run_channels(port: u16, since_min: Option<i64>, limit: usize) {
    let val = match http_get(port, "/api/channels") {
        Ok(v) => v,
        Err(e) => { err(&format!("Cannot connect to shotlog on port {port}: {e}")); hint("Start it: shotlog serve &"); std::process::exit(1); }
    };
    let rows = val.as_array().cloned().unwrap_or_default();
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
    let fmt_ago = |secs: i64| -> String {
        let m = secs / 60;
        if m < 1 { "just now".into() } else if m < 60 { format!("{m}m ago") }
        else if m < 1440 { format!("{}h ago", m / 60) } else { format!("{}d ago", m / 1440) }
    };
    println!("{:>9}  {:>5}  {:<20}  {}", "ACTIVITY", "SHOTS", "WATCHING", "CHANNEL");
    let (mut shown, mut watched, mut total) = (0usize, 0usize, 0usize);
    for row in &rows {
        total += 1;
        let last_epoch = row["last_epoch"].as_i64().unwrap_or(0);
        let age = now - last_epoch;
        if let Some(mins) = since_min { if last_epoch == 0 || age > mins * 60 { break; } } // sorted newest-first
        let viewers = row["viewers"].as_u64().unwrap_or(0);
        let visible = row["visible"].as_u64().unwrap_or(0);
        if viewers > 0 { watched += 1; }
        if shown >= limit { continue; }
        let surfaces: Vec<String> = row["surfaces"].as_array()
            .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect()).unwrap_or_default();
        let watching = if viewers > 0 {
            let s = if surfaces.is_empty() { String::new() } else { format!(" [{}]", surface_label(&surfaces)) };
            format!("● {visible}/{viewers} on-screen{s}")
        } else { "—  nobody".into() };
        let ch = row["channel"].as_str().unwrap_or("?");
        let shots = row["shots"].as_u64().unwrap_or(0);
        let ago = if last_epoch == 0 { "—".into() } else { fmt_ago(age) };
        println!("{ago:>9}  {shots:>5}  {watching:<20}  {ch}");
        shown += 1;
    }
    println!("\n{total} channels · {watched} with a live viewer right now.");
    if watched == 0 && total > 0 {
        hint("No channel is being watched. Shots injected now would auto-open a slim webview (or set SHOTLOG_NO_FORCE_OPEN=1 to suppress).");
    }
}

fn run_install() {
    ensure_read_hook();
    let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());

    // 1. Install skill
    let skill_dir = format!("{home}/.claude/skills/adom-shotlog");
    let skill_file = format!("{skill_dir}/SKILL.md");
    std::fs::create_dir_all(&skill_dir).ok();
    match std::fs::write(&skill_file, SKILL_MD) {
        Ok(_) => ok(&format!("Skill installed to {skill_file}")),
        Err(e) => err(&format!("Failed to write skill: {e}")),
    }

    // 2. Install bash completions
    let comp_dir = format!("{home}/.local/share/bash-completion/completions");
    std::fs::create_dir_all(&comp_dir).ok();
    let comp_file = format!("{comp_dir}/adom-shotlog");
    let mut cmd = Cli::command();
    let mut buf = Vec::new();
    clap_complete::generate(clap_complete::Shell::Bash, &mut cmd, "adom-shotlog", &mut buf);
    match std::fs::write(&comp_file, &buf) {
        Ok(_) => ok(&format!("Bash completions installed to {comp_file}")),
        Err(e) => err(&format!("Failed to write completions: {e}")),
    }

    // 3. Add to .bashrc if not there
    let bashrc = format!("{home}/.bashrc");
    let bashrc_content = std::fs::read_to_string(&bashrc).unwrap_or_default();
    let marker = "# adom-shotlog completions";
    if !bashrc_content.contains(marker) {
        let line = format!("\n{marker}\ncommand -v adom-shotlog >/dev/null && eval \"$(adom-shotlog completions bash 2>/dev/null)\"\n");
        match std::fs::OpenOptions::new().append(true).open(&bashrc) {
            Ok(mut f) => {
                use std::io::Write;
                f.write_all(line.as_bytes()).ok();
                ok("Bash completions added to .bashrc");
            }
            Err(e) => err(&format!("Failed to update .bashrc: {e}")),
        }
    }

    // 4. Back-compat `shotlog` alias → symlink next to the adom-shotlog binary.
    if let Ok(exe) = std::env::current_exe() {
        if let Some(dir) = exe.parent() {
            let alias = dir.join("shotlog");
            if !alias.exists() {
                #[cfg(unix)]
                match std::os::unix::fs::symlink(&exe, &alias) {
                    Ok(_) => ok(&format!("`shotlog` alias linked to {}", alias.display())),
                    Err(e) => err(&format!("Could not create `shotlog` alias ({e}) — run: sudo ln -s {} {}", exe.display(), alias.display())),
                }
            }
        }
    }

    println!("\nInstall complete.");
}

// ── HTTP client (raw TCP) ───────────────────────────────────────

fn http_post(port: u16, endpoint: &str, body: &serde_json::Value) -> Result<serde_json::Value, String> {
    let body_str = body.to_string();
    let request = format!(
        "POST {endpoint} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body_str}",
        body_str.len(),
    );
    send_request(port, &request)
}

fn http_get(port: u16, endpoint: &str) -> Result<serde_json::Value, String> {
    let request = format!(
        "GET {endpoint} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"
    );
    send_request(port, &request)
}

fn send_request(port: u16, request: &str) -> Result<serde_json::Value, String> {
    use std::io::{Read, Write};

    let mut stream = std::net::TcpStream::connect(format!("127.0.0.1:{port}"))
        .map_err(|e| format!("Connection refused: {e}"))?;

    let timeout = std::time::Duration::from_secs(10);
    stream.set_read_timeout(Some(timeout)).ok();
    stream.set_write_timeout(Some(timeout)).ok();

    stream.write_all(request.as_bytes()).map_err(|e| e.to_string())?;
    let mut response = String::new();
    stream.read_to_string(&mut response).unwrap_or(0);

    if response.is_empty() {
        return Ok(serde_json::json!({ "ok": true }));
    }

    let body_start = match response.find("\r\n\r\n") {
        Some(pos) => pos,
        None => return Ok(serde_json::json!({ "ok": true })),
    };
    let raw_body = &response[body_start + 4..];

    // Slice out the JSON value — an OBJECT {…} or an ARRAY […] (the /api/channels
    // response is an array; the old object-only slice mangled it).
    let start = raw_body.find(|c| c == '{' || c == '[');
    let end = raw_body.rfind(|c| c == '}' || c == ']');
    let body = match (start, end) {
        (Some(s), Some(e)) if e >= s => &raw_body[s..=e],
        _ => raw_body.trim(),
    };

    if body.is_empty() {
        return Ok(serde_json::json!({ "ok": true }));
    }

    serde_json::from_str(body).map_err(|e| format!("Invalid JSON: {e}"))
}