12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::http::{header, HeaderValue, Method, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tower_http::cors::CorsLayer;

use crate::describe;
use crate::image::{parse_data_url, read_png_dimensions, resize_png};
use crate::store::Store;

const INDEX_HTML: &str = include_str!("html/index.html");
const ICON_SVG: &str = include_str!("html/icon.svg");

/// Version stamp for the page<->broker reload handshake: version PLUS the
/// broker process start time. Same-version redeploys used to slip past a
/// version-only stamp, leaving humans staring at stale UIs ("dude if you make
/// changes you have to refresh your surfaces"). Any restart now makes every
/// connected page reload exactly once on reconnect (guard is keyed by stamp).
fn boot_stamp() -> &'static str {
    use std::sync::OnceLock;
    static STAMP: OnceLock<String> = OnceLock::new();
    STAMP.get_or_init(|| {
        let epoch = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        format!("{}+{}", env!("CARGO_PKG_VERSION"), epoch)
    })
}
// Raster favicons: Chrome ignores SVG favicons for app-mode windows, pinned
// tabs, and the OS taskbar (what pup windows show) — those need PNG/ICO.
const ICON_PNG_32: &[u8] = include_bytes!("icons/favicon-32.png");
const ICON_PNG_192: &[u8] = include_bytes!("icons/favicon-192.png");
const ICON_ICO: &[u8] = include_bytes!("icons/favicon.ico");

/// Turn a description into a safe filename: lowercase, hyphens, max 60 chars
fn slugify_filename(desc: &str) -> String {
    let slug: String = desc
        .to_lowercase()
        .chars()
        .map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { ' ' })
        .collect::<String>()
        .split_whitespace()
        .collect::<Vec<_>>()
        .join("-");
    let slug = if slug.len() > 60 { &slug[..60] } else { &slug };
    let slug = slug.trim_end_matches('-');
    if slug.is_empty() {
        let ts = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
        format!("shot-{ts}.png")
    } else {
        format!("{slug}.png")
    }
}

/// Ensure a filename is unique in a directory by appending -2, -3, etc.
fn unique_filename(dir: &std::path::Path, filename: &str) -> String {
    if !dir.join(filename).exists() {
        return filename.to_string();
    }
    let stem = filename.trim_end_matches(".png");
    for i in 2..100 {
        let candidate = format!("{stem}-{i}.png");
        if !dir.join(&candidate).exists() {
            return candidate;
        }
    }
    // Fallback: add timestamp
    let ts = chrono::Utc::now().format("%H%M%S").to_string();
    format!("{stem}-{ts}.png")
}

pub struct AppState {
    pub store: Arc<Store>,
    pub port: u16,
    /// Monotonic id generator for viewer presence (one per WS connection).
    pub next_viewer_id: std::sync::atomic::AtomicU64,
}

pub fn create_router(state: Arc<AppState>) -> Router {
    let cors = CorsLayer::new()
        .allow_origin(tower_http::cors::Any)
        .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
        .allow_headers([header::CONTENT_TYPE]);

    Router::new()
        .route("/", get(root_redirect))
        .route("/log/", get(serve_viewer))
        .route("/log/{channel}", get(log_redirect))
        .route("/log/{channel}/", get(serve_viewer))
        .route("/ws/_all", get(ws_all_handler))
        .route("/favicon.svg", get(serve_favicon))
        // The single-window app lives at /log/ — without this exact route the
        // page's relative favicon.svg 308s into the channel handler and the
        // browser gets HTML instead of an icon (= no favicon at all).
        .route("/log/favicon.svg", get(serve_favicon))
        .route("/log/{channel}/favicon.svg", get(serve_favicon))
        // Raster icons at every page level (hrefs are relative so they work
        // through the auth proxy, which owns the URL root).
        .route("/favicon-32.png", get(serve_favicon_png32))
        .route("/log/favicon-32.png", get(serve_favicon_png32))
        .route("/log/{channel}/favicon-32.png", get(serve_favicon_png32))
        .route("/favicon-192.png", get(serve_favicon_png192))
        .route("/log/favicon-192.png", get(serve_favicon_png192))
        .route("/log/{channel}/favicon-192.png", get(serve_favicon_png192))
        .route("/favicon.ico", get(serve_favicon_ico))
        .route("/log/favicon.ico", get(serve_favicon_ico))
        .route("/log/{channel}/favicon.ico", get(serve_favicon_ico))
        .route("/api/shots", get(get_shots))
        .route("/api/shots", post(post_shots))
        .route("/api/viewer-status", get(viewer_status))
        .route("/api/channels", get(get_channels))
        .route("/api/canvas-check", get(canvas_check))
        .route("/api/canvas-switch", post(canvas_switch))
        .route("/api/canvas-open", post(canvas_open))
        .route("/api/canvas-close", post(canvas_close))
        .route("/api/canvas-pref", post(set_canvas_pref))
        .route("/api/prefs", get(get_prefs))
        .route("/api/prefs", post(set_prefs))
        .route("/api/log", post(post_log))
        .route("/api/notice", post(post_notice))
        .route("/api/show", post(post_show))
        .route("/prompt", get(serve_prompt))
        .route("/api/paste", post(post_paste))
        .route("/api/open", post(open_in_vscode))
        .route("/api/reveal", post(reveal_in_vscode))
        .route("/api/clear-log", delete(clear_log))
        .route("/api/clear-all", delete(clear_all))
        .route("/api/delete", delete(delete_entry))
        .route("/ws/{channel}", get(ws_handler))
        .route("/shots/{channel}/{filename}", get(serve_image))
        .route("/health", get(health))
        .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) // 50MB
        .layer(cors)
        .with_state(state)
}

async fn root_redirect() -> Redirect {
    Redirect::temporary("/log/")
}

async fn log_redirect(Path(channel): Path<String>) -> Redirect {
    Redirect::permanent(&format!("/log/{channel}/"))
}

/// One-shot human-facing notice, delivered as a toast by the next page that
/// completes the WS welcome (fresh for 10 min, single delivery). Set by the
/// broker itself (pup fallback / recovery) or by any AI via POST /api/notice -
/// the toast is the narration channel to the human ("tried pup, it was down,
/// fell back to webview").
fn notice_cell() -> &'static std::sync::Mutex<Option<(String, std::time::Instant)>> {
    static CELL: std::sync::OnceLock<std::sync::Mutex<Option<(String, std::time::Instant)>>> = std::sync::OnceLock::new();
    CELL.get_or_init(|| std::sync::Mutex::new(None))
}
fn set_notice(text: &str) {
    *notice_cell().lock().unwrap() = Some((text.to_string(), std::time::Instant::now()));
    println!("{} NOTICE queued for the human: {text}", ts());
}
fn take_notice() -> Option<String> {
    let mut g = notice_cell().lock().unwrap();
    match g.take() {
        Some((t, at)) if at.elapsed().as_secs() < 600 => Some(t),
        _ => None,
    }
}

fn icon_response(bytes: &'static [u8], mime: &'static str) -> Response {
    let mut res = bytes.into_response();
    res.headers_mut()
        .insert(header::CONTENT_TYPE, HeaderValue::from_static(mime));
    res.headers_mut().insert(
        header::CACHE_CONTROL,
        HeaderValue::from_static("public, max-age=86400"),
    );
    res
}
async fn serve_favicon_png32() -> Response { icon_response(ICON_PNG_32, "image/png") }
async fn serve_favicon_png192() -> Response { icon_response(ICON_PNG_192, "image/png") }
async fn serve_favicon_ico() -> Response { icon_response(ICON_ICO, "image/x-icon") }

async fn serve_favicon() -> Response {
    let mut res = ICON_SVG.to_string().into_response();
    res.headers_mut().insert(
        header::CONTENT_TYPE,
        HeaderValue::from_static("image/svg+xml"),
    );
    res.headers_mut().insert(
        header::CACHE_CONTROL,
        HeaderValue::from_static("public, max-age=86400"),
    );
    res
}

async fn serve_viewer() -> Response {
    let html = INDEX_HTML.replacen(
        "<head>",
        "<head><script>document.write('<base href=\"'+((location.pathname.match(/^\\/proxy\\/\\d+\\//)||['/'])[0])+'\">')</script>",
        1,
    );
    // no-cache: the viewer UI must NEVER go stale in a long-lived webview/pup
    // tab - without this, browsers heuristically cached old HTML across
    // deploys and reloads showed a months-old UI.
    let html = html.replacen("__SHOTLOG_VERSION__", boot_stamp(), 1);
    let mut res = Html(html).into_response();
    res.headers_mut().insert(
        header::CACHE_CONTROL,
        HeaderValue::from_static("no-cache"),
    );
    res
}

#[derive(Deserialize)]
struct ChannelQuery {
    channel: Option<String>,
}

