123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};

use crate::image::{read_png_dimensions, read_png_meta, PngMeta};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
    pub id: u64,
    pub timestamp: DateTime<Utc>,
    pub filename: String,
    pub description: String,
    pub w: u32,
    pub h: u32,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub orig_w: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub orig_h: Option<u32>,
    pub bytes: u64,
    pub kb: u64,
    pub source: String,
    pub path: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub png_meta: Option<PngMeta>,
    /// Free-form capture metadata (e.g. Adom Desktop coordMap, dpiScale,
    /// screenRect, screen resolution). Additive — absent for plain injects.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub meta: Option<serde_json::Value>,
    /// Owned-popup screenshots attached to this shot (autofill dropdowns,
    /// confirm bubbles, native dialogs). Each carries its own filename +
    /// coordMap. Additive — absent for plain single-image injects.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub popups: Option<serde_json::Value>,
    /// Which AI THREAD produced this shot (from the read-hook's session
    /// metadata, or any injector passing --thread). Human-readable title.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub thread: Option<String>,
    /// WHY the shot exists: the user's prompt the thread was serving when it
    /// captured/read this image (extracted programmatically from the session
    /// transcript's last-prompt record - zero AI tokens).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub context: Option<String>,
    /// Filename of the stored FULL-RESOLUTION copy ({stem}__full.png) when the
    /// injector provided the original (safe/full pairs). Viewable via the zoom.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub full: Option<String>,
}

struct Channel {
    entries: Vec<Entry>,
    next_id: u64,
    tx: broadcast::Sender<Entry>,
}

/// In-memory AND on-disk cap of shots per channel (SHOTLOG_MAX_SHOTS, default 200).
/// When a channel exceeds it, the oldest shot is dropped AND its files deleted -
/// without this the disk grew unbounded (3.4 GB before retention existed).
#[allow(dead_code)]
fn max_shots() -> usize {
    std::env::var("SHOTLOG_MAX_SHOTS").ok().and_then(|v| v.parse().ok()).unwrap_or(200)
}

/// Days of inactivity before a whole channel is pruned (SHOTLOG_RETENTION_DAYS,
/// default 30; 0 disables the sweep).
#[allow(dead_code)]
fn retention_days() -> u64 {
    std::env::var("SHOTLOG_RETENTION_DAYS").ok().and_then(|v| v.parse().ok()).unwrap_or(30)
}

/// Delete a shot's files: the PNG, its JSON sidecar, and any child-window PNGs.
fn delete_entry_files(entry: &Entry) {
    let p = std::path::Path::new(&entry.path);
    let _ = fs::remove_file(p);
    let _ = fs::remove_file(p.with_extension("json"));
    if let (Some(f), Some(dir)) = (&entry.full, p.parent()) {
        let _ = fs::remove_file(dir.join(f));
    }
    if let Some(arr) = entry.popups.as_ref().and_then(|v| v.as_array()) {
        if let Some(dir) = p.parent() {
            for c in arr {
                if let Some(f) = c.get("filename").and_then(|x| x.as_str()) {
                    let _ = fs::remove_file(dir.join(f));
                }
            }
        }
    }
}

const MAX_ENTRIES: usize = 200; // legacy const; runtime cap comes from max_shots()

/// Global user preferences, persisted to .prefs.json in the data dir and
/// editable from the viewer's Preferences dialog.
#[derive(Clone, Serialize, Deserialize)]
pub struct AppPrefs {
    /// Where force-open lands when nobody is watching and the channel has no
    /// canvas preference of its own: "webview" | "pup".
    #[serde(default = "default_canvas")]
    pub default_canvas: String,
    /// The read-hook (auto-capture of every image any AI Reads). When false,
    /// the broker drops a flag file the hook script checks, so the hook
    /// becomes a no-op without touching hook registration.
    #[serde(default = "default_true")]
    pub read_hook: bool,
    /// Janitor: newest N shots kept per channel on disk (oldest deleted as new
    /// ones arrive). Env SHOTLOG_MAX_SHOTS overrides when set.
    #[serde(default = "default_max_shots")]
    pub max_shots: usize,
    /// Janitor: prune whole channels idle this many days (0 = never).
    /// Env SHOTLOG_RETENTION_DAYS overrides when set.
    #[serde(default = "default_retention_days")]
    pub retention_days: u64,
    /// Orange update cues when NEW SHOTS arrive: the webview tab highlight and
    /// the pup taskbar flash. Off = still pulls the tab forward, just no flash.
    #[serde(default = "default_true")]
    pub update_alerts: bool,
    /// Width of the skinny webview panel as a percent of the workspace (10-50).
    #[serde(default = "default_webview_width")]
    pub webview_width: u8,
    /// In the single-window viewer: automatically switch to the channel tab
    /// that just received a shot (vs sit where you are with a badge + flash).
    #[serde(default = "default_true")]
    pub update_autoswitch: bool,
}
fn default_canvas() -> String { "webview".into() }
fn default_true() -> bool { true }
fn default_max_shots() -> usize { 200 }
fn default_retention_days() -> u64 { 30 }
fn default_webview_width() -> u8 { 20 }
impl Default for AppPrefs {
    fn default() -> Self { Self { default_canvas: default_canvas(), read_hook: true,
        max_shots: default_max_shots(), retention_days: default_retention_days(),
        update_alerts: true, webview_width: default_webview_width(),
        update_autoswitch: true } }
}

/// The flag file the read-hook script checks; present = hook disabled.
pub fn read_hook_off_flag() -> PathBuf {
    PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into()))
        .join(".claude/hooks/.shotlog-read-hook-off")
}

/// One connected viewer: which surface it's on, and whether that surface is
/// actually on-screen (Page Visibility) vs a background tab / minimized window.
#[derive(Clone)]
pub struct ViewerInfo {
    pub surface: String,
    pub visible: bool,
    /// Optional host handle the viewer reported (e.g. the pup sessionId from
    /// ?session=... in the viewer URL) so the broker can cue that exact window.
    pub session: Option<String>,
}

pub struct Store {
    channels: RwLock<HashMap<String, Channel>>,
    /// Global feed: every new entry from EVERY channel, for the single-window
    /// multi-tab viewer (/ws/_all).
    pub all_tx: broadcast::Sender<(String, Entry)>,
    /// Control frames for the app (raw JSON strings), e.g. {type:"show",channel}
    /// - the consolidation path: switch the existing window's internal tab
    /// instead of spawning another surface.
    pub ctrl_tx: broadcast::Sender<String>,
    base_dir: PathBuf,
    /// Live viewer presence: channel -> (connection id -> viewer info).
    /// Surface is what the viewer reported on connect: webview | pup | mobile | browser.
    presence: RwLock<HashMap<String, HashMap<u64, ViewerInfo>>>,
    /// The user's CHOSEN canvas per channel ("webview" | "pup"), recorded on
    /// every open/switch and persisted to .canvas-prefs.json. Presence is
    /// instantaneous (a pup WS blip looks like "nobody watching"), so force-open
    /// must consult THIS, not just presence - or it reopens the wrong canvas
    /// and steamrolls the user's choice.
    canvas_prefs: RwLock<HashMap<String, String>>,
    app_prefs: RwLock<AppPrefs>,
}