async fn get_shots(
    State(state): State<Arc<AppState>>,
    Query(q): Query<ChannelQuery>,
) -> Json<Vec<crate::store::Entry>> {
    let channel = q.channel.unwrap_or_else(|| "default".into());
    let entries = state.store.get_entries(&channel).await;
    Json(entries)
}

async fn viewer_status(
    State(state): State<Arc<AppState>>,
    Query(q): Query<ChannelQuery>,
) -> Json<serde_json::Value> {
    let channel = q.channel.unwrap_or_else(|| "default".into());
    let infos = state.store.viewer_infos(&channel).await;
    let viewers = infos.len();
    let surfaces: Vec<String> = infos.iter().map(|i| i.surface.clone()).collect();
    let visible: usize = infos.iter().filter(|i| i.visible).count();
    let visible_surfaces: Vec<String> = infos.iter().filter(|i| i.visible).map(|i| i.surface.clone()).collect();
    let pup_sessions: Vec<String> = infos.iter()
        .filter(|i| i.surface == "pup").filter_map(|i| i.session.clone()).collect();
    Json(serde_json::json!({
        "channel": channel,
        "viewers": viewers,
        "has_viewers": viewers > 0,
        "surfaces": surfaces,
        "visible": visible,
        "visible_surfaces": visible_surfaces,
        "pup_sessions": pup_sessions,
        "canvas_pref": state.store.canvas_pref(&channel).await,
    }))
}

/// Overview of every channel: shot count, last-activity time + source, and live
/// viewer presence. Lets a human (or an AI) see how OTHER threads are using
/// shotlog on an ongoing basis. Sorted newest-activity first.
async fn get_channels(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
    let mut rows: Vec<serde_json::Value> = Vec::new();
    for (name, shots, last, source) in state.store.channel_summaries().await {
        let infos = state.store.viewer_infos(&name).await;
        let visible = infos.iter().filter(|i| i.visible).count();
        let surfaces: Vec<String> = infos.iter().map(|i| i.surface.clone()).collect();
        rows.push(serde_json::json!({
            "channel": name,
            "shots": shots,
            "last": last.map(|t| t.to_rfc3339()),
            "last_epoch": last.map(|t| t.timestamp()).unwrap_or(0),
            "source": source,
            "viewers": infos.len(),
            "visible": visible,
            "surfaces": surfaces,
        }));
    }
    rows.sort_by(|a, b| b["last_epoch"].as_i64().unwrap_or(0).cmp(&a["last_epoch"].as_i64().unwrap_or(0)));
    Json(serde_json::json!(rows))
}

/// Is the pup canvas available on this machine? Checks the adom-desktop CLI and
/// the pup bridge (browser_readiness), cached for 2 minutes. Powers the viewer's
/// canvas-toggle button, including the "how to install" guidance.
async fn canvas_check() -> Json<serde_json::Value> {
    let v = tokio::task::spawn_blocking(do_canvas_check)
        .await
        .unwrap_or_else(|_| serde_json::json!({"available": false, "reason": "internal"}));
    Json(v)
}

fn do_canvas_check() -> serde_json::Value {
    use std::sync::Mutex;
    use std::time::Instant;
    static CACHE: Mutex<Option<(Instant, serde_json::Value)>> = Mutex::new(None);
    if let Some((t, v)) = CACHE.lock().unwrap().as_ref() {
        if t.elapsed().as_secs() < 120 {
            return v.clone();
        }
    }
    let result = (|| {
        if std::process::Command::new("adom-desktop").arg("--version").output().is_err() {
            return serde_json::json!({"available": false, "reason": "no-adom-desktop",
                "hint": "pup needs Adom Desktop. Install it on your computer from wiki.adom.inc/adom/adom-desktop - the pup bridge is a default bridge that auto-installs right after it."});
        }
        let mut cmd = std::process::Command::new("adom-desktop");
        if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") {
            cmd.args(["--target", &t]);
        }
        match cmd.args(["browser_readiness", "{}"]).output() {
            Ok(o) => {
                let text = format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
                if text.contains("ambiguous_target") {
                    serde_json::json!({"available": false, "reason": "multiple-desktops",
                        "hint": "More than one Adom Desktop is connected. Set SHOTLOG_AD_TARGET=<desktop name> where the broker runs."})
                } else if text.contains("\"ready\": true") || text.contains("\"ready\":true") || text.contains("\"ok\": true") {
                    serde_json::json!({"available": true})
                } else if text.contains("No clients") || text.contains("not connected") || text.contains("no desktop") {
                    serde_json::json!({"available": false, "reason": "desktop-not-connected",
                        "hint": "Adom Desktop is installed but not connected right now. Launch it on your computer and it links to this container automatically."})
                } else {
                    serde_json::json!({"available": false, "reason": "pup-not-ready",
                        "hint": "Adom Desktop is here but the pup bridge is not ready yet (it auto-installs on first use). Give it a moment, or run: adom-desktop browser_prewarm"})
                }
            }
            Err(e) => serde_json::json!({"available": false, "reason": "probe-failed",
                "hint": format!("Could not probe pup: {e}")}),
        }
    })();
    *CACHE.lock().unwrap() = Some((Instant::now(), result.clone()));
    result
}

#[derive(Deserialize)]
struct CanvasSwitchRequest {
    channel: String,
    to: String,
}

#[derive(Deserialize)]
struct LogRequest {
    line: String,
}

/// Append a one-line event to the broker's audit trail (stdout). Used by the
/// CLI so "who opened what, how" is greppable in ONE place.
async fn post_log(Json(req): Json<LogRequest>) -> Json<serde_json::Value> {
    let mut line = req.line.replace(['\n', '\r'], " ");
    line.truncate(300);
    println!("{} {}", ts(), line);
    Json(serde_json::json!({"ok": true}))
}

#[derive(Deserialize)]
struct ShowRequest {
    channel: String,
}

/// CONSOLIDATION: "open channel X" while the app is already on screen means
/// "switch the existing window's internal tab", never "spawn another surface".
/// (One human, one shotlog window, tabs inside - two windows was confusing.)
/// Returns consolidated:false when nothing is connected, so the caller falls
/// through to the normal canvas-open machinery.
async fn post_show(
    State(state): State<Arc<AppState>>,
    Json(req): Json<ShowRequest>,
) -> Json<serde_json::Value> {
    if req.channel.contains("..") || req.channel.contains('/') || req.channel.is_empty() {
        return Json(serde_json::json!({"ok": false, "error": "invalid channel"}));
    }
    let infos = state.store.viewer_infos("_all").await;
    if infos.is_empty() {
        return Json(serde_json::json!({"ok": true, "consolidated": false, "viewers": 0}));
    }
    let prefs = state.store.app_prefs().await;
    let frame = serde_json::json!({
        "type": "show",
        "channel": req.channel,
        "toast": format!("Consolidated: an AI asked to open '{}' - switched this tab instead of opening another shotlog window.", req.channel),
    });
    let _ = state.store.ctrl_tx.send(frame.to_string());
    println!("{} DECISION show '{}' consolidated into the open app window ({} viewer(s)) - no new surface.", ts(), req.channel, infos.len());
    notify_surface(&req.channel, state.port, &infos, &prefs.default_canvas, prefs.update_alerts, prefs.webview_width as f64 / 100.0);
    Json(serde_json::json!({
        "ok": true, "consolidated": true, "viewers": infos.len(),
        "surfaces": infos.iter().map(|i| i.surface.clone()).collect::<Vec<_>>(),
    }))
}

#[derive(Deserialize)]
struct NoticeRequest {
    text: String,
}

/// Queue a one-shot toast for the human: the next page to complete the WS
/// welcome shows it (10 min freshness). The broker uses this for pup
/// fallback/recovery narration; AIs may POST here to explain a surface change.
async fn post_notice(Json(req): Json<NoticeRequest>) -> Json<serde_json::Value> {
    let mut text = req.text.replace(['\n', '\r'], " ");
    text.truncate(240);
    if text.is_empty() {
        return Json(serde_json::json!({"ok": false, "error": "empty text"}));
    }
    set_notice(&text);
    Json(serde_json::json!({"ok": true, "delivered": "on next viewer welcome (10 min freshness)"}))
}

#[derive(Deserialize)]
struct CanvasPrefRequest {
    channel: String,
    canvas: String,
}

/// Record which canvas the user chose for a channel (set by open/switch paths).
async fn set_canvas_pref(
    State(state): State<Arc<AppState>>,
    Json(req): Json<CanvasPrefRequest>,
) -> Json<serde_json::Value> {
    if req.channel.is_empty() {
        return Json(serde_json::json!({"ok": false, "error": "channel required"}));
    }
    match req.canvas.as_str() {
        "pup" | "webview" => {
            println!("{} DECISION canvas note '{}'={} (explicit, in-memory)", ts(), req.channel, req.canvas);
            state.store.set_canvas_pref(&req.channel, &req.canvas).await
        },
        // "default" = forget the channel's own choice; follow the global default.
        "default" => state.store.clear_canvas_pref(&req.channel).await,
        _ => return Json(serde_json::json!({"ok": false, "error": "canvas must be pup|webview|default"})),
    }
    Json(serde_json::json!({"ok": true}))
}