impl Store {
    pub fn new(base_dir: PathBuf) -> Arc<Self> {
        fs::create_dir_all(&base_dir).ok();
        // Canonicalize so all paths are absolute
        let base_dir = fs::canonicalize(&base_dir).unwrap_or(base_dir);
        // Canvas notes are IN-MEMORY ONLY (user's explicit clicks this session);
        // every channel otherwise follows the global default. Remove the legacy
        // persisted file so old recorded "choices" can never resurrect.
        let _ = fs::remove_file(base_dir.join(".canvas-prefs.json"));
        let prefs: HashMap<String, String> = HashMap::new();
        let app_prefs: AppPrefs = fs::read_to_string(base_dir.join(".prefs.json"))
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default();
        // Reconcile the hook flag file with the persisted pref at startup.
        let flag = read_hook_off_flag();
        if app_prefs.read_hook { let _ = fs::remove_file(&flag); }
        else { let _ = fs::write(&flag, "disabled via shotlog Preferences\n"); }
        let (all_tx, _) = broadcast::channel(256);
        let (ctrl_tx, _) = broadcast::channel(64);
        let store = Arc::new(Self {
            channels: RwLock::new(HashMap::new()),
            all_tx,
            ctrl_tx,
            base_dir,
            presence: RwLock::new(HashMap::new()),
            canvas_prefs: RwLock::new(prefs),
            app_prefs: RwLock::new(app_prefs),
        });
        store
    }

    /// Recover existing screenshots from disk on startup.
    pub async fn recover(&self) {
        let entries_dir = &self.base_dir;
        let Ok(read_dir) = fs::read_dir(entries_dir) else {
            return;
        };

        for dir_entry in read_dir.flatten() {
            if !dir_entry.path().is_dir() {
                continue;
            }
            let channel_name = dir_entry.file_name().to_string_lossy().to_string();
            let channel_dir = dir_entry.path();

            let mut files: Vec<(PathBuf, std::fs::Metadata)> = Vec::new();
            if let Ok(inner) = fs::read_dir(&channel_dir) {
                for f in inner.flatten() {
                    let p = f.path();
                    if p.extension().and_then(|e| e.to_str()) == Some("png") {
                        // Skip owned-popup attachment PNGs ({stem}__popupN.png) — they
                        // belong to a primary shot's sidecar, not a standalone entry.
                        if p.file_name().and_then(|n| n.to_str()).map(|n| n.contains("__popup") || n.contains("__full")).unwrap_or(false) {
                            continue;
                        }
                        if let Ok(meta) = fs::metadata(&p) {
                            files.push((p, meta));
                        }
                    }
                }
            }

            // Sort by modification time ascending
            files.sort_by_key(|(_, m)| m.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH));

            let (tx, _) = broadcast::channel(64);
            let mut entries = Vec::new();
            let mut next_id = 1u64;

            for (path, meta) in &files {
                let bytes_raw = fs::read(path).unwrap_or_default();
                let (w, h) = read_png_dimensions(&bytes_raw);
                let file_bytes = meta.len();
                let filename = path
                    .file_name()
                    .unwrap_or_default()
                    .to_string_lossy()
                    .to_string();

                let modified: DateTime<Utc> = meta
                    .modified()
                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
                    .into();

                // Read sidecar metadata if it exists
                let meta_path = path.with_extension("json");
                let (description, source, timestamp, orig_w, orig_h, cap_meta, popups, thread, context, full) = if let Ok(json_str) = fs::read_to_string(&meta_path) {
                    if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json_str) {
                        let desc = v["description"].as_str().unwrap_or("").to_string();
                        let src = v["source"].as_str().unwrap_or("recovered").to_string();
                        let ts = v["timestamp"].as_str()
                            .and_then(|s| s.parse::<DateTime<Utc>>().ok())
                            .unwrap_or(modified);
                        let ow = v["orig_w"].as_u64().map(|n| n as u32);
                        let oh = v["orig_h"].as_u64().map(|n| n as u32);
                        let cm = if v.get("meta").map(|m| !m.is_null()).unwrap_or(false) { Some(v["meta"].clone()) } else { None };
                        let pp = if v.get("popups").map(|p| !p.is_null()).unwrap_or(false) { Some(v["popups"].clone()) } else { None };
                        let th = v.get("thread").and_then(|t| t.as_str()).map(String::from);
                        let cx = v.get("context").and_then(|t| t.as_str()).map(String::from);
                        let fl = v.get("full").and_then(|t| t.as_str()).map(String::from);
                        (desc, src, ts, ow, oh, cm, pp, th, cx, fl)
                    } else {
                        (String::new(), "recovered".into(), modified, None, None, None, None, None, None, None)
                    }
                } else {
                    (String::new(), "recovered".into(), modified, None, None, None, None, None, None, None)
                };

                let png_meta = read_png_meta(&bytes_raw);
                entries.push(Entry {
                    id: next_id,
                    timestamp,
                    filename: filename.clone(),
                    description,
                    w,
                    h,
                    orig_w,
                    orig_h,
                    bytes: file_bytes,
                    kb: file_bytes / 1024,
                    source,
                    path: path.to_string_lossy().to_string(),
                    png_meta,
                    meta: cap_meta,
                    popups,
                    thread,
                    context,
                    full,
                });
                next_id += 1;
            }