async fn get_prefs(State(state): State<Arc<AppState>>) -> Json<crate::store::AppPrefs> {
    Json(state.store.app_prefs().await)
}

#[derive(Deserialize)]
struct PrefsPatch {
    default_canvas: Option<String>,
    read_hook: Option<bool>,
    max_shots: Option<usize>,
    retention_days: Option<u64>,
    update_alerts: Option<bool>,
    webview_width: Option<u8>,
    update_autoswitch: Option<bool>,
}

/// Patch global preferences from the viewer's Preferences dialog.
async fn set_prefs(
    State(state): State<Arc<AppState>>,
    Json(patch): Json<PrefsPatch>,
) -> Json<serde_json::Value> {
    let mut p = state.store.app_prefs().await;
    if let Some(c) = patch.default_canvas {
        if c != "pup" && c != "webview" {
            return Json(serde_json::json!({"ok": false, "error": "default_canvas must be pup|webview"}));
        }
        if c != p.default_canvas {
            // The human just made a NEW explicit choice - stale in-memory
            // click-notes must not override it ("all channels adhere to the
            // GLOBAL default"; notes only exist for clicks made SINCE).
            let cleared = state.store.clear_all_canvas_prefs().await;
            if cleared > 0 {
                println!("{} DECISION default canvas changed to {c} - cleared {cleared} in-memory canvas note(s).", ts());
            }
        }
        p.default_canvas = c;
    }
    if let Some(h) = patch.read_hook {
        p.read_hook = h;
    }
    if let Some(n) = patch.max_shots {
        if !(10..=5000).contains(&n) {
            return Json(serde_json::json!({"ok": false, "error": "max_shots must be 10-5000"}));
        }
        p.max_shots = n;
    }
    if let Some(a) = patch.update_alerts {
        p.update_alerts = a;
    }
    if let Some(a) = patch.update_autoswitch {
        p.update_autoswitch = a;
    }
    if let Some(w) = patch.webview_width {
        if !(10..=50).contains(&w) {
            return Json(serde_json::json!({"ok": false, "error": "webview_width must be 10-50 (percent)"}));
        }
        p.webview_width = w;
    }
    if let Some(d) = patch.retention_days {
        if d > 3650 {
            return Json(serde_json::json!({"ok": false, "error": "retention_days must be 0-3650 (0 = never)"}));
        }
        p.retention_days = d;
    }
    state.store.set_app_prefs(p.clone()).await;
    Json(serde_json::json!({"ok": true, "prefs": p}))
}

#[derive(Deserialize)]
struct CanvasOpenRequest {
    channel: String,
    to: String,
}

/// STAGE 1 of the tracked switch: open the DESTINATION surface only. The
/// viewer then polls /api/viewer-status until the destination viewer's
/// WebSocket actually CONNECTS (the full signal), and only after that calls
/// /api/canvas-close for the source. Never closes anything itself, so a
/// failure at any stage leaves the user's current surface untouched.
async fn canvas_open(
    State(state): State<Arc<AppState>>,
    Json(req): Json<CanvasOpenRequest>,
) -> Json<serde_json::Value> {
    if req.channel.contains("..") || req.channel.contains('/') || req.channel.is_empty() {
        return Json(serde_json::json!({"ok": false, "error": "invalid channel"}));
    }
    if req.to != "pup" && req.to != "webview" {
        return Json(serde_json::json!({"ok": false, "error": "to must be pup|webview"}));
    }
    // The switch expresses the user's canvas CHOICE - remember it so force-open
    // (e.g. a hook inject during a presence blip) reopens the RIGHT canvas.
    state.store.set_canvas_pref(&req.channel, &req.to).await;
    let v = tokio::task::spawn_blocking(move || {
        let exe = std::env::current_exe().unwrap_or_else(|_| "adom-shotlog".into());
        let flag = if req.to == "pup" { "--pup" } else { "--skinny" };
        match std::process::Command::new(exe).env("SHOTLOG_FORCE_SURFACE", "1").args(["open", "-c", &req.channel, flag]).output() {
            Ok(o) => serde_json::json!({"ok": o.status.success(),
                "detail": format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))}),
            Err(e) => serde_json::json!({"ok": false, "error": e.to_string()}),
        }
    })
    .await
    .unwrap_or_else(|_| serde_json::json!({"ok": false, "error": "spawn failed"}));
    let okf = v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false);
    let mut detail = v.get("detail").or_else(|| v.get("error")).and_then(|d| d.as_str()).unwrap_or("").replace('\n', " | ");
    detail.truncate(220);
    println!("{} DECISION canvas-open result ok={okf}{}", ts(),
        if okf { String::new() } else { format!(" detail: {detail}") });
    Json(v)
}

#[derive(Deserialize)]
struct CanvasCloseRequest {
    channel: String,
    surface: String,
}

/// STAGE 3 of the tracked switch (or a rollback): close ONE surface of a
/// channel. surface = "webview" closes the Hydrogen tab; "pup" closes the tab
/// in the shared shotlog pup window.
async fn canvas_close(
    State(state): State<Arc<AppState>>,
    Json(req): Json<CanvasCloseRequest>,
) -> Json<serde_json::Value> {
    if req.channel.contains("..") || req.channel.contains('/') || req.channel.is_empty() {
        return Json(serde_json::json!({"ok": false, "error": "invalid channel"}));
    }
    let channel = req.channel.clone();
    let surface = req.surface.clone();
    let v = tokio::task::spawn_blocking(move || {
        if req.surface == "webview" {
            let _ = &req.channel;
            let name = "Shotlog".to_string();
            let out = std::process::Command::new("adom-cli")
                .args(["hydrogen", "workspace", "remove-tab", "--name", &name])
                .output();
            let mut ok = out.as_ref().map(|o| o.status.success()
                && !String::from_utf8_lossy(&o.stdout).contains("\"error\"")).unwrap_or(false);
            // Last tab in its pane: Hydrogen requires closing the whole pane.
            if !ok {
                let text = out.map(|o| format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))).unwrap_or_default();
                if text.contains("Cannot remove all tabs") {
                    ok = std::process::Command::new("adom-cli")
                        .args(["hydrogen", "workspace", "remove-tab", "--name", &name, "--mode", "all"])
                        .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
                        .status().map(|s| s.success()).unwrap_or(false);
                }
            }
            serde_json::json!({"ok": ok, "surface": "webview"})
        } else if req.surface == "pup" {
            let ok = crate::open::close_pup_tab(&req.channel);
            serde_json::json!({"ok": ok, "surface": "pup"})
        } else {
            serde_json::json!({"ok": false, "error": "surface must be webview|pup"})
        }
    })
    .await
    .unwrap_or_else(|_| serde_json::json!({"ok": false, "error": "spawn failed"}));
    // Closing one surface while the OTHER is live means the other is now the
    // channel's canvas - keep the pref honest (covers rollbacks too).
    if v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) {
        let other = if surface == "pup" { "webview" } else { "pup" };
        if state.store.viewer_infos(&channel).await.iter().any(|i| i.surface == other) {
            state.store.set_canvas_pref(&channel, other).await;
        }
    }
    Json(v)
}

/// Switch a channel between canvases from the viewer UI. For SPEED: open the
/// DESTINATION surface (blocking, so the human sees it appear), then close the
/// SOURCE surface in the BACKGROUND. The old code ran the full `--swap`
/// (open + close) sequentially before responding, so the button sat on
/// "docking…" through the slow laptop round-trip of closing the other surface.
async fn canvas_switch(
    State(state): State<Arc<AppState>>,
    Json(req): Json<CanvasSwitchRequest>,
) -> Json<serde_json::Value> {
    if req.channel.contains("..") || req.channel.contains('/') || req.channel.is_empty() {
        return Json(serde_json::json!({"ok": false, "error": "invalid channel"}));
    }
    if req.to != "pup" && req.to != "webview" {
        return Json(serde_json::json!({"ok": false, "error": "to must be pup|webview"}));
    }
    let port = state.port;
    let channel = req.channel.clone();
    let to = req.to.clone();

    // Close the SOURCE surface in the background — do NOT make the button wait
    // on it. (Closing the pup tab is a slow laptop round-trip; closing a webview
    // tab is a fast Hydrogen call. Either way it's fire-and-forget.)
    {
        let ch = channel.clone();
        let to_bg = to.clone();
        tokio::spawn(async move {
            let _ = tokio::task::spawn_blocking(move || {
                if to_bg == "pup" {
                    // moved to pup → close the webview tab (Hydrogen, fast)
                    let _ = std::process::Command::new("adom-cli")
                        .args(["hydrogen", "workspace", "remove-tab", "--name", &format!("Shots: {ch}")])
                        .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
                        .status();
                } else {
                    // moved to webview → close the pup tab (laptop round-trip)
                    crate::open::close_pup_tab(&ch);
                }
            })
            .await;
        });
    }

    // Open the DESTINATION surface (blocking) via a self-subprocess — isolated
    // so open_pup's process::exit on error can never take down the broker.
    let v = tokio::task::spawn_blocking(move || {
        let exe = std::env::current_exe().unwrap_or_else(|_| "adom-shotlog".into());
        let flag = if to == "pup" { "--pup" } else { "--skinny" };
        let _ = port; // (port defaults to 8820 inside the child)
        match std::process::Command::new(exe)
            .env("SHOTLOG_FORCE_SURFACE", "1")
                .args(["open", "-c", &channel, flag])
            .output()
        {
            Ok(o) => serde_json::json!({"ok": o.status.success(),
                "detail": format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))}),
            Err(e) => serde_json::json!({"ok": false, "error": e.to_string()}),
        }
    })
    .await
    .unwrap_or_else(|_| serde_json::json!({"ok": false, "error": "spawn failed"}));
    Json(v)
}

/// Paste-prompt text served at /prompt — guidance for AI agents on how to inject.
async fn serve_prompt() -> impl IntoResponse {
    let body = "\
Use 'shotlog inject' for all screenshot logging.

shotlog ALWAYS records the original capture resolution. Resizing before inject is
fine and expected — oversized images crash Claude's vision analysis, so most
screenshot verbs return a shrunk copy. The original size just has to travel with
the shot.

If you inject a PRE-SHRUNK image (<=1500px), declare the original — shotlog
REJECTS the inject otherwise. One of:
  --orig-w <W> --orig-h <H>                    (the original dimensions)
  --orig-file <the original full-res image>    (shotlog measures it)
  --native                                     (only if it was NOT resized)

  shotlog inject -c CHANNEL -d \"what and why\" -s pup_screenshot --orig-w 1920 --orig-h 1080 SHRUNK.png

Building a screenshot tool? Have it (1) report the ORIGINAL width/height, and
(2) keep the full-res file available, so callers can pass --orig-w/--orig-h (or
--orig-file). A full-res image also works — shotlog resizes >1500px itself and
records the original.

The description becomes the filename -- say what you are looking at and why.
";
    ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], body)
}

/// Timestamp prefix for decision-log lines (the broker's stdout is the audit
/// trail - deploys APPEND to it, so keep every line greppable + timestamped).
fn ts() -> String {
    chrono::Local::now().format("%m-%d %H:%M:%S").to_string()
}

/// Progressive-disclosure gate: a teaching hint is shown at most `max` times per
/// broker lifetime, then goes quiet. The broker is the long-lived stateful side
/// (CLI processes are one-shot), so the counters live here.
fn hint_gate(key: &str, max: u32) -> bool {
    use std::sync::Mutex;
    use std::collections::HashMap;
    static SEEN: Mutex<Option<HashMap<String, u32>>> = Mutex::new(None);
    let mut guard = SEEN.lock().unwrap();
    let map = guard.get_or_insert_with(HashMap::new);
    let c = map.entry(key.to_string()).or_insert(0);
    *c += 1;
    *c <= max
}

/// Per-key cooldown so a burst of injects can't spam adom-cli. Returns true if
/// the action for `key` may run now (and stamps it), false if still cooling down.
fn debounce_ok(key: &str, secs: u64) -> bool {
    use std::sync::Mutex;
    use std::collections::HashMap;
    use std::time::Instant;
    static LAST: Mutex<Option<HashMap<String, Instant>>> = Mutex::new(None);
    let mut guard = LAST.lock().unwrap();
    let map = guard.get_or_insert_with(HashMap::new);
    if let Some(t) = map.get(key) {
        if t.elapsed().as_secs() < secs {
            return false;
        }
    }
    map.insert(key.to_string(), Instant::now());
    true
}

/// Decide how to surface a new shot, based on which surface(s) are watching.
/// Never steals the human's OS foreground; all webview targeting is by the tab's
/// UNIQUE name (`Shots: <channel>`) so, with many shotlogs open in parallel, only
/// THIS channel's tab is ever touched.
///  - **webview** open for this channel → bring THAT tab **forward** (activate it)
///    so the update is seen. Done in code, on by default.
///  - **only pup / phone** (no webview) → leave their foreground alone; the
///    viewer's own in-page title cue flags the backgrounded tab and the AI is
///    hinted to flash the pup taskbar.
///  - **nobody** watching → force-open a slim ~20% webview, unless
///    `SHOTLOG_NO_FORCE_OPEN` is set.
fn notify_surface(channel: &str, port: u16, infos: &[crate::store::ViewerInfo], pref: &str, alerts: bool, ratio: f64) {
    // Pup viewers get an automatic ORANGE TASKBAR FLASH (FlashWindowEx) on their
    // hosting window — a gentle "update" cue, never a foreground steal. Works when
    // the viewer URL carried ?session=<pup sessionId> (reported in the WS hello).
    let pup_sessions: std::collections::HashSet<String> = infos
        .iter()
        .filter(|i| i.surface == "pup")
        .filter_map(|i| i.session.clone())
        .collect();
    let has_pup = infos.iter().any(|i| i.surface == "pup");
    if alerts {
        for s in pup_sessions {
            flash_pup_session(&s);
        }
    }
    // In the single-window app, per-channel tab surfacing happens IN-APP
    // (the viewer's own tab strip); no external pup tab switching needed.
    let _ = has_pup;

    let has_webview = infos.iter().any(|i| i.surface == "webview");
    if has_webview {
        bring_webview_forward(channel, port, alerts);
        // The webview FALLBACK is a lifeboat, not a destination. If the human's
        // canvas says pup and the only thing connected is a webview (classic:
        // AD was offline, we fell back, AD came back), each new shot buys one
        // cooled-down attempt to bring pup up - and once pup CONNECTS, the
        // fallback webview closes. Staged like the UI switch: nothing closes
        // until the destination is live.
        if pref == "pup" && !has_pup {
            recover_pup_from_fallback(channel, port);
        }
        return;
    }
    if infos.is_empty() && std::env::var("SHOTLOG_NO_FORCE_OPEN").is_err() {
        // Honor the channel's chosen canvas (falling back to the global default):
        // a pup channel whose viewer blipped must NOT get a webview shoved at it.
        println!("{} DECISION force-open '{channel}' canvas={pref} (nobody watching)", ts());
        if pref == "pup" {
            force_open_pup(channel, ratio);
        } else {
            force_open_viewer(channel, port, ratio);
        }
    }
}