            // Keep only the newest max_shots() - and DELETE the older shots'
            // files, which previously lingered on disk forever.
            let cap = std::env::var("SHOTLOG_MAX_SHOTS").ok().and_then(|v| v.parse().ok()).unwrap_or(200);
            if entries.len() > cap {
                let trimmed: Vec<Entry> = entries.drain(..entries.len() - cap).collect();
                for e in &trimmed { delete_entry_files(e); }
            }

            let mut channels = self.channels.write().await;
            channels.insert(
                channel_name,
                Channel {
                    entries,
                    next_id,
                    tx,
                },
            );
        }
    }

    /// Add a new screenshot entry. Saves to disk and broadcasts via SSE.
    #[allow(clippy::too_many_arguments)]
    pub async fn add_entry(
        &self,
        channel_name: &str,
        image_bytes: Vec<u8>,
        filename: String,
        description: String,
        source: String,
        w: u32,
        h: u32,
        orig_w: Option<u32>,
        orig_h: Option<u32>,
        meta: Option<serde_json::Value>,
        popups: Option<serde_json::Value>,
        thread: Option<String>,
        context: Option<String>,
        orig_path: Option<String>,
    ) -> Entry {
        let channel_dir = self.base_dir.join(channel_name);
        fs::create_dir_all(&channel_dir).ok();

        let file_path = channel_dir.join(&filename);
        fs::write(&file_path, &image_bytes).ok();

        // Safe/full pairs: copy the ORIGINAL next to the stored (safe) shot so
        // the full resolution stays viewable even after the source cache is
        // janitored elsewhere. Skipped when the original IS the injected file.
        let full = orig_path.as_deref().and_then(|op| {
            let ob = fs::read(op).ok()?;
            if ob == image_bytes { return None; }
            let fname = format!("{}__full.png", filename.trim_end_matches(".png"));
            fs::write(channel_dir.join(&fname), &ob).ok()?;
            Some(fname)
        });

        let file_bytes = image_bytes.len() as u64;
        let png_meta = read_png_meta(&image_bytes);

        // Save metadata sidecar JSON
        let meta_path = file_path.with_extension("json");
        let mut meta_json = serde_json::json!({
            "description": description,
            "source": source,
            "w": w, "h": h,
            "bytes": file_bytes,
            "timestamp": Utc::now().to_rfc3339(),
        });
        if let (Some(ow), Some(oh)) = (orig_w, orig_h) {
            meta_json["orig_w"] = serde_json::json!(ow);
            meta_json["orig_h"] = serde_json::json!(oh);
        }
        if let Some(m) = &meta { meta_json["meta"] = m.clone(); }
        if let Some(p) = &popups { meta_json["popups"] = p.clone(); }
        if let Some(t) = &thread { meta_json["thread"] = serde_json::json!(t); }
        if let Some(c) = &context { meta_json["context"] = serde_json::json!(c); }
        if let Some(f) = &full { meta_json["full"] = serde_json::json!(f); }
        fs::write(&meta_path, meta_json.to_string()).ok();

        let mut channels = self.channels.write().await;
        let ch = channels
            .entry(channel_name.to_string())
            .or_insert_with(|| {
                let (tx, _) = broadcast::channel(64);
                Channel {
                    entries: Vec::new(),
                    next_id: 1,
                    tx,
                }
            });

        let entry = Entry {
            id: ch.next_id,
            timestamp: Utc::now(),
            filename,
            description,
            w,
            h,
            orig_w,
            orig_h,
            bytes: file_bytes,
            kb: file_bytes / 1024,
            source,
            path: file_path.to_string_lossy().to_string(),
            png_meta,
            meta,
            popups,
            thread,
            context,
            full,
        };

        ch.next_id += 1;
        ch.entries.push(entry.clone());
        let cap = std::env::var("SHOTLOG_MAX_SHOTS").ok().and_then(|v| v.parse().ok())
            .unwrap_or_else(|| { // prefs read is async; channels lock is held - use try_read
                self.app_prefs.try_read().map(|p| p.max_shots).unwrap_or(200)
            });
        while ch.entries.len() > cap {
            let old = ch.entries.remove(0);
            delete_entry_files(&old);
        }

        // Broadcast to SSE listeners (ignore errors — no receivers is fine)
        let _ = ch.tx.send(entry.clone());
        let _ = self.all_tx.send((channel_name.to_string(), entry.clone()));

        entry
    }

    /// Get all entries for a channel.
    pub async fn get_entries(&self, channel_name: &str) -> Vec<Entry> {
        let channels = self.channels.read().await;
        channels
            .get(channel_name)
            .map(|ch| ch.entries.clone())
            .unwrap_or_default()
    }

    /// Number of live viewers (WebSocket subscribers) for a channel.
    pub async fn viewer_count(&self, channel_name: &str) -> usize {
        let channels = self.channels.read().await;
        channels.get(channel_name).map(|ch| ch.tx.receiver_count()).unwrap_or(0)
    }

    /// Register (or update) a connected viewer with the surface it reported
    /// (webview/pup/mobile/browser) and whether it's currently on-screen.
    pub async fn register_viewer(&self, channel_name: &str, id: u64, surface: String, visible: bool, session: Option<String>) {
        let mut p = self.presence.write().await;
        p.entry(channel_name.to_string())
            .or_default()
            .insert(id, ViewerInfo { surface, visible, session });
    }

    /// Update just the on-screen (Page Visibility) state of an existing viewer.
    pub async fn set_viewer_visibility(&self, channel_name: &str, id: u64, visible: bool) {
        let mut p = self.presence.write().await;
        if let Some(v) = p.get_mut(channel_name).and_then(|m| m.get_mut(&id)) {
            v.visible = visible;
        }
    }

    /// Drop a viewer on disconnect.
    pub async fn unregister_viewer(&self, channel_name: &str, id: u64) {
        let mut p = self.presence.write().await;
        if let Some(m) = p.get_mut(channel_name) {
            m.remove(&id);
            if m.is_empty() { p.remove(channel_name); }
        }
    }

    /// Full presence for a channel — one entry per connected viewer.
    /// Presence for a channel INCLUDING single-window app viewers (registered
    /// under "_all"), who watch every channel at once.
    pub async fn viewer_infos(&self, channel_name: &str) -> Vec<ViewerInfo> {
        let p = self.presence.read().await;
        let mut v: Vec<ViewerInfo> = p.get(channel_name).map(|m| m.values().cloned().collect()).unwrap_or_default();
        if channel_name != "_all" {
            if let Some(m) = p.get("_all") {
                v.extend(m.values().cloned());
            }
        }
        v
    }

    /// Surfaces of the live viewers for a channel, e.g. ["pup","mobile"].
    pub async fn viewer_surfaces(&self, channel_name: &str) -> Vec<String> {
        let p = self.presence.read().await;
        p.get(channel_name).map(|m| m.values().map(|v| v.surface.clone()).collect()).unwrap_or_default()
    }

    /// Get a broadcast receiver for a channel's SSE stream.
    pub async fn subscribe(&self, channel_name: &str) -> broadcast::Receiver<Entry> {
        let mut channels = self.channels.write().await;
        let ch = channels
            .entry(channel_name.to_string())
            .or_insert_with(|| {
                let (tx, _) = broadcast::channel(64);
                Channel {
                    entries: Vec::new(),
                    next_id: 1,
                    tx,
                }
            });
        ch.tx.subscribe()
    }

    /// Update the description of an existing entry and broadcast the update.
    pub async fn update_description(&self, channel_name: &str, entry_id: u64, description: String) {
        let mut channels = self.channels.write().await;
        if let Some(ch) = channels.get_mut(channel_name) {
            if let Some(entry) = ch.entries.iter_mut().find(|e| e.id == entry_id) {
                entry.description = description.clone();

                // Update the sidecar JSON on disk
                let meta_path = std::path::Path::new(&entry.path).with_extension("json");
                if let Ok(json_str) = fs::read_to_string(&meta_path) {
                    if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&json_str) {
                        v["description"] = serde_json::json!(description);
                        v["described_by"] = serde_json::json!("claude -p \"...\" --model haiku --image <path>");
                        v["described_at"] = serde_json::json!(chrono::Utc::now().to_rfc3339());
                        fs::write(&meta_path, v.to_string()).ok();
                    }
                }

                let _ = ch.tx.send(entry.clone());
            }
        }
    }

    /// Delete a single entry by filename.
    pub async fn delete_entry(&self, channel_name: &str, filename: &str) {
        let mut channels = self.channels.write().await;
        if let Some(ch) = channels.get_mut(channel_name) {
            ch.entries.retain(|e| e.filename != filename);
        }
    }

    /// Clear in-memory entries for a channel (log only, files stay on disk).
    pub async fn clear_log(&self, channel_name: &str) -> usize {
        let mut channels = self.channels.write().await;
        if let Some(ch) = channels.get_mut(channel_name) {
            let count = ch.entries.len();
            ch.entries.clear();
            count
        } else {
            0
        }
    }

    /// Clear entries AND delete all files for a channel.
    pub async fn clear_all(&self, channel_name: &str) -> usize {
        let count = self.clear_log(channel_name).await;
        let channel_dir = self.base_dir.join(channel_name);
        if channel_dir.is_dir() {
            if let Ok(entries) = fs::read_dir(&channel_dir) {
                for entry in entries.flatten() {
                    let p = entry.path();
                    // Remove EVERY per-shot file, not just the PNGs: the .json
                    // sidecars and __popup child-window PNGs must go too, or the
                    // folder fills with orphaned metadata.
                    match p.extension().and_then(|e| e.to_str()) {
                        Some("png") | Some("json") => { fs::remove_file(&p).ok(); }
                        _ => {}
                    }
                }
            }
        }
        count
    }

    /// List all channel names.
    pub async fn channel_names(&self) -> Vec<String> {
        let channels = self.channels.read().await;
        channels.keys().cloned().collect()
    }

    /// Lightweight per-channel summary for the `channels` overview:
    /// (name, shot count, last timestamp, last source). No entry cloning.
    pub async fn channel_summaries(&self) -> Vec<(String, usize, Option<DateTime<Utc>>, Option<String>)> {
        let channels = self.channels.read().await;
        channels.iter().map(|(k, ch)| {
            let last = ch.entries.last();
            (k.clone(), ch.entries.len(), last.map(|e| e.timestamp), last.map(|e| e.source.clone()))
        }).collect()
    }

    /// Get the base directory path.
    pub fn base_dir(&self) -> &Path {
        &self.base_dir
    }

    /// Note the user's explicit canvas CLICK for a channel - IN MEMORY ONLY.
    /// Never persisted: a broker restart (or user reboot) forgets all notes and
    /// every channel returns to the global default.
    pub async fn set_canvas_pref(&self, channel: &str, canvas: &str) {
        self.canvas_prefs.write().await.insert(channel.to_string(), canvas.to_string());
    }

    /// Forget a channel's note so it follows the global default again.
    pub async fn clear_canvas_pref(&self, channel: &str) {
        self.canvas_prefs.write().await.remove(channel);
    }

    /// Forget EVERY in-memory canvas note. Used when the human changes the
    /// global default: the newest explicit intent wins, so stale click-notes
    /// must never override it.
    pub async fn clear_all_canvas_prefs(&self) -> usize {
        let mut m = self.canvas_prefs.write().await;
        let n = m.len();
        m.clear();
        n
    }

    pub async fn canvas_pref(&self, channel: &str) -> Option<String> {
        self.canvas_prefs.read().await.get(channel).cloned()
    }

    pub async fn app_prefs(&self) -> AppPrefs {
        self.app_prefs.read().await.clone()
    }

    /// Janitor knobs: env vars override, otherwise the UI-editable prefs.
    pub async fn effective_max_shots(&self) -> usize {
        std::env::var("SHOTLOG_MAX_SHOTS").ok().and_then(|v| v.parse().ok())
            .unwrap_or(self.app_prefs.read().await.max_shots)
    }
    pub async fn effective_retention_days(&self) -> u64 {
        std::env::var("SHOTLOG_RETENTION_DAYS").ok().and_then(|v| v.parse().ok())
            .unwrap_or(self.app_prefs.read().await.retention_days)
    }

    /// Update prefs, persist, and sync the read-hook flag file.
    pub async fn set_app_prefs(&self, p: AppPrefs) {
        let flag = read_hook_off_flag();
        if p.read_hook { let _ = fs::remove_file(&flag); }
        else {
            if let Some(dir) = flag.parent() { let _ = fs::create_dir_all(dir); }
            let _ = fs::write(&flag, "disabled via shotlog Preferences\n");
        }
        if let Ok(j) = serde_json::to_string_pretty(&p) {
            let _ = fs::write(self.base_dir.join(".prefs.json"), j);
        }
        *self.app_prefs.write().await = p;
    }

    /// Prune whole channels idle longer than SHOTLOG_RETENTION_DAYS (default 30):
    /// delete the channel directory and drop it from the in-memory store.
    /// Returns the pruned channel names. Runs at startup and every 6 hours.
    pub async fn retention_sweep(&self) -> Vec<String> {
        let days = self.effective_retention_days().await;
        if days == 0 { return Vec::new(); }
        let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(days * 86_400);
        let mut pruned = Vec::new();
        let Ok(read_dir) = fs::read_dir(&self.base_dir) else { return pruned };
        for d in read_dir.flatten() {
            let path = d.path();
            if !path.is_dir() { continue; }
            // newest mtime of any file inside (or the dir itself when empty)
            let newest = fs::read_dir(&path).ok()
                .map(|it| it.flatten()
                    .filter_map(|f| f.metadata().ok().and_then(|m| m.modified().ok()))
                    .max())
                .flatten()
                .or_else(|| fs::metadata(&path).ok().and_then(|m| m.modified().ok()));
            if let Some(t) = newest {
                if t < cutoff {
                    let name = d.file_name().to_string_lossy().to_string();
                    // Never prune a channel someone is actively viewing.
                    if !self.viewer_infos(&name).await.is_empty() { continue; }
                    if fs::remove_dir_all(&path).is_ok() {
                        self.channels.write().await.remove(&name);
                        pruned.push(name);
                    }
                }
            }
        }
        pruned
    }
}