/// Force-open the PUP canvas for a channel (its chosen canvas) when a shot
/// lands with nobody watching. Runs via a self-subprocess (open_pup can exit).
/// If pup genuinely cannot open, falls back to a webview - never invisible.
fn force_open_pup(channel: &str, fallback_ratio: f64) {
    // Debounce longer than the whole retry window so attempts never overlap.
    if !debounce_ok(&format!("openpup:{channel}"), 240) {
        return;
    }
    let channel = channel.to_string();
    std::thread::spawn(move || {
        // PATIENT: right after a laptop reboot Adom Desktop takes a minute to
        // reconnect. Retry pup before surrendering to the webview fallback -
        // the user's pref says pup, so give pup a real chance to come up.
        let tries: u32 = std::env::var("SHOTLOG_PUP_RETRY_N").ok().and_then(|v| v.parse().ok()).unwrap_or(6);
        let wait_s: u64 = std::env::var("SHOTLOG_PUP_RETRY_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(20);
        let exe = std::env::current_exe().unwrap_or_else(|_| "adom-shotlog".into());
        for attempt in 1..=tries {
            let ok = std::process::Command::new(&exe)
                .env("SHOTLOG_FORCE_SURFACE", "1")
                .args(["open", "-c", &channel, "--pup"])
                .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
                .status().map(|s| s.success()).unwrap_or(false);
            if ok {
                if attempt > 1 {
                    println!("OK: pup force-open for '{channel}' succeeded on attempt {attempt} (Adom Desktop is back).");
                }
                return;
            }
            println!("WARN: pup force-open for '{channel}' attempt {attempt}/{tries} failed (Adom Desktop offline / reconnecting?){}",
                if attempt < tries { format!(" - retrying in {wait_s}s") } else { String::new() });
            if attempt < tries {
                std::thread::sleep(std::time::Duration::from_secs(wait_s));
            }
        }
        println!("{} WARN: pup never came up for '{channel}' - falling back to a webview so the shots aren't invisible (no choice recorded).", ts());
        set_notice("Tried to open pup (your default) - it was unreachable, so I fell back to this webview so shots stay visible. I'll keep retrying and move you back to pup automatically once it's up.");
        crate::open::open_webview(&channel, None, None, 8820, true, fallback_ratio);
    });
}

/// Recover from the webview fallback once pup is reachable again: try pup once
/// (cooldown 5 min), wait for a REAL pup viewer connect, then close the
/// fallback webview. Never closes anything while pup isn't live.
fn recover_pup_from_fallback(channel: &str, port: u16) {
    if !debounce_ok("puprecover", 300) {
        return;
    }
    let channel = channel.to_string();
    std::thread::spawn(move || {
        println!("{} DECISION default canvas is pup but only a webview is connected (fallback residue) - retrying pup.", ts());
        let exe = std::env::current_exe().unwrap_or_else(|_| "adom-shotlog".into());
        let ok = std::process::Command::new(&exe)
            .env("SHOTLOG_FORCE_SURFACE", "1")
                .args(["open", "-c", &channel, "--pup"])
            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
            .status().map(|s| s.success()).unwrap_or(false);
        if !ok {
            println!("{} DECISION pup still unreachable - staying on the fallback webview.", ts());
            return;
        }
        for _ in 0..15 {
            std::thread::sleep(std::time::Duration::from_secs(2));
            if crate::open::broker_has_pup_viewer(port) {
                println!("{} DECISION pup is back and CONNECTED - closing the fallback webview (it was never a choice).", ts());
                set_notice("pup is back - your shotlog moved here and the fallback webview was closed.");
                let out = std::process::Command::new("adom-cli")
                    .args(["hydrogen", "workspace", "remove-tab", "--name", "Shotlog"])
                    .output();
                let mut closed = out.as_ref().map(|o| o.status.success()
                    && !String::from_utf8_lossy(&o.stdout).contains("\"error\"")).unwrap_or(false);
                if !closed {
                    // Last tab in its pane: Hydrogen requires closing the pane.
                    let text = out.map(|o| format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))).unwrap_or_default();
                    if text.contains("Cannot remove all tabs") {
                        closed = std::process::Command::new("adom-cli")
                            .args(["hydrogen", "workspace", "remove-tab", "--name", "Shotlog", "--mode", "all"])
                            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
                            .status().map(|s| s.success()).unwrap_or(false);
                    }
                }
                if !closed {
                    println!("{} WARN: fallback webview tab close failed - pup is live, human may see both surfaces.", ts());
                }
                return;
            }
        }
        println!("{} DECISION pup open reported ok but no pup viewer connected within 30s - leaving the webview alone.", ts());
    });
}

/// Orange taskbar flash on the pup window hosting a viewer. Debounced per session
/// so a burst of injects flashes once, not strobe.
fn flash_pup_session(session: &str) {
    if !debounce_ok(&format!("pupflash:{session}"), 8) {
        return;
    }
    let session = session.to_string();
    std::thread::spawn(move || {
        let mut cmd = std::process::Command::new("adom-desktop");
        if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") {
            cmd.args(["--target", &t]);
        }
        let _ = cmd
            .args(["browser_alert_window", &format!("{{\"sessionId\":\"{session}\"}}")])
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status();
    });
}

/// Bring THIS channel's already-open webview tab to the front. Finds the tab by
/// its panelState URL (`/log/<channel>/` — unique per channel, so parallel
/// shotlog tabs are never cross-touched), then `move-tab` to its own pane:
/// Hydrogen auto-activates it (surfaces to top) and `--alert` paints the ORANGE
/// update highlight on the tab. Falls back to create-or-refresh if not found.
fn bring_webview_forward(channel: &str, port: u16, alerts: bool) {
    if !debounce_ok(&format!("fwd:{channel}"), 2) {
        return;
    }
    let channel = channel.to_string();
    std::thread::spawn(move || {
        // GENTLE activate: `workspace active-tab` brings the tab to the front
        // WITHIN ITS OWN PANE — no tab moves, no iframe re-parenting, no
        // workspace-tree re-render. (The old approach bounced the tab through
        // the user's VS Code pane via move-tab, which re-rendered the whole
        // Hydrogen page and interrupted typing. Never do that.)
        let _ = &channel;
        let tab = "Shotlog".to_string();
        let activated = std::process::Command::new("adom-cli")
            .args(["hydrogen", "workspace", "active-tab", "--name", &tab])
            .output()
            .map(|o| o.status.success()
                && String::from_utf8_lossy(&o.stdout).contains("204"))
            .unwrap_or(false);
        if activated {
            // Orange update indicator on the (now-front) tab - user-toggleable.
            if alerts {
                let _ = std::process::Command::new("adom-cli")
                    .args(["hydrogen", "alert", "set", "--name", &tab, "-d", "6000"])
                    .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
                    .status();
            }
            return;
        }
        // Tab not found: do NOTHING. A webview VIEWER with no Hydrogen tab is a
        // plain page reporting surface=webview (e.g. a headless/test client) -
        // it already receives updates over its WebSocket. Creating a tab here
        // materialized phantom webview tabs against the user's pup preference.
        let _ = port;
    });
}

/// Force-open the viewer for a channel when a shot lands with NOBODY watching.
/// Fallback only (see notify_surface). Debounced per-channel. Defaults to a slim
/// ~20%-wide right-side panel so cross-thread shots auto-appear without taking
/// over the workspace — and CRUCIALLY passes a --panel-id (via open_webview) so
/// the tab is actually CREATED, not silently skipped.
fn force_open_viewer(channel: &str, port: u16, ratio: f64) {
    if std::env::var("VSCODE_PROXY_URI").is_err() {
        return; // not in an Adom container; nothing to open
    }
    if !debounce_ok(&format!("open:{channel}"), 60) {
        return; // opened recently
    }
    let channel = channel.to_string();
    // Best-effort; don't block the inject response on the adom-cli round-trip.
    std::thread::spawn(move || {
        crate::open::open_webview(&channel, None, None, port, true, ratio);
    });
}

#[derive(Deserialize)]
struct InjectRequest {
    channel: String,
    image: String,
    description: String,
    source: Option<String>,
    filename: Option<String>,
    /// Caller-reported original dimensions (when the caller resized before sending).
    orig_w: Option<u32>,
    orig_h: Option<u32>,
    /// Free-form capture metadata (Adom Desktop coordMap / dpiScale / screenRect /
    /// screen resolution, shotId, ownedPopupCount, …). Stored verbatim on the entry.
    meta: Option<serde_json::Value>,
    /// Owned-popup screenshots. Each object carries an `image` data-URL plus any
    /// metadata (hwnd, title, kind, coordMap, shotId). The `image` is saved as its
    /// own PNG and replaced with `filename` in the stored array.
    popups: Option<Vec<serde_json::Value>>,
    /// Which AI thread produced this shot (human-readable title). The read-hook
    /// fills this from the session transcript; any injector may pass it.
    thread: Option<String>,
    /// WHY the shot exists: the user prompt the thread was serving (the
    /// read-hook extracts it programmatically from the transcript).
    context: Option<String>,
    /// Absolute path of the ORIGINAL full-res image (when the caller passed
    /// --orig-file): the broker stores a copy beside the shot as {stem}__full.png.
    orig_path: Option<String>,
}

/// Images larger than this on any edge are auto-resized (Claude crashes >1500px).
const MAX_EDGE: u32 = 1500;
/// Target edge for the auto-resize.
const RESIZE_TO: u32 = 1400;

#[derive(Serialize)]
struct InjectResponse {
    ok: bool,
    id: u64,
    name: String,
    path: String,
    w: u32,
    h: u32,
    kb: u64,
    bytes: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    original_w: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    original_h: Option<u32>,
    /// Live viewers connected at inject time, and their surfaces — so the caller
    /// can immediately tell whether the human is actually seeing this shot.
    viewers: usize,
    surfaces: Vec<String>,
    /// Of those, how many are actually ON-SCREEN (Page Visibility) vs a
    /// background tab / minimized window, and which surfaces those are.
    visible_viewers: usize,
    visible_surfaces: Vec<String>,
    /// What the broker is DOING about this shot (webview_surface / pup_flash /
    /// force_open / none) — so the calling AI knows surfacing is handled and
    /// never foregrounds anything itself.
    #[serde(skip_serializing_if = "Option::is_none")]
    surfacing: Option<serde_json::Value>,
    /// Progressive teaching hints (mini-skills), revealed a few times then quiet.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    hints: Vec<String>,
}

async fn post_shots(
    State(state): State<Arc<AppState>>,
    Json(req): Json<InjectRequest>,
) -> Result<Json<InjectResponse>, (StatusCode, String)> {
    let (_mime, raw_bytes) =
        parse_data_url(&req.image).map_err(|e| (StatusCode::BAD_REQUEST, e))?;

    let (incoming_w, incoming_h) = read_png_dimensions(&raw_bytes);

    // Auto-resize anything over the limit, recording the TRUE original. This is
    // how orig dims get captured at the source: shotlog owns the resize, so it
    // always knows what the image was before shrinking. No caller pre-resize needed.
    let (bytes, w, h, orig_w, orig_h) = if incoming_w > MAX_EDGE || incoming_h > MAX_EDGE {
        let (resized, nw, nh) =
            resize_png(raw_bytes, RESIZE_TO).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
        (resized, nw, nh, Some(incoming_w), Some(incoming_h))
    } else {
        // Image already within limits. If the caller pre-resized externally and
        // reported the original via orig_w/orig_h, honor it; otherwise none.
        (raw_bytes, incoming_w, incoming_h, req.orig_w, req.orig_h)
    };

    let raw_name = req.filename.unwrap_or_else(|| slugify_filename(&req.description));
    let channel_dir = state.store.base_dir().join(&req.channel);
    let filename = unique_filename(&channel_dir, &raw_name);

    let source = req.source.unwrap_or_else(|| "ai".into());

    // Owned popups: save each popup's image as its own PNG next to the primary,
    // strip the data-URL from the stored object, and record its filename + dims.
    let popups_stored = if let Some(popups) = req.popups {
        let stem = filename.trim_end_matches(".png");
        let mut out = Vec::new();
        std::fs::create_dir_all(&channel_dir).ok();
        for (i, mut p) in popups.into_iter().enumerate() {
            let Some(img) = p.get("image").and_then(|v| v.as_str()).map(|s| s.to_string()) else { continue };
            let Ok((_m, pbytes)) = parse_data_url(&img) else { continue };
            let (pw, ph) = read_png_dimensions(&pbytes);
            let pname = format!("{stem}__popup{}.png", i + 1);
            std::fs::write(channel_dir.join(&pname), &pbytes).ok();
            if let Some(obj) = p.as_object_mut() {
                obj.remove("image");
                obj.insert("filename".into(), serde_json::json!(pname));
                obj.insert("w".into(), serde_json::json!(pw));
                obj.insert("h".into(), serde_json::json!(ph));
                obj.insert("kb".into(), serde_json::json!(pbytes.len() / 1024));
            }
            out.push(p);
        }
        if out.is_empty() { None } else { Some(serde_json::Value::Array(out)) }
    } else { None };

    let had_meta = req.meta.is_some() || popups_stored.is_some();
    let source_str = source.clone();

    let entry = state
        .store
        .add_entry(&req.channel, bytes, filename, req.description, source, w, h, orig_w, orig_h, req.meta, popups_stored, req.thread.clone(), req.context.clone(), req.orig_path.clone())
        .await;

    // Presence at inject time — the caller uses this to know if the human is
    // actually watching (and on which surface / on-screen) without guessing.
    let infos = state.store.viewer_infos(&req.channel).await;
    let viewers = infos.len();
    let surfaces: Vec<String> = infos.iter().map(|i| i.surface.clone()).collect();
    let visible_viewers = infos.iter().filter(|i| i.visible).count();
    let visible_surfaces: Vec<String> = infos.iter().filter(|i| i.visible).map(|i| i.surface.clone()).collect();

    // Decide + REPORT the surfacing action (so the calling AI knows it's handled),
    // then fire it in the background. Never steals the human's foreground.
    let has_webview = infos.iter().any(|i| i.surface == "webview");
    let pup_sessions: Vec<String> = {
        let s: std::collections::HashSet<String> = infos.iter()
            .filter(|i| i.surface == "pup").filter_map(|i| i.session.clone()).collect();
        s.into_iter().collect()
    };
    let force_open_on = std::env::var("SHOTLOG_NO_FORCE_OPEN").is_err();
    let app_prefs = state.store.app_prefs().await;
    let alerts_on = app_prefs.update_alerts;
    let canvas_pref = match state.store.canvas_pref("_app").await {
        Some(c) => c,
        None => match state.store.canvas_pref(&req.channel).await {
            Some(c) => c,
            None => app_prefs.default_canvas,
        },
    };
    let mut actions: Vec<serde_json::Value> = Vec::new();
    if !pup_sessions.is_empty() && alerts_on {
        actions.push(serde_json::json!({"action":"pup_flash","sessions":pup_sessions,"cue":"orange taskbar flash"}));
    }
    if has_webview {
        actions.push(serde_json::json!({"action":"webview_surface","tab":format!("Shots: {}", req.channel),
            "cue": if alerts_on {"tab pulled to front + orange alert"} else {"tab pulled to front (update alerts off)"}}));
    } else if infos.is_empty() && force_open_on {
        actions.push(serde_json::json!({"action":"force_open","canvas": canvas_pref,
            "layout": if canvas_pref=="pup" {"tab in the shared pup window"} else {"slim 20% right-side webview"}}));
    }
    let surfacing = Some(serde_json::json!({"actions": actions}));
    let ratio = (app_prefs.webview_width as f64 / 100.0).clamp(0.10, 0.50);
    notify_surface(&req.channel, state.port, &infos, &canvas_pref, alerts_on, ratio);

    // Progressive mini-skill hints — taught at the moment they're relevant, then
    // quiet (hint_gate caps how many times each lesson repeats).
    let mut hints: Vec<String> = Vec::new();
    if entry.id == 1 && hint_gate(&format!("teach:new-channel:{}", req.channel), 1) {
        hints.push(format!(
            "mini-skill [surfacing]: first shot in '{ch}'. To open a viewer use PLAIN `shotlog open -c {ch}` - it follows the human's default canvas (webview or pup); only pass --skinny/--pup when the human explicitly asked for that canvas. I auto-open a 20% webview when nobody is watching, pull the channel's tab to the front with an orange alert on updates, and flash pup taskbars — never foreground anything yourself. Gate on visibility with `shotlog viewers -c {ch}` (exit 0=on-screen, 4=backgrounded, 3=nobody); see every channel with `shotlog channels`.",
            ch = req.channel));
    }
    if source_str.contains("desktop_screenshot") && !had_meta && hint_gate(&format!("teach:meta:{}", req.channel), 2) {
        hints.push("mini-skill [child-windows]: this source looks like an Adom Desktop window capture. AD returns the window's CHILD WINDOWS (owned dropdowns/dialogs, `screenshots[]`/`ownedPopupCount`) + coordMap in the SAME response — pass that whole JSON via `--meta` and they all render inline on this card, and the original resolution comes free.".into());
    }
    if infos.iter().any(|i| i.surface == "pup" && i.session.is_none()) && hint_gate(&format!("teach:pup-session:{}", req.channel), 2) {
        hints.push("mini-skill [surfacing]: a pup viewer is watching WITHOUT a ?session= handle, so I cannot flash its taskbar for you. Open pup viewers with `?surface=pup&session=<pup sessionId>` and I flash that window orange automatically on every update.".into());
    }

    // Dual-surface check: same channel on webview AND pup is usually an accident
    // (the human asked to "switch" and the AI opened pup without closing webview).
    let has_webview_v = infos.iter().any(|i| i.surface == "webview");
    let has_pup_v = infos.iter().any(|i| i.surface == "pup");
    if has_webview_v && has_pup_v && hint_gate(&format!("teach:dual-surface:{}", req.channel), 3) {
        hints.push(format!(
            "you are showing '{ch}' on TWO surfaces at once (webview + pup). Did you mean to? Most users just want one surface. If you picked pup, close the webview and stop sending to it: `shotlog open -c {ch} --pup --swap` (or `adom-cli hydrogen workspace remove-tab --name \"Shots: {ch}\"`). The broker then stops pulling the webview tab forward on updates.",
            ch = req.channel));
    }
    let webview_only = !infos.is_empty() && infos.iter().all(|i| i.surface == "webview");
    if webview_only && hint_gate(&format!("teach:canvas:{}", req.channel), 2) {
        hints.push(format!(
            "mini-skill [surfacing]: '{ch}' is only being watched in the SKINNY webview. If the human wants to inspect images full-size (zoom in the skinny panel can't get bigger than the panel), switch canvases: `shotlog open -c {ch} --pup` opens the full-browser pup window (one shared window, a tab per channel, taskbar flashes orange on updates). Add `--swap` to close the webview tab so pup becomes the channel's canvas.",
            ch = req.channel));
    }

    Ok(Json(InjectResponse {
        ok: true,
        id: entry.id,
        name: entry.filename,
        path: entry.path,
        w: entry.w,
        h: entry.h,
        kb: entry.kb,
        bytes: entry.bytes,
        original_w: entry.orig_w,
        original_h: entry.orig_h,
        viewers,
        surfaces,
        visible_viewers,
        visible_surfaces,
        surfacing,
        hints,
    }))
}

#[derive(Deserialize)]
struct PasteRequest {
    channel: String,
    #[serde(rename = "dataUrl")]
    data_url: String,
    description: Option<String>,
    // Provenance is DECLARED, never assumed. The viewer's Ctrl+V handler sends
    // source:"clipboard"; programmatic clients say who they are (or default to
    // "api", which renders chip-less like a CLI inject). Stamping every paste
    // "clipboard" mislabeled AI-pushed shots as human pastes.
    source: Option<String>,
    thread: Option<String>,
    context: Option<String>,
}

async fn post_paste(
    State(state): State<Arc<AppState>>,
    Json(req): Json<PasteRequest>,
) -> Result<Json<InjectResponse>, (StatusCode, String)> {
    let (_mime, raw_bytes) =
        parse_data_url(&req.data_url).map_err(|e| (StatusCode::BAD_REQUEST, e))?;

    let (orig_w, orig_h) = read_png_dimensions(&raw_bytes);
    let orig_bytes = raw_bytes.len() as u64;

    let (bytes, w, h) =
        resize_png(raw_bytes, RESIZE_TO).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;

    let was_resized = w != orig_w || h != orig_h;

    let source = req.source.clone().unwrap_or_else(|| "api".into());
    let kind = if source == "clipboard" { "Clipboard paste" } else { "API paste" };
    let description = req.description.unwrap_or_else(|| {
        if was_resized {
            format!(
                "{} (resized from {}x{}, {:.0} KB to {}x{}, {:.0} KB)",
                kind, orig_w, orig_h, orig_bytes as f64 / 1024.0,
                w, h, bytes.len() as f64 / 1024.0,
            )
        } else {
            format!("{} ({}x{}, {:.0} KB)", kind, w, h, bytes.len() as f64 / 1024.0)
        }
    });

    let raw_name = slugify_filename(&description);
    let channel_dir = state.store.base_dir().join(&req.channel);
    let filename = unique_filename(&channel_dir, &raw_name);

    let entry = state
        .store
        .add_entry(
            &req.channel, bytes, filename, description, source, w, h,
            if was_resized { Some(orig_w) } else { None },
            if was_resized { Some(orig_h) } else { None },
            None, None, req.thread.clone(), req.context.clone(), None,
        )
        .await;


    let infos = state.store.viewer_infos(&req.channel).await;
    let viewers = infos.len();
    let surfaces: Vec<String> = infos.iter().map(|i| i.surface.clone()).collect();
    let visible_viewers = infos.iter().filter(|i| i.visible).count();
    let visible_surfaces: Vec<String> = infos.iter().filter(|i| i.visible).map(|i| i.surface.clone()).collect();

    Ok(Json(InjectResponse {
        ok: true,
        id: entry.id,
        name: entry.filename,
        path: entry.path,
        w: entry.w,
        h: entry.h,
        kb: entry.kb,
        bytes: entry.bytes,
        original_w: if was_resized { Some(orig_w) } else { None },
        original_h: if was_resized { Some(orig_h) } else { None },
        viewers,
        surfaces,
        visible_viewers,
        visible_surfaces,
        surfacing: None,
        hints: Vec::new(),
    }))
}

#[derive(Deserialize)]
struct OpenRequest {
    path: String,
}

async fn open_in_vscode(
    Json(req): Json<OpenRequest>,
) -> Json<serde_json::Value> {
    if req.path.contains("..") {
        return Json(serde_json::json!({ "ok": false, "error": "invalid path" }));
    }

    // Detect if path is a directory (reveal) or file (open)
    let is_dir = std::path::Path::new(&req.path).is_dir();
    let cmd = if is_dir { "reveal" } else { "open" };

    let result = std::process::Command::new("adom-vscode")
        .args([cmd, &req.path])
        .output()
        .or_else(|_| {
            std::process::Command::new(
                "/usr/lib/code-server/lib/vscode/bin/remote-cli/code-linux.sh",
            )
            .args(["--reuse-window", &req.path])
            .output()
        });

    match result {
        Ok(output) if output.status.success() => {
            Json(serde_json::json!({ "ok": true }))
        }
        Ok(output) => {
            let err = String::from_utf8_lossy(&output.stderr);
            Json(serde_json::json!({ "ok": false, "error": err.to_string() }))
        }
        Err(e) => {
            Json(serde_json::json!({ "ok": false, "error": e.to_string() }))
        }
    }
}

async fn reveal_in_vscode(
    Json(req): Json<OpenRequest>,
) -> Json<serde_json::Value> {
    if req.path.contains("..") {
        return Json(serde_json::json!({ "ok": false, "error": "invalid path" }));
    }

    // Ensure directory exists before revealing
    std::fs::create_dir_all(&req.path).ok();

    let result = std::process::Command::new("adom-vscode")
        .args(["reveal", &req.path])
        .output();

    match result {
        Ok(output) if output.status.success() => {
            Json(serde_json::json!({ "ok": true }))
        }
        Ok(output) => {
            let err = String::from_utf8_lossy(&output.stderr);
            Json(serde_json::json!({ "ok": false, "error": err.to_string() }))
        }
        Err(e) => {
            Json(serde_json::json!({ "ok": false, "error": e.to_string() }))
        }
    }
}

async fn clear_log(
    State(state): State<Arc<AppState>>,
    Query(q): Query<ChannelQuery>,
) -> Json<serde_json::Value> {
    let channel = q.channel.unwrap_or_else(|| "default".into());
    let count = state.store.clear_log(&channel).await;
    Json(serde_json::json!({ "ok": true, "cleared": count }))
}

async fn clear_all(
    State(state): State<Arc<AppState>>,
    Query(q): Query<ChannelQuery>,
) -> Json<serde_json::Value> {
    let channel = q.channel.unwrap_or_else(|| "default".into());
    let count = state.store.clear_all(&channel).await;
    Json(serde_json::json!({ "ok": true, "cleared": count, "files_deleted": true }))
}

#[derive(Deserialize)]
struct DeleteQuery {
    channel: Option<String>,
    filename: Option<String>,
}

async fn delete_entry(
    State(state): State<Arc<AppState>>,
    Query(q): Query<DeleteQuery>,
) -> Json<serde_json::Value> {
    let channel = q.channel.unwrap_or_else(|| "default".into());
    let filename = match q.filename {
        Some(f) => f,
        None => return Json(serde_json::json!({ "ok": false, "error": "missing filename" })),
    };

    // Delete the PNG and JSON sidecar from disk
    let channel_dir = state.store.base_dir().join(&channel);
    let png_path = channel_dir.join(&filename);
    let json_path = png_path.with_extension("json");
    std::fs::remove_file(&png_path).ok();
    std::fs::remove_file(&json_path).ok();

    // Remove from in-memory entries
    state.store.delete_entry(&channel, &filename).await;

    Json(serde_json::json!({ "ok": true, "deleted": filename }))
}

// WebSocket: client connects to /ws/<channel>, receives new entries as JSON text frames
/// The single-window app's WebSocket: EVERY channel's new entries as
/// {type:"entry", channel, entry}. Presence registers under "_all" (an app
/// viewer watches every channel at once).
async fn ws_all_handler(
    ws: WebSocketUpgrade,
    State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
    ws.on_upgrade(move |socket| ws_all_connection(socket, state))
}

async fn ws_all_connection(mut socket: WebSocket, state: Arc<AppState>) {
    let mut rx = state.store.all_tx.subscribe();
    let mut ctrl_rx = state.store.ctrl_tx.subscribe();
    let vid = state.next_viewer_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    let mut conn_surface: String = "browser".into();
    state.store.register_viewer("_all", vid, "browser".into(), true, None).await;
    let _ = socket.send(Message::Text(
        {
        let mut w = serde_json::json!({"type": "welcome", "version": boot_stamp()});
        if let Some(n) = take_notice() { w["notice"] = serde_json::json!(n); }
        w.to_string().into()
    }
    )).await;
    loop {
        tokio::select! {
            result = rx.recv() => {
                match result {
                    Ok((channel, entry)) => {
                        let json = serde_json::json!({"type": "entry", "channel": channel, "entry": entry});
                        if socket.send(Message::Text(json.to_string().into())).await.is_err() { break; }
                    }
                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
                    Err(_) => break,
                }
            }
            ctrl = ctrl_rx.recv() => {
                match ctrl {
                    Ok(frame) => {
                        if socket.send(Message::Text(frame.into())).await.is_err() { break; }
                    }
                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
                    Err(_) => break,
                }
            }
            msg = socket.recv() => {
                match msg {
                    Some(Ok(Message::Close(_))) | None => break,
                    Some(Ok(Message::Text(t))) => {
                        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&t) {
                            match v.get("type").and_then(|x| x.as_str()) {
                                Some("hello") => {
                                    let surface = v.get("surface").and_then(|x| x.as_str()).unwrap_or("browser").to_string();
                                    let visible = v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true);
                                    let session = v.get("session").and_then(|x| x.as_str()).map(String::from);
                                    conn_surface = surface.clone();
                                    state.store.register_viewer("_all", vid, surface, visible, session).await;
                                }
                                Some("vis") => {
                                    let visible = v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true);
                                    state.store.set_viewer_visibility("_all", vid, visible).await;
                                }
                                _ => {}
                            }
                        }
                    }
                    _ => {}
                }
            }
        }
    }
    state.store.unregister_viewer("_all", vid).await;
    // User-closed-pup detection for the SINGLE app window: sets an in-memory
    // app-level note so the next force-open uses webview this session.
    if conn_surface == "pup" {
        let store = state.store.clone();
        tokio::spawn(async move {
            tokio::time::sleep(std::time::Duration::from_secs(8)).await;
            if store.viewer_infos("_all").await.iter().any(|i| i.surface == "pup") { return; }
            let verdict: Option<bool> = tokio::task::spawn_blocking(move || {
                let mut cmd = std::process::Command::new("adom-desktop");
                if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") { cmd.args(["--target", &t]); }
                match cmd.args(["browser_list_tabs", "{\"sessionId\":\"shotlog\"}"]).output() {
                    Ok(o) => {
                        let out = String::from_utf8_lossy(&o.stdout).to_string();
                        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&out) {
                            if let Some(tabs) = v.get("tabs").and_then(|t| t.as_array()) {
                                return Some(!tabs.iter().any(|t| t.get("url").and_then(|u| u.as_str())
                                    .map(|u| u.contains("/log/")).unwrap_or(false)));
                            }
                            if out.contains("session_not_found") { return Some(true); }
                        }
                        None
                    }
                    Err(_) => None,
                }
            }).await.unwrap_or(None);
            if verdict == Some(true) && !store.viewer_infos("_all").await.iter().any(|i| i.surface == "pup") {
                store.set_canvas_pref("_app", "webview").await;
                println!("{} DECISION shotlog pup window closed by the user - app note set to webview until restart.", ts());
            }
        });
    }
}

async fn ws_handler(
    ws: WebSocketUpgrade,
    Path(channel): Path<String>,
    State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
    ws.on_upgrade(move |socket| ws_connection(socket, channel, state))
}

async fn ws_connection(mut socket: WebSocket, channel: String, state: Arc<AppState>) {
    let mut rx = state.store.subscribe(&channel).await;

    // Register this connection's presence. The client sends {type:'hello',surface}
    // right after connecting; until then we record it as an unknown surface so the
    // live-viewer COUNT is right immediately even before the label arrives.
    let vid = state
        .next_viewer_id
        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    let mut conn_surface: String = "browser".into();
    state.store.register_viewer(&channel, vid, "browser".into(), true, None).await;
    // Version handshake: stale pages reload themselves on mismatch.
    let _ = socket.send(Message::Text(
        {
        let mut w = serde_json::json!({"type": "welcome", "version": boot_stamp()});
        if let Some(n) = take_notice() { w["notice"] = serde_json::json!(n); }
        w.to_string().into()
    }
    )).await;

    loop {
        tokio::select! {
            result = rx.recv() => {
                match result {
                    Ok(entry) => {
                        let json = serde_json::json!({
                            "type": "entry",
                            "entry": entry,
                        });
                        if socket.send(Message::Text(json.to_string().into())).await.is_err() {
                            break; // client disconnected
                        }
                    }
                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
                    Err(_) => break,
                }
            }
            msg = socket.recv() => {
                match msg {
                    Some(Ok(Message::Close(_))) | None => break,
                    Some(Ok(Message::Text(t))) => {
                        // {type:'hello', surface, visible} on connect; {type:'vis', visible}
                        // whenever the tab is shown/hidden (Page Visibility).
                        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&t) {
                            match v.get("type").and_then(|x| x.as_str()) {
                                Some("hello") => {
                                    let surface = v.get("surface").and_then(|x| x.as_str()).unwrap_or("browser").to_string();
                                    let visible = v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true);
                                    let session = v.get("session").and_then(|x| x.as_str()).map(String::from);
                                    conn_surface = surface.clone();
                                    state.store.register_viewer(&channel, vid, surface, visible, session).await;
                                }
                                Some("vis") => {
                                    let visible = v.get("visible").and_then(|x| x.as_bool()).unwrap_or(true);
                                    state.store.set_viewer_visibility(&channel, vid, visible).await;
                                }
                                _ => {}
                            }
                        }
                    }
                    _ => {} // ignore other client messages
                }
            }
        }
    }

    state.store.unregister_viewer(&channel, vid).await;

    // USER-CLOSED-PUP DETECTION. A pup viewer vanishing can be a blip (reload,
    // network, laptop sleep) or the user deliberately closing the tab/window.
    // Grace-wait for a reconnect, then probe whether the tab still exists:
    // gone + no reconnect = the user closed it = their canvas choice CHANGED.
    // Revert the channel to webview so the next auto-open lands in the slim
    // panel instead of shoving a fresh pup window at them. (Swap flows are
    // immune: they re-point the pref BEFORE closing, so pref!=pup here.)
    if conn_surface == "pup" {
        let store = state.store.clone();
        let ch = channel.clone();
        tokio::spawn(async move {
            if store.canvas_pref(&ch).await.as_deref() != Some("pup") {
                return;
            }
            tokio::time::sleep(std::time::Duration::from_secs(8)).await;
            if store.viewer_infos(&ch).await.iter().any(|i| i.surface == "pup") {
                return; // reconnected - just a blip
            }
            let ch2 = ch.clone();
            // Three-way verdict - flip ONLY on DEFINITIVE evidence of a user close:
            //   Some(true)  = AD reachable AND (session alive with the tab absent,
            //                 OR session_not_found while AD is up) -> user closed it
            //   Some(false) = tab still there -> blip/reload
            //   None        = AD offline / error / can't tell -> a REBOOT looks
            //                 exactly like this; never mistake it for a choice.
            let verdict: Option<bool> = tokio::task::spawn_blocking(move || {
                let mut cmd = std::process::Command::new("adom-desktop");
                if let Ok(t) = std::env::var("SHOTLOG_AD_TARGET") {
                    cmd.args(["--target", &t]);
                }
                match cmd.args(["browser_list_tabs", "{\"sessionId\":\"shotlog\"}"]).output() {
                    Ok(o) => {
                        let out = String::from_utf8_lossy(&o.stdout).to_string();
                        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&out) {
                            if let Some(tabs) = v.get("tabs").and_then(|t| t.as_array()) {
                                return Some(!tabs.iter().any(|t| t.get("url").and_then(|u| u.as_str())
                                    .map(|u| u.contains(&format!("/log/{ch2}/"))).unwrap_or(false)));
                            }
                            if out.contains("session_not_found") {
                                // AD answered; the whole pup window is gone while
                                // the laptop is up -> the user closed the window.
                                return Some(true);
                            }
                        }
                        None // no-clients / ambiguous / unparseable -> uncertain
                    }
                    Err(_) => None,
                }
            })
            .await
            .unwrap_or(None);
            if verdict == Some(true) && !store.viewer_infos(&ch).await.iter().any(|i| i.surface == "pup") {
                store.set_canvas_pref(&ch, "webview").await;
                println!("{} DECISION pup tab for '{ch}' closed by the user - in-memory note set to webview until restart.", ts());
            }
        });
    }
}

async fn serve_image(
    State(state): State<Arc<AppState>>,
    Path((channel, filename)): Path<(String, String)>,
) -> Response {
    if channel.contains("..") || filename.contains("..") {
        return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
    }

    let file_path = state.store.base_dir().join(&channel).join(&filename);
    match tokio::fs::read(&file_path).await {
        Ok(bytes) => {
            let mut res = bytes.into_response();
            res.headers_mut().insert(
                header::CONTENT_TYPE,
                HeaderValue::from_static("image/png"),
            );
            res.headers_mut().insert(
                header::CACHE_CONTROL,
                HeaderValue::from_static("no-cache"),
            );
            res
        }
        Err(_) => (StatusCode::NOT_FOUND, "Image not found").into_response(),
    }
}

#[derive(Serialize)]
struct HealthResponse {
    ok: bool,
    version: String,
    port: u16,
    data_dir: String,
    channels: Vec<String>,
}

async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
    Json(HealthResponse {
        ok: true,
        version: env!("CARGO_PKG_VERSION").to_string(),
        port: state.port,
        data_dir: state.store.base_dir().to_string_lossy().to_string(),
        channels: state.store.channel_names().await,
    })
}