1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786
//! Auto-player for synthesized clips.
//!
//! `adom-tts play <target>` (and `say --play`) opens the mp3 in either a
//! Hydrogen webview tab or a pup browser window per user pref. The whole
//! point: AIs and drivers should never have to author a custom autoplay
//! HTML page again — this module bakes the standard flow into the binary.
//!
//! **Transport (both surfaces):** spin up a one-shot tiny_http on an
//! ephemeral 127.0.0.1 port, serve `/` → autoplay HTML and `/audio.mp3` →
//! file stream. The container's `$VSCODE_PROXY_URI` template wraps that
//! port into a publicly-routable Cloudflare URL — Hydrogen embeds it, pup
//! navigates to it, both reach the same server. No size limit, no port
//! mapping creation, no base64 URL bloat. Server self-shuts down on
//! `/done` (sent from the `<audio>` `ended` event) or after timeout.
//!
//! See `/home/adom/.claude/projects/-home-adom-project/memory/feedback_audio_playback_via_pup.md`
//! for the original "no port mappings" rule this module encodes — the
//! one-shot tiny_http approach satisfies it (no NEW port mapping; we
//! reuse the container's existing `$VSCODE_PROXY_URI` proxy path).

use crate::config::{self, Surface};
use crate::history;
use crate::server;
use anyhow::{anyhow, Context, Result};
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use tiny_http::{Header, Method, Response, Server, StatusCode};

const PLAYER_HTML: &str = include_str!("player.html");
const PLAYER_TAB_NAME: &str = "TTS Player";
const SERVER_DEFAULT_TIMEOUT_SECS: u64 = 60;
const SERVER_MAX_TIMEOUT_SECS: u64 = 300;

/// Env vars the player needs to reach/drive the Hydrogen surface. A `say` can
/// arrive from a SANITIZED shell (cron, hooks, some agent spawns) — a drainer
/// inherited from one of those can't run `adom-cli hydrogen` (it requires
/// VSCODE_PROXY_URI) and builds an unreachable 127.0.0.1 player URL, killing
/// playback for EVERY thread until superseded. So: every caller that HAS the
/// good env persists it, and the drainer adopts whatever it's missing.
const SURFACE_ENV_VARS: &[&str] = &["VSCODE_PROXY_URI", "VSCODE_IPC_HOOK_CLI"];

fn persist_surface_env() {
    let Ok(home) = tts_home() else { return };
    let mut map = serde_json::Map::new();
    for k in SURFACE_ENV_VARS {
        if let Ok(v) = std::env::var(k) {
            if !v.is_empty() { map.insert(k.to_string(), serde_json::Value::String(v)); }
        }
    }
    if map.contains_key("VSCODE_PROXY_URI") {   // only persist a USEFUL env
        let _ = std::fs::create_dir_all(home.join(".adom/tts"));
        let _ = std::fs::write(home.join(".adom/tts/env.json"),
            serde_json::Value::Object(map).to_string());
    }
}

/// Adopt any missing surface env vars from the persisted snapshot. Called
/// FIRST thing in the drainer (single-threaded at that point) so a drainer
/// spawned from a sanitized shell can still open the tab and build a proxy URL.
fn adopt_surface_env() {
    let Ok(home) = tts_home() else { return };
    let Ok(raw) = std::fs::read_to_string(home.join(".adom/tts/env.json")) else { return };
    let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else { return };
    let mut adopted = Vec::new();
    if let Some(o) = v.as_object() {
        for (k, val) in o {
            if std::env::var(k).is_err() {
                if let Some(s) = val.as_str() {
                    std::env::set_var(k, s);
                    adopted.push(k.clone());
                }
            }
        }
    }
    if !adopted.is_empty() {
        log_event("env_adopted", serde_json::json!({ "vars": adopted }));
    }
}

pub fn run(target: &str, surface_cli: Option<Surface>, _wait: bool, allow_repeat: bool) -> Result<()> {
    let path = resolve_target(target)?;
    let cfg = config::load();
    let surface = config::resolve_surface(surface_cli, &cfg.play);

    // Keep the good-env snapshot fresh so drainers can always self-heal.
    persist_surface_env();

    // SHARED QUEUE MODEL: every `play`/`say --play` from any thread enqueues
    // into one shared queue and a SINGLE background drainer plays them
    // sequentially in ONE reused tab — so parallel Claude threads never play
    // audio over each other.
    enqueue(&path, allow_repeat)?;
    ensure_drainer(surface)?;
    Ok(())
}

const QUEUE_SUBDIR: &str = ".adom/tts/queue";
const LOCK_FILE: &str = ".adom/tts/drainer.lock";

fn tts_home() -> Result<PathBuf> {
    Ok(PathBuf::from(std::env::var("HOME").context("no $HOME")?))
}
fn now_millis() -> u128 {
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis()).unwrap_or(0)
}

/// Append one structured event to `~/.adom/tts/events.jsonl` — a persistent,
/// cross-invocation audit trail so anomalies (silent clips, cross-thread tab
/// fights, service flaps) can be reviewed after the fact with `adom-tts logs`.
/// Every line: {ts_ms, pid, kind, ...fields}. Best-effort; never fails a call.
pub(crate) fn log_event(kind: &str, fields: serde_json::Value) {
    let Ok(home) = std::env::var("HOME") else { return };
    let dir = std::path::Path::new(&home).join(".adom/tts");
    let _ = std::fs::create_dir_all(&dir);
    let mut line = serde_json::json!({ "ts_ms": now_millis() as u64, "pid": std::process::id(), "kind": kind });
    if let (Some(o), Some(f)) = (line.as_object_mut(), fields.as_object()) {
        for (k, v) in f { o.insert(k.clone(), v.clone()); }
    }
    if let Ok(mut fh) = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("events.jsonl")) {
        use std::io::Write;
        let _ = writeln!(fh, "{line}");
    }
}

/// Review the persistent event log — recent events + a summary + anomaly hints.
/// The tool teaching the AI (and the user) what actually happened: which clips
/// synthesized, which played fully, which went silent and WHY, and whether
/// multiple threads/processes were competing.
pub fn show_logs(tail: usize) -> Result<()> {
    use std::collections::{BTreeMap, BTreeSet};
    let path = tts_home()?.join(".adom/tts/events.jsonl");
    let raw = std::fs::read_to_string(&path).unwrap_or_default();
    let lines: Vec<&str> = raw.lines().collect();
    if lines.is_empty() { println!("no events logged yet ({})", path.display()); return Ok(()); }
    let now = now_millis() as u64;
    // Summarize a 24h WINDOW, not all-time — cumulative counters made a healed
    // incident from yesterday read as "flapping hard today" (field feedback).
    const WINDOW_MS: u64 = 24 * 3600 * 1000;
    let (mut kinds, mut pids): (BTreeMap<String,u32>, BTreeSet<u64>) = Default::default();
    let (mut bad_play, mut synth_fail, mut open_fail, mut short_body) = (0u32, 0u32, 0u32, 0u32);
    for l in &lines {
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(l) {
            if now.saturating_sub(v.get("ts_ms").and_then(|x| x.as_u64()).unwrap_or(now)) > WINDOW_MS { continue; }
            let k = v.get("kind").and_then(|x| x.as_str()).unwrap_or("").to_string();
            *kinds.entry(k.clone()).or_default() += 1;
            if let Some(p) = v.get("pid").and_then(|x| x.as_u64()) { pids.insert(p); }
            match k.as_str() {
                "clip_end" if v.get("verdict").and_then(|x| x.as_str()) != Some("ok") => bad_play += 1,
                "synth_failed" => synth_fail += 1,
                "open_surface_failed" => open_fail += 1,
                "synth_short_body" => short_body += 1,
                _ => {}
            }
        }
    }
    let start = lines.len().saturating_sub(tail);
    println!("=== adom-tts event log — last {} of {} events ===", lines.len() - start, lines.len());
    for l in &lines[start..] {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(l) else { continue };
        let ts = v.get("ts_ms").and_then(|x| x.as_u64()).unwrap_or(now);
        let ago = now.saturating_sub(ts) / 1000;
        let pid = v.get("pid").and_then(|x| x.as_u64()).unwrap_or(0);
        let kind = v.get("kind").and_then(|x| x.as_str()).unwrap_or("?");
        let mut extra = String::new();
        if let Some(o) = v.as_object() {
            for (k, val) in o {
                if !["ts_ms", "pid", "kind", "timeline"].contains(&k.as_str()) {
                    extra.push_str(&format!(" {k}={val}"));
                }
            }
        }
        println!("  {ago:>5}s ago  pid={pid:<7} {kind}{extra}");
    }
    println!("--- summary (last 24h) ---");
    println!("  by kind: {kinds:?}");
    println!("  distinct pids: {} {:?}", pids.len(), pids);
    if bad_play > 0 { println!("  ⚠ {bad_play} clip(s) did NOT fully play"); }
    if synth_fail > 0 { println!("  ⚠ {synth_fail} synthesis failure(s) (service flap)"); }
    if short_body > 0 { println!("  ⚠ {short_body} suspiciously-short synth bod(ies) — possible silent clips"); }
    if open_fail > 0 { println!("  ⚠ {open_fail} surface-open failure(s)"); }
    if pids.len() > 1 {
        println!("# hint: {} distinct processes logged here. If they're SEPARATE containers (parallel AI", pids.len());
        println!("#       threads), they each run their own queue+player and can fight over the one shared");
        println!("#       Hydrogen 'TTS Player' tab — a real cause of 'opened but silent'. Same container = fine.");
    }
    if bad_play > 0 {
        println!("# hint: re-queue the silent clip(s). Inspect a clip_end line's 'timeline' (adom-tts logs shows");
        println!("#       it in the raw jsonl) for stall/waiting/error to see exactly where playback died.");
    }
    if synth_fail > 0 || short_body > 0 {
        println!("# hint: service-tts was flapping. adom-tts already retries transient failures; if it persists,");
        println!("#       chunk long text into shorter clips. NEVER install a local TTS engine (shared-infra rule).");
    }
    Ok(())
}

/// Current epoch millis (for callers to stamp a "wait for events after now").
pub fn now_ms() -> u64 { now_millis() as u64 }

/// Show the shared player's live status so any thread can QUERY the queue
/// (is a player active, what's playing, how many clips are waiting and their
/// order). Lets parallel threads coordinate politely instead of guessing.
pub fn show_status() -> Result<()> {
    let dir = tts_home()?.join(QUEUE_SUBDIR);
    let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir).into_iter().flatten()
        .filter_map(|e| e.ok()).map(|e| e.path())
        .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json"))
        .collect();
    entries.sort();
    let owner = lock_owner();
    let drainer_alive = owner.as_ref().map(|(p, _)| pid_alive(*p)).unwrap_or(false);
    // Is the live player's server up, and how far into the current clip?
    let mut playing_line = String::from("  (idle — no clip playing)");
    if let Some(port) = read_player_port() {
        if let Ok(r) = reqwest::blocking::Client::new()
            .get(format!("http://127.0.0.1:{port}/state"))
            .timeout(Duration::from_millis(600)).send() {
            if let Ok(v) = r.json::<serde_json::Value>() {
                let dur = v.get("dur_ms").and_then(|x| x.as_f64()).unwrap_or(0.0) / 1000.0;
                let reached = v.get("last_ms").and_then(|x| x.as_f64()).unwrap_or(0.0) / 1000.0;
                let clip = v.get("clip").and_then(|x| x.as_str()).unwrap_or("");
                let what = if clip.is_empty() { String::new() } else { format!(" \"{clip}\"") };
                if v.get("paused").and_then(|x| x.as_bool()).unwrap_or(false) {
                    // The HUMAN paused — say so loudly; "now playing" here misled
                    // a reporting thread into thinking its clip vanished.
                    playing_line = format!(
                        "  ⏸ PAUSED BY THE HUMAN{what} at {reached:.0}s / {dur:.0}s — the WHOLE queue is holding.\n  # hint: nothing plays until the human presses play (or the player reopens). Do NOT\n  #       re-send your clip — it is safely queued and will play, in order, on resume.");
                } else {
                    playing_line = format!("  ▶ now playing{what} on :{port} — {reached:.0}s / {dur:.0}s");
                }
            }
        }
    }
    println!("=== adom-tts shared player status ===");
    match &owner {
        Some((pid, ver)) if drainer_alive =>
            println!("  player/drainer: ACTIVE (pid {pid}, v{ver})"),
        _ => println!("  player/drainer: idle (next clip will start one)"),
    }
    println!("{playing_line}");
    println!("  queue: {} clip(s) waiting", entries.len());
    for (i, e) in entries.iter().enumerate() {
        // Prefer the name/label captured at enqueue (queue entries snapshot the
        // bytes, so the mp3 path is an internal seq name — not caller-facing).
        let (mp3, label) = std::fs::read_to_string(e).ok()
            .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())
            .map(|v| {
                let name = v.get("name").and_then(|s| s.as_str()).map(str::to_string)
                    .or_else(|| v.get("mp3").and_then(|m| m.as_str())
                        .and_then(|m| PathBuf::from(m).file_name().and_then(|s| s.to_str()).map(str::to_string)))
                    .unwrap_or_else(|| "?".into());
                let label = v.get("label").and_then(|s| s.as_str()).unwrap_or("").to_string();
                (name, label)
            }).unwrap_or_else(|| ("?".into(), String::new()));
        let who = if label.is_empty() { String::new() } else { format!("  [{label}]") };
        println!("  {}. {mp3}{who}  (waiting #{})", i + 1, i + 1);
    }
    // ALWAYS hint — an AI polling status must know how to verify, whatever state
    // the queue is in.
    if entries.is_empty() {
        println!("# hint: queue is empty — a new `say ... --play` starts within ~1s. To VERIFY a clip you already");
        println!("#       sent was heard: `adom-tts check <clip.mp3>` (PLAYED = heard; FAILED = re-queue it), or");
        println!("#       use `say ... --play --confirm` next time to block until the human actually hears it.");
    } else {
        println!("# hint: clips play oldest-first in one shared player. Your `say --play` already appended and");
        println!("#       returned — you don't block others. Poll `adom-tts status` to watch it drain, verify");
        println!("#       YOURS with `adom-tts check <clip.mp3>`, or use `--confirm` to block until it's heard.");
    }
    if !drainer_alive && !entries.is_empty() {
        println!("# hint: clips are waiting but NO drainer is alive — run any `say --play` (or `adom-tts play");
        println!("#       <file>`) to start one; it will drain this whole queue in order.");
    }
    Ok(())
}

/// Non-blocking per-clip query: "did MY clip play?" A thread that fired
/// `say --play` (which returns immediately) calls this later to learn whether
/// its clip is still queued, playing, played-ok, or failed — WITHOUT blocking
/// the way `--confirm` does. Prints a verdict + a next-step hint and, on a
/// confirmed failure, exits non-zero so scripts/agents can branch on it.
pub fn check_clip(clip: &str) -> Result<()> {
    // Latest recorded outcome for this clip from the event log.
    let path = tts_home()?.join(".adom/tts/events.jsonl");
    let raw = std::fs::read_to_string(&path).unwrap_or_default();
    let (mut end_verdict, mut started) = (None::<String>, false);
    for l in raw.lines() {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(l) else { continue };
        if v.get("clip").and_then(|x| x.as_str()) != Some(clip) { continue; }
        match v.get("kind").and_then(|x| x.as_str()) {
            Some("clip_end") => end_verdict = Some(v.get("verdict").and_then(|x| x.as_str()).unwrap_or("?").to_string()),
            Some("clip_start") => started = true,
            _ => {}
        }
    }
    // Is it still in the shared queue (and where)?
    let dir = tts_home()?.join(QUEUE_SUBDIR);
    let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir).into_iter().flatten()
        .filter_map(|e| e.ok()).map(|e| e.path())
        .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json")).collect();
    entries.sort();
    // Match on the caller-facing `name` captured at enqueue (queue entries
    // snapshot bytes under an internal seq filename); fall back to the mp3
    // basename for legacy entries.
    let pos = entries.iter().position(|e| std::fs::read_to_string(e).ok()
        .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())
        .and_then(|v| v.get("name").and_then(|s| s.as_str()).map(str::to_string)
            .or_else(|| v.get("mp3").and_then(|m| m.as_str())
                .and_then(|m| PathBuf::from(m).file_name().and_then(|s| s.to_str()).map(str::to_string))))
        == Some(clip.to_string()));

    if let Some(p) = pos {
        if p == 0 {
            println!("▶ PLAYING — \"{clip}\" is at the front of the shared player now.");
        } else {
            println!("… QUEUED — \"{clip}\" has {p} clip(s) ahead of it in the shared player.");
        }
        println!("# hint: not done yet. Poll `adom-tts check {clip}` again, or next time pass `--confirm`");
        println!("#       to BLOCK until it plays. Do NOT tell the human it played until this says PLAYED,");
        println!("#       and do NOT re-send it — queued clips buffer (up to 10 min) + retry (3x) on their own;");
        println!("#       a duplicate would play twice when the listener's connection returns.");
        return Ok(());
    }
    match end_verdict.as_deref() {
        Some("ok") => {
            println!("✓ PLAYED — \"{clip}\" played fully to the human. Safe to tell them you spoke it.");
            Ok(())
        }
        Some(v) if v == "expired" => {
            println!("⌛ EXPIRED — \"{clip}\" waited past the freshness TTL (default 2h; buffered or pause-held) and was");
            println!("   skipped so the human wouldn't hear stale news. It sits in the player History, flagged.");
            println!("# hint: if the content still matters, REWRITE it with current info and send fresh — do not");
            println!("#       replay stale phrasing like 'just finished' hours later.");
            std::process::exit(5);
        }
        Some(v) => {
            println!("✗ FAILED — \"{clip}\" did NOT fully play (verdict={v}).");
            println!("# hint: do NOT tell the human it played. Diagnose first: `adom-tts logs` (where playback");
            println!("#       died), `adom-tts status` (is a player live), `adom-tts reopen` (revive a dead tab).");
            println!("#       Then re-send ONCE with `--play --confirm`. NEVER install a local TTS fallback —");
            println!("#       report the outage instead.");
            std::process::exit(5);
        }
        None if started => {
            println!("▶ PLAYING — \"{clip}\" has started but no completion is logged yet.");
            println!("# hint: poll `adom-tts check {clip}` again in a few seconds.");
            Ok(())
        }
        None => {
            println!("? UNKNOWN — no record of \"{clip}\". Did you `say --play` it? The name must match --out.");
            println!("# hint: run `adom-tts status` to see the live queue, or `adom-tts logs` for recent events.");
            std::process::exit(5);
        }
    }
}

/// Block until the shared player logs that THIS clip actually played to the
/// human (a `clip_end` event for `clip` stamped at/after `since_ms`), then
/// report the verdict. This is the GUARANTEE: `say --play` only *queues* a clip
/// (the drainer plays it in a detached process, so the caller never sees the
/// outcome) — `--confirm` waits for the real result so the AI never tells the
/// human "played" unless it truly did. Exits non-zero if it did NOT fully play.
/// Is this clip still queued (by caller-facing name) or the live player's
/// current clip? Used by `--confirm` so a failed FIRST attempt doesn't produce
/// a verdict while a retry is still pending — the verdict must reflect the
/// LATEST attempt, not the first.
fn clip_pending_or_playing(clip: &str) -> bool {
    if let Ok(dir) = tts_home().map(|h| h.join(QUEUE_SUBDIR)) {
        if let Ok(rd) = std::fs::read_dir(&dir) {
            for e in rd.flatten() {
                let p = e.path();
                if p.extension().and_then(|x| x.to_str()) != Some("json") { continue; }
                let name = std::fs::read_to_string(&p).ok()
                    .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())
                    .and_then(|v| v.get("name").and_then(|s| s.as_str()).map(str::to_string)
                        .or_else(|| v.get("mp3").and_then(|m| m.as_str())
                            .and_then(|m| PathBuf::from(m).file_name().and_then(|s| s.to_str()).map(str::to_string))));
                if name.as_deref() == Some(clip) { return true; }
            }
        }
    }
    if let Some(port) = read_player_port() {
        if let Ok(r) = reqwest::blocking::Client::new()
            .get(format!("http://127.0.0.1:{port}/state"))
            .timeout(Duration::from_millis(500)).send() {
            if let Ok(v) = r.json::<serde_json::Value>() {
                if v.get("clip").and_then(|c| c.as_str()) == Some(clip)
                    && !v.get("done").and_then(|d| d.as_bool()).unwrap_or(false) {
                    return true;
                }
            }
        }
    }
    false
}

/// One-line surface evidence for failure output: what the player page looks
/// like RIGHT NOW (live/stale/dead + position), so the caller doesn't need a
/// logs+status+check forensics session to know what the listener experienced.
fn surface_evidence() -> String {
    let Some(port) = read_player_port() else { return "no live player server".into() };
    match reqwest::blocking::Client::new()
        .get(format!("http://127.0.0.1:{port}/state"))
        .timeout(Duration::from_millis(500)).send()
        .ok().and_then(|r| r.json::<serde_json::Value>().ok()) {
        Some(v) => {
            let age = v.get("poll_age_ms").and_then(|x| x.as_u64()).unwrap_or(999_999_999);
            let pipe = if age < 3000 { format!("page LIVE (polled {age}ms ago)") }
                       else { format!("page NOT polling ({}s silent — listener's device offline/backgrounded)", age / 1000) };
            format!("player :{port}, {pipe}, current clip \"{}\" at {:.0}s",
                v.get("clip").and_then(|c| c.as_str()).unwrap_or("-"),
                v.get("last_ms").and_then(|x| x.as_f64()).unwrap_or(0.0) / 1000.0)
        }
        None => format!("player server :{port} not reachable (drainer restarting or gone)"),
    }
}

pub fn confirm_playback(clip: &str, since_ms: u64, timeout_secs: u64) -> Result<()> {
    let Ok(home) = std::env::var("HOME") else { return Ok(()) };
    let path = std::path::Path::new(&home).join(".adom/tts/events.jsonl");
    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
    let mut last_fail: Option<(String, f64, f64)> = None;
    loop {
        if let Ok(raw) = std::fs::read_to_string(&path) {
            for line in raw.lines().rev() {
                let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
                if v.get("kind").and_then(|x| x.as_str()) != Some("clip_end") { continue; }
                if v.get("clip").and_then(|x| x.as_str()) != Some(clip) { continue; }
                if v.get("ts_ms").and_then(|x| x.as_u64()).unwrap_or(0) < since_ms { continue; }
                let verdict = v.get("verdict").and_then(|x| x.as_str()).unwrap_or("?").to_string();
                let reached = v.get("reached_s").and_then(|x| x.as_f64()).unwrap_or(0.0);
                let dur = v.get("dur_s").and_then(|x| x.as_f64()).unwrap_or(0.0);
                if verdict == "ok" {
                    println!("✓ CONFIRMED: \"{clip}\" played fully to the human ({reached:.0}/{dur:.0}s)");
                    return Ok(());
                }
                // Latest attempt failed — but the drainer auto-retries. Only
                // report failure once NOTHING is pending; otherwise the verdict
                // would describe a superseded attempt (the confirm-vs-reality
                // race from field feedback).
                last_fail = Some((verdict.clone(), reached, dur));
                if !clip_pending_or_playing(clip) {
                    eprintln!("✗ NOT CONFIRMED: \"{clip}\" did NOT fully play (latest attempt: verdict={verdict}, reached {reached:.0}s of {dur:.0}s; retries exhausted)");
                    eprintln!("  surface: {}", surface_evidence());
                    eprintln!("# hint: the human did NOT hear this. Do NOT tell them it played. Fix the surface first");
                    eprintln!("#       (`adom-tts reopen` if the page is dead), then re-send ONCE with `--confirm`.");
                    std::process::exit(5);
                }
                break; // retry pending — keep waiting for its outcome
            }
        }
        if Instant::now() >= deadline {
            match &last_fail {
                Some((v, r, d)) => {
                    eprintln!("✗ UNCONFIRMED: \"{clip}\" not confirmed within {timeout_secs}s (latest attempt: verdict={v}, reached {r:.0}s of {d:.0}s; a retry may still be pending)");
                }
                None => {
                    eprintln!("✗ UNCONFIRMED: no playback attempt for \"{clip}\" within {timeout_secs}s (still queued/buffered).");
                }
            }
            eprintln!("  surface: {}", surface_evidence());
            eprintln!("# hint: clips buffer up to 10 min + retry 3x on their own. Poll `adom-tts check {clip}`");
            eprintln!("#       until PLAYED/FAILED; do NOT re-send yet — a duplicate would play twice on reconnect.");
            std::process::exit(5);
        }
        thread::sleep(Duration::from_millis(700));
    }
}


/// Is identical content already queued, or played within the dedup window
/// (15 min)? Returns a human-readable location for the refusal message.
fn find_duplicate(hash: &str) -> Option<String> {
    if let Ok(dir) = tts_home().map(|h| h.join(QUEUE_SUBDIR)) {
        if let Ok(rd) = std::fs::read_dir(&dir) {
            for e in rd.flatten() {
                let p = e.path();
                if p.extension().and_then(|x| x.to_str()) != Some("json") { continue; }
                if let Some(v) = std::fs::read_to_string(&p).ok()
                    .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok()) {
                    if v.get("hash").and_then(|h| h.as_str()) == Some(hash) {
                        return Some("is ALREADY QUEUED (it will play; the queue never loses a clip)".into());
                    }
                }
            }
        }
    }
    const WINDOW_MS: u64 = 15 * 60 * 1000;
    let now = now_ms();
    for e in read_play_history(40) {
        if e.get("hash").and_then(|h| h.as_str()) != Some(hash) { continue; }
        let ts = e.get("ts_ms").and_then(|t| t.as_u64()).unwrap_or(0);
        if now.saturating_sub(ts) <= WINDOW_MS {
            let v = e.get("verdict").and_then(|x| x.as_str()).unwrap_or("?");
            return Some(format!("already played {}s ago (verdict={v})", now.saturating_sub(ts) / 1000));
        }
    }
    None
}

/// Deterministic content fingerprint (FNV-1a 64) of the mp3 bytes — the
/// REQUEST ID for deduplication. Same audio content = same id, across
/// processes and threads, no extra dependencies.
fn content_hash(bytes: &[u8]) -> String {
    let mut h: u64 = 0xcbf29ce484222325;
    for &b in bytes { h ^= b as u64; h = h.wrapping_mul(0x100000001b3); }
    format!("{h:016x}")
}

/// Append a clip to the shared play queue (atomic temp+rename). Ordering is
/// by filename, which is `<millis>-<pid>` zero-padded → chronological.
fn enqueue(mp3: &Path, allow_repeat: bool) -> Result<()> {
    let dir = tts_home()?.join(QUEUE_SUBDIR);
    std::fs::create_dir_all(&dir)?;
    let abs = mp3.canonicalize().unwrap_or_else(|_| mp3.to_path_buf());
    let seq = format!("{:020}-{:07}", now_millis(), std::process::id());
    // SNAPSHOT the mp3 bytes into a queue-owned file so a later overwrite of the
    // source can't corrupt (or duplicate) this queued clip. Threads commonly
    // reuse a generic name like /tmp/reply.mp3; without this, re-writing it
    // before the queued clip plays would make the clip play the NEW bytes — the
    // "same file played twice / wrong audio" confusion. Also capture the display
    // metadata now, from the source's sidecar, so the snapshot is self-contained.
    let clips = tts_home()?.join(".adom/tts/clips");
    let _ = std::fs::create_dir_all(&clips);
    let bytes = std::fs::read(&abs).with_context(|| format!("read {}", abs.display()))?;
    let hash = content_hash(&bytes);
    // DEDUPLICATION — identical content queued or recently played is a resend,
    // and a resend ALWAYS double-plays on the human. Refuse it loudly. (Field
    // case: a thread resent after a slow window-open; the human heard the same
    // 65s clip twice back to back.) Override deliberately with --allow-repeat.
    if !allow_repeat {
        if let Some(whereabouts) = find_duplicate(&hash) {
            eprintln!("adom-tts: DUPLICATE refused — identical audio {whereabouts}.");
            eprintln!("# hint: do NOT re-send. If playback seems stuck: it is queued/buffered and the system");
            eprintln!("#       auto-retries failures; poll `adom-tts check <clip>` for the truth. A slow player");
            eprintln!("#       window is NOT a lost clip. To play the same audio again ON PURPOSE (the human");
            eprintln!("#       asked to hear it twice), pass --allow-repeat.");
            std::process::exit(7);
        }
    }
    let snap = clips.join(format!("{seq}.mp3"));
    let stored = if std::fs::write(&snap, &bytes).is_ok() { snap.clone() } else { abs.clone() };
    let name = mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();
    let words: serde_json::Value = serde_json::from_str(&sidecar_words_json(mp3)).unwrap_or_else(|_| serde_json::json!([]));
    let body = serde_json::json!({
        "mp3": stored.to_string_lossy(),
        "name": name,
        "label": sidecar_label(mp3),
        "desc": sidecar_field(mp3, "desc"),
        "meta": sidecar_meta_line(mp3),
        "words": words,
        "hash": hash,
        "snapshot": stored == snap,
    }).to_string();
    let tmp = dir.join(format!(".{seq}.tmp"));
    std::fs::write(&tmp, body)?;
    std::fs::rename(&tmp, dir.join(format!("{seq}.json")))?;
    let depth = std::fs::read_dir(&dir).map(|rd| rd.filter_map(|e| e.ok())
        .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json")).count()).unwrap_or(1);
    log_event("enqueue", serde_json::json!({ "mp3": abs.to_string_lossy(), "depth": depth }));
    let clip = mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip");
    if std::env::var("ADOM_TTS_NO_HINTS").is_err() {
        // Broker-style acknowledgement: tell the calling thread exactly where
        // its clip landed and how to find out when it actually played.
        if depth > 1 {
            eprintln!("# ACCEPTED \"{clip}\" — position {} in the shared player ({} ahead). One player plays", depth, depth - 1);
            eprintln!("#          all threads' clips in order, no overlap; your call returned without blocking others.");
        } else {
            eprintln!("# ACCEPTED \"{clip}\" — next up in the shared player.");
        }
        eprintln!("# to KNOW it played: `adom-tts check {clip}` (non-blocking: QUEUED/PLAYING/PLAYED/FAILED), or");
        eprintln!("#   `adom-tts status` (whole queue), or re-run `say ... --play --confirm` to BLOCK until done.");
        eprintln!("#   Do NOT tell the human you spoke it until check says PLAYED / --confirm says CONFIRMED.");
        eprintln!("# etiquette: clips from ALL Claude threads share this one player — START your narration with");
        eprintln!("#   which thread you are (or pass `--thread \"<name>\"`) so the listener knows who's speaking.");
    }
    Ok(())
}

const MY_VERSION: &str = env!("CARGO_PKG_VERSION");

/// The lock file is `{pid}\n{version}`. Parse it (missing/garbage → None).
fn lock_owner() -> Option<(u32, String)> {
    let home = tts_home().ok()?;
    let raw = std::fs::read_to_string(home.join(LOCK_FILE)).ok()?;
    let mut lines = raw.lines();
    let pid = lines.next()?.trim().parse::<u32>().ok()?;
    let ver = lines.next().unwrap_or("").trim().to_string();
    Some((pid, ver))
}

fn pid_alive(pid: u32) -> bool { Path::new(&format!("/proc/{pid}")).exists() }

/// True while THIS process still owns the drain lock. The drainer polls this
/// every loop and exits the moment a newer/other drainer takes over — the
/// single mechanism that guarantees exactly ONE live player across all threads
/// and cleanly hands off when a newer binary version supersedes an old one.
fn i_own_lock() -> bool {
    lock_owner().map(|(pid, _)| pid == std::process::id()).unwrap_or(false)
}

/// Spawn a detached background drainer unless a live drainer of the SAME (or
/// newer) version already owns the player. If a live drainer is running OLDER
/// code (e.g. this container's binary was just upgraded), we still spawn — the
/// new drainer takes over the lock and the old one self-exits. This is how
/// every thread ends up talking to the latest player after an upgrade.
fn ensure_drainer(surface: Surface) -> Result<()> {
    if let Some((pid, ver)) = lock_owner() {
        if pid_alive(pid) && ver == MY_VERSION { return Ok(()); }  // current drainer already up
    }
    let exe = std::env::current_exe().context("locate current exe")?;
    Command::new(&exe)
        .args(["__drain", "--surface", surface.as_str()])
        .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
        .spawn().context("spawn detached drainer")?;
    Ok(())
}

/// Removes the lockfile on drop — but ONLY if it still names THIS pid, so a
/// drainer that was superseded (its lock overwritten by a newer one) never
/// deletes the new owner's lock.
struct LockGuard(PathBuf);
impl Drop for LockGuard {
    fn drop(&mut self) {
        if i_own_lock() { let _ = std::fs::remove_file(&self.0); }
    }
}

fn write_lock(lock: &Path) -> std::io::Result<()> {
    std::fs::write(lock, format!("{}\n{}", std::process::id(), MY_VERSION))
}

/// Acquire the single drain lock, or take it over from an older-version
/// drainer. Returns None (→ this drainer should exit) when a live drainer of
/// the SAME version already holds it, so same-version racers collapse to one.
fn acquire_drain_lock() -> Result<Option<LockGuard>> {
    let lock = tts_home()?.join(LOCK_FILE);
    if let Some(p) = lock.parent() { std::fs::create_dir_all(p)?; }
    for _ in 0..3 {
        match std::fs::OpenOptions::new().write(true).create_new(true).open(&lock) {
            Ok(_) => { write_lock(&lock)?; return Ok(Some(LockGuard(lock))); }
            Err(_) => match lock_owner() {
                Some((pid, ver)) if pid_alive(pid) => {
                    if pid == std::process::id() { return Ok(Some(LockGuard(lock))); }
                    if ver == MY_VERSION { return Ok(None); }   // a current drainer already owns it
                    // Older-version drainer is stale → take over; it self-exits
                    // on its next `i_own_lock()` check once we overwrite the pid.
                    write_lock(&lock)?;
                    log_event("drain_takeover", serde_json::json!({ "from_pid": pid, "from_ver": ver, "to_ver": MY_VERSION }));
                    return Ok(Some(LockGuard(lock)));
                }
                _ => { let _ = std::fs::remove_file(&lock); }   // dead/garbage → clear + retry
            }
        }
    }
    Ok(None)
}

/// The detached drainer entrypoint: hold the lock, play queued clips oldest-
/// first in ONE reused tab, deleting each as it finishes, until the queue is
/// empty. Re-scans each loop so clips enqueued mid-drain still play.
/// Seconds the shared player lingers (open + reusable) after the queue empties
/// before it auto-closes. Keeps the player alive for rapid back-to-back asks
/// across threads, then tidies up once you're clearly done.
const PLAYER_IDLE_CLOSE_SECS: u64 = 120;

/// Freshness TTL: a clip that has waited this long (buffered, pause-held, or
/// behind a dead page) is STALE NEWS — playing it as a surprise is worse than
/// skipping it. Expired clips go to History flagged (replayable on purpose),
/// never to the speaker. Override with ADOM_TTS_CLIP_TTL_SECS.
fn clip_ttl_secs() -> u64 {
    std::env::var("ADOM_TTS_CLIP_TTL_SECS").ok()
        .and_then(|v| v.parse().ok()).unwrap_or(7200)
}

/// Enqueue timestamp encoded in a queue entry's filename ({millis:020}-{pid}).
fn entry_enqueue_ms(entry: &Path) -> u64 {
    entry.file_stem().and_then(|s| s.to_str())
        .and_then(|s| s.split('-').next())
        .and_then(|s| s.parse().ok()).unwrap_or_else(now_ms)
}

fn next_queue_entry(dir: &Path) -> Option<PathBuf> {
    let mut entries: Vec<PathBuf> = std::fs::read_dir(dir).into_iter().flatten()
        .filter_map(|e| e.ok()).map(|e| e.path())
        .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json"))
        .collect();
    entries.sort();
    entries.into_iter().next()
}

pub fn drain(surface_cli: Option<Surface>) -> Result<()> {
    // FIRST (still single-threaded): recover surface env if this drainer was
    // spawned from a sanitized shell — the #1 cause of "workspace get failed"
    // → every clip no-playback. Also warn loudly if it's still missing.
    adopt_surface_env();
    if std::env::var("VSCODE_PROXY_URI").is_err() {
        log_event("drain_env_missing", serde_json::json!({ "var": "VSCODE_PROXY_URI" }));
    }
    let cfg = config::load();
    let surface = config::resolve_surface(surface_cli, &cfg.play);
    let Some(_guard) = acquire_drain_lock()? else {
        log_event("drain_skip", serde_json::json!({ "reason": "another drainer holds the lock" }));
        return Ok(());
    };
    log_event("drain_start", serde_json::json!({ "surface": surface.as_str() }));
    let dir = tts_home()?.join(QUEUE_SUBDIR);
    let keep_open = std::env::var("ADOM_TTS_KEEP_OPEN").is_ok();
    sweep_old_snapshots();   // GC any byte-snapshots orphaned by a killed drainer
    rotate_event_log();      // keep events.jsonl bounded (summaries stay honest)

    // ONE persistent player server for this whole drainer lifetime. The single
    // webview tab is opened ONCE and reused for every clip via /poll — clips
    // NEVER trigger a tab re-navigation (the old new-port-per-clip race that
    // could leave a clip "opened but silent"). This one server owns the tab and
    // knows the true playback state of every clip.
    let player = start_player_server()?;
    write_player_portfile(player.port);
    let url = server::webview_url(player.port);
    open_player_surface(surface, &url, &cfg.play);

    let mut next_id: u64 = 1;
    let mut superseded = false;
    loop {
        // SINGLE-OWNER guarantee: if a newer drainer took over the lock, stop
        // NOW — don't fight over the one tab (that flip-flopping between two
        // clips is exactly what this prevents). Leave the tab for the new owner.
        if !i_own_lock() { superseded = true; log_event("drain_superseded", serde_json::json!({})); break; }
        // HUMAN PREEMPT FIRST: a tapped history replay plays NOW, ahead of the
        // whole AI queue. (The human preempts; the AI queues.)
        if let Some(e) = player.shared.preempt.lock().ok().and_then(|mut p| p.take()) {
            if let Some(file) = e.get("file").and_then(|f| f.as_str()).map(str::to_string) {
                if Path::new(&file).exists() {
                    let id = next_id; next_id += 1;
                    let name = e.get("name").and_then(|n| n.as_str()).unwrap_or("clip").to_string();
                    let label = e.get("label").and_then(|l| l.as_str()).unwrap_or("").to_string();
                    let desc = format!("(replay) {}", e.get("desc").and_then(|d| d.as_str()).unwrap_or("")
                        .trim_start_matches("(replay) "));
                    let words_json = e.get("words").map(|w| w.to_string()).unwrap_or_else(|| "[]".into());
                    let bytes = std::fs::read(&file).unwrap_or_default();
                    set_current_clip(&player.shared, CurrentClip {
                        id, bytes: Arc::new(bytes), title: name.clone(), meta: String::new(),
                        label, desc, words_json,
                    });
                    log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id, "replay": true }));
                    let _ = wait_for_clip(&player.shared, id, surface, &url, &cfg.play, now_ms());
                    let _ = log_clip_end(&player.shared, &name);
                }
            }
            continue;   // then back to the queue (interrupted clip restarts)
        }
        // Keep the page's "+N queued" chip honest (clips waiting behind current).
        player.shared.queued.store(queue_depth(&dir).saturating_sub(1) as u64, Ordering::SeqCst);
        if let Some(entry) = next_queue_entry(&dir) {
            let mut retry_in_place = false;
            if let Ok(raw) = std::fs::read_to_string(&entry) {
                if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
                    if let Some(mp3) = v.get("mp3").and_then(|m| m.as_str()).map(PathBuf::from) {
                        if mp3.exists() {
                            let id = next_id; next_id += 1;
                            // Prefer the metadata captured at enqueue time; fall
                            // back to the source sidecar for legacy queue entries.
                            let name = v.get("name").and_then(|s| s.as_str()).map(str::to_string)
                                .unwrap_or_else(|| mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string());
                            let label = v.get("label").and_then(|s| s.as_str()).map(str::to_string)
                                .unwrap_or_else(|| sidecar_label(&mp3));
                            let desc = v.get("desc").and_then(|s| s.as_str()).map(str::to_string)
                                .unwrap_or_else(|| sidecar_field(&mp3, "desc"));
                            let meta = v.get("meta").and_then(|s| s.as_str()).map(str::to_string)
                                .unwrap_or_else(|| sidecar_meta_line(&mp3));
                            let words_json = v.get("words").map(|w| w.to_string())
                                .unwrap_or_else(|| sidecar_words_json(&mp3));
                            let chash = v.get("hash").and_then(|h| h.as_str()).unwrap_or("").to_string();
                            let bytes = std::fs::read(&mp3).unwrap_or_default();
                            // FRESHNESS: a clip that waited past the TTL is
                            // stale — skip it into History (flagged, replayable)
                            // instead of playing yesterday's news at the human.
                            let enq_ms = entry_enqueue_ms(&entry);
                            if now_ms().saturating_sub(enq_ms) > clip_ttl_secs() * 1000 {
                                log_event("clip_expired", serde_json::json!({ "clip": name, "age_s": (now_ms()-enq_ms)/1000 }));
                                record_play_history(&name, &label, &desc, "expired", 0.0, &mp3, &words_json, &chash);
                                if v.get("snapshot").and_then(|b| b.as_bool()).unwrap_or(false) { let _ = std::fs::remove_file(&mp3); }
                                let _ = std::fs::remove_file(&entry);
                                continue;
                            }
                            // DRIVE-MODE BUFFERING: don't start (and burn) a clip
                            // while no player page is connected — a phone's link
                            // to Hydrogen flaps in the car. Hold the queue and
                            // keep re-offering the tab until the page polls again;
                            // clips then play in order. Silence used to eat clips.
                            if !wait_for_audience(&player, surface, &url, &cfg.play, &dir) {
                                superseded = true; // new owner takes the queue (entry left in place)
                                log_event("drain_superseded", serde_json::json!({ "during": "audience_wait" }));
                                break;
                            }
                            set_current_clip(&player.shared, CurrentClip {
                                id, bytes: Arc::new(bytes), title: name.clone(), meta,
                                label: label.clone(), desc: desc.clone(), words_json: words_json.clone(),
                            });
                            log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id }));
                            let expired_mid = wait_for_clip(&player.shared, id, surface, &url, &cfg.play, enq_ms);
                            // Interrupted by a human replay tap? Not a failure —
                            // keep the entry untouched; it restarts after the replay.
                            let preempted = player.shared.preempt.lock().map(|p| p.is_some()).unwrap_or(false);
                            if preempted {
                                log_event("clip_preempted", serde_json::json!({ "clip": name }));
                                retry_in_place = true;
                            } else if expired_mid {
                                // went stale during a long pause-hold: History, not speaker
                                record_play_history(&name, &label, &desc, "expired", 0.0, &mp3, &words_json, &chash);
                            } else {
                            let (verdict, reached_s, dur_s) = log_clip_end(&player.shared, &name);
                            // NEVER-LOSE-A-CLIP: retry a clip the human didn't
                            // meaningfully hear — never fetched (no-playback) OR
                            // died in the first moments (early partial: <10% of
                            // its duration; field case: 0.7s of an 80s clip).
                            // Fresh audience gate each attempt; capped at 3.
                            let early_partial = verdict == "partial"
                                && dur_s > 0.0 && reached_s < (dur_s * 0.10).min(8.0);
                            let attempts = v.get("attempts").and_then(|a| a.as_u64()).unwrap_or(0);
                            if (verdict == "no-playback" || early_partial) && attempts < 3 {
                                let mut v2 = v.clone();
                                v2["attempts"] = serde_json::json!(attempts + 1);
                                if std::fs::write(&entry, v2.to_string()).is_ok() {
                                    log_event("clip_retry", serde_json::json!({
                                        "clip": name, "attempt": attempts + 1,
                                        "reason": if early_partial { "early-partial" } else { "no-playback" },
                                        "replay": "auto",
                                    }));
                                    retry_in_place = true;
                                }
                            } else if verdict == "no-playback" || early_partial {
                                log_event("clip_gave_up", serde_json::json!({ "clip": name, "attempts": attempts, "verdict": verdict }));
                            }
                            // Final outcome (not an in-place retry) → replayable history,
                            // with the audio preserved and the sender's label + desc.
                            if !retry_in_place {
                                let dur = player.shared.dur_ms.load(Ordering::SeqCst) as f64 / 1000.0;
                                record_play_history(&name, &label, &desc, verdict, dur, &mp3, &words_json, &chash);
                            }
                            }
                        }
                        // Delete the byte-snapshot we owned (kept the source safe
                        // from overwrites) — but only once we're done retrying.
                        if !retry_in_place && v.get("snapshot").and_then(|b| b.as_bool()).unwrap_or(false) {
                            let _ = std::fs::remove_file(&mp3);
                        }
                    }
                }
            }
            if !retry_in_place { let _ = std::fs::remove_file(&entry); }
            continue;
        }
        // Queue empty: linger so back-to-back asks reuse this player, then bail
        // out (and close below) after an idle grace so it doesn't sit forever.
        player.shared.queued.store(0, Ordering::SeqCst);
        let idle_start = Instant::now();
        let mut resumed = false;
        while idle_start.elapsed() < Duration::from_secs(PLAYER_IDLE_CLOSE_SECS) {
            thread::sleep(Duration::from_millis(600));
            if !i_own_lock() { superseded = true; break; }
            if next_queue_entry(&dir).is_some() { resumed = true; break; }
        }
        if superseded { log_event("drain_superseded", serde_json::json!({})); break; }
        if !resumed { break; }
    }
    // Tear the one server down. On supersede, leave the tab alone — the new
    // owner already reuses it; only close it when WE idled out.
    log_event("drain_idle_close", serde_json::json!({ "kept_open": keep_open, "superseded": superseded }));
    player.shutdown();
    if !keep_open && !superseded {
        match surface {
            Surface::Hydrogen => { let _ = close_hydrogen_tab(PLAYER_TAB_NAME); }
            Surface::Pup => { let _ = close_pup_window(&cfg.play.pup_session); }
        }
    }
    Ok(())
}

/// Keep the event log bounded: unrotated, week-old incidents dominate the
/// summary counters and misread as "failing today". Keep the newest 3000.
fn rotate_event_log() {
    let Ok(home) = tts_home() else { return };
    let path = home.join(".adom/tts/events.jsonl");
    let raw = std::fs::read_to_string(&path).unwrap_or_default();
    let lines: Vec<&str> = raw.lines().collect();
    if lines.len() > 4000 {
        let keep = &lines[lines.len() - 3000..];
        let _ = std::fs::write(&path, keep.join("\n") + "\n");
    }
}

/// Delete byte-snapshots older than an hour — normally each is removed right
/// after its clip plays, but a drainer killed mid-play can orphan one.
fn sweep_old_snapshots() {
    let Ok(clips) = tts_home().map(|h| h.join(".adom/tts/clips")) else { return };
    let now = std::time::SystemTime::now();
    if let Ok(rd) = std::fs::read_dir(&clips) {
        for e in rd.flatten() {
            if let Ok(meta) = e.metadata() {
                if let Ok(age) = meta.modified().and_then(|m| now.duration_since(m).map_err(|_| std::io::ErrorKind::Other.into())) {
                    if age > Duration::from_secs(3600) { let _ = std::fs::remove_file(e.path()); }
                }
            }
        }
    }
}

fn queue_depth(dir: &Path) -> usize {
    std::fs::read_dir(dir).map(|rd| rd.filter_map(|e| e.ok())
        .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json")).count()).unwrap_or(0)
}

/// Open (or refresh) the single player surface. Idempotent — used both to open
/// the tab once at startup and to RE-OPEN it if the page later disappears.
fn open_player_surface(surface: Surface, url: &str, play_cfg: &config::PlayConfig) {
    let opened = match surface {
        Surface::Hydrogen => server::open_webview_tab_named(url, PLAYER_TAB_NAME),
        Surface::Pup => open_pup_window(url, &play_cfg.pup_session, &play_cfg.pup_profile),
    };
    if let Err(e) = &opened {
        eprintln!("Hint: could not open {} surface ({e}) — visit {url} manually", surface.as_str());
        log_event("open_surface_failed", serde_json::json!({ "surface": surface.as_str(), "error": e.to_string() }));
    }
}

/// Point the persistent player at a new clip (bytes already read + metadata
/// captured at enqueue). The page notices the new id on its next /poll and
/// swaps `<audio>.src` — no page reload. Resets per-clip telemetry so the
/// verdict reflects THIS clip only.
fn set_current_clip(shared: &Arc<PlayerShared>, clip: CurrentClip) {
    shared.dur_ms.store(0, Ordering::SeqCst);
    shared.reached_ms.store(0, Ordering::SeqCst);
    shared.last_ms.store(0, Ordering::SeqCst);
    shared.err_code.store(0, Ordering::SeqCst);
    shared.done.store(false, Ordering::SeqCst);
    shared.user_paused.store(false, Ordering::SeqCst);
    if let Ok(mut e) = shared.events.lock() { e.clear(); }
    if let Ok(mut c) = shared.clip.lock() { *c = clip; }
}

/// Block until a player page is actually POLLING us (a live audience) before
/// starting a clip. In drive mode the phone's Hydrogen link comes and goes;
/// starting a clip into a disconnected page just burns it (verdict=no-playback,
/// the user hears nothing). Instead we hold the queue — clips buffer in order —
/// and keep re-offering the tab until the page reconnects. Returns false only
/// if superseded by a newer drainer (caller exits; queue survives for the new
/// owner). Patience is capped: after AUDIENCE_WAIT_SECS we proceed anyway so a
/// permanently-dead session still drains (and logs no-playback verdicts).
fn wait_for_audience(player: &Player, surface: Surface, url: &str,
    play_cfg: &config::PlayConfig, queue_dir: &Path) -> bool {
    const AUDIENCE_WAIT_SECS: u64 = 600;   // drive-mode patience: buffer up to 10 min
    const LIVE_MS: u64 = 2500;             // one fresh poll = maybe alive…
    const SUSTAIN_CHECKS: u32 = 3;         // …but demand THREE in a row (~2s apart).
    // A locked/backgrounded phone still trickles a poll every ~10-60s (mobile
    // timer throttling) — a single fresh poll used to pass this gate and the
    // clip then started into a zombie page and burned. Requiring sustained
    // polling proves the page is truly foregrounded and can actually play.
    let sustained_live = |shared: &PlayerShared| -> bool {
        for i in 0..SUSTAIN_CHECKS {
            if shared.poll_age_ms() >= LIVE_MS { return false; }
            if i + 1 < SUSTAIN_CHECKS { thread::sleep(Duration::from_millis(1900)); }
        }
        true
    };
    if sustained_live(&player.shared) { return true; }
    log_event("audience_wait", serde_json::json!({ "poll_age_ms": player.shared.poll_age_ms().min(u64::MAX >> 1) }));
    let started = Instant::now();
    let mut last_offer = Instant::now() - Duration::from_secs(60);
    while started.elapsed() < Duration::from_secs(AUDIENCE_WAIT_SECS) {
        if !i_own_lock() { return false; }
        if player.shared.poll_age_ms() < LIVE_MS && sustained_live(&player.shared) {
            log_event("audience_ok", serde_json::json!({ "waited_s": started.elapsed().as_secs() }));
            return true;
        }
        // Re-offer the tab every ~15s — cheap if it's already there; recreates
        // it as soon as Hydrogen answers again after a connection flap.
        if last_offer.elapsed() > Duration::from_secs(15) {
            open_player_surface(surface, url, play_cfg);
            last_offer = Instant::now();
        }
        // Keep the queued-count honest for whenever the page reconnects.
        player.shared.queued.store(queue_depth(queue_dir) as u64, Ordering::SeqCst);
        thread::sleep(Duration::from_millis(700));
    }
    log_event("audience_timeout", serde_json::json!({ "waited_s": AUDIENCE_WAIT_SECS }));
    true
}

/// Block until the current clip finishes, stalls, or times out. Against the
/// persistent server's shared state — and crucially RE-OPENS the tab if the
/// page stops polling (recovers a closed/crashed webview instead of silently
/// failing the clip). This is what makes "opened but silent" self-heal.
fn wait_for_clip(shared: &Arc<PlayerShared>, id: u64, surface: Surface, url: &str, play_cfg: &config::PlayConfig, enqueue_ms: u64) -> bool {
    const STALL_SECS: u64 = 14;
    const START_SECS: u64 = 28;   // room for poll latency + one re-open attempt
    let started = Instant::now();
    let mut deadline = started + Duration::from_secs(SERVER_DEFAULT_TIMEOUT_SECS);
    let mut max_deadline = started + Duration::from_secs(SERVER_MAX_TIMEOUT_SECS);
    let mut pause_hold_logged = false;
    let mut last_reached = 0u64;
    let mut last_advance = Instant::now();
    let mut last_reopen = started;
    let mut reopen_count = 0u32;
    let mut reopened = false;
    let mut offline_grace_logged = false;
    loop {
        // If a newer drainer took over the lock, ABANDON this clip NOW — don't
        // keep playing it on our port while the new owner plays on theirs. That
        // overlap (two players at once) is what makes a clip get heard twice.
        // drain()'s own i_own_lock() check then completes our clean shutdown.
        if !i_own_lock() { break; }
        // HUMAN PREEMPT: a history-replay tap outranks the playing AI clip —
        // abort now; the drain loop plays the replay, then restarts this clip.
        if shared.preempt.lock().map(|p| p.is_some()).unwrap_or(false) { break; }
        // A single drainer means the clip id shouldn't change under us; if it
        // somehow did, stop waiting on the stale clip.
        if shared.clip.lock().map(|c| c.id).unwrap_or(id) != id { break; }
        if shared.done.load(Ordering::SeqCst) {
            thread::sleep(Duration::from_secs(2));   // let the "ended" state show briefly
            break;
        }
        let reached = shared.reached_ms.load(Ordering::SeqCst);
        if reached > last_reached { last_reached = reached; last_advance = Instant::now(); }
        let now = Instant::now();
        // HUMAN PAUSE = hold everything. No stall-advance (a paused clip is
        // not a stuck clip), no deadline, no tab reopen, no next clip. The
        // human un-pauses when they want sound again; supersede and replay
        // preempts (checked above) still break in immediately.
        if shared.user_paused.load(Ordering::SeqCst) {
            // even a held clip goes stale: expire it out of the hold
            if now_ms().saturating_sub(enqueue_ms) > clip_ttl_secs() * 1000 {
                log_event("clip_expired", serde_json::json!({ "id": id, "during": "pause_hold" }));
                return true;
            }
            if !pause_hold_logged {
                pause_hold_logged = true;
                log_event("pause_hold", serde_json::json!({ "id": id, "at_s": reached as f64 / 1000.0 }));
            }
            last_advance = now;
            deadline = now + Duration::from_secs(SERVER_DEFAULT_TIMEOUT_SECS);
            max_deadline = now + Duration::from_secs(SERVER_MAX_TIMEOUT_SECS);
            thread::sleep(Duration::from_millis(400));
            continue;
        }
        // Recover a dead page: if it hasn't polled recently the tab is gone —
        // re-open it (throttled to once / 8s) so the clip still gets heard.
        let poll_age = shared.poll_age_ms();
        // CRITICAL: only ever re-open to rescue a clip that NEVER STARTED
        // (reached == 0 — the tab was dead when the clip began). Re-opening
        // RELOADS the tab, which restarts the clip from 0 — so if we did it
        // after playback began, a flaky mobile connection would make the SAME
        // clip play again and again. Once a clip is progressing we NEVER reopen;
        // a mid-clip connection drop just stalls and we advance to the next clip.
        // Cap rescue attempts so a truly dead tab can't loop-reopen either.
        if reached == 0 && reopen_count < 2
            && poll_age > 9000 && now.duration_since(last_reopen) > Duration::from_secs(12) {
            log_event("player_reopen", serde_json::json!({ "id": id, "poll_age_ms": poll_age, "attempt": reopen_count + 1 }));
            open_player_surface(surface, url, play_cfg);
            last_reopen = now;
            reopen_count += 1;
            reopened = true;
        }
        if reached > 0 && now.duration_since(last_advance) >= Duration::from_secs(STALL_SECS) {
            // OFFLINE-PLAYBACK GRACE: the page downloads each clip fully and
            // plays from a local blob, so a hotspot drop mid-clip silences the
            // *telemetry*, not the audio. If the page has stopped polling but
            // the clip started, it's very likely still playing offline — wait
            // out the clip's own duration (+margin) for the link to return and
            // the first poll to carry the true position/done state.
            let page_offline = shared.poll_age_ms() > 4000;
            let dur_ms = shared.dur_ms.load(Ordering::SeqCst);
            let grace = Duration::from_millis(dur_ms.max(20_000)) + Duration::from_secs(45);
            if page_offline && now.duration_since(started) < grace {
                if !offline_grace_logged {
                    offline_grace_logged = true;
                    log_event("offline_grace", serde_json::json!({ "id": id, "reached_ms": reached, "dur_ms": dur_ms }));
                }
                thread::sleep(Duration::from_millis(500));
                continue;
            }
            eprintln!("# clip stalled at {:.1}s (no progress {STALL_SECS}s) — advancing queue", reached as f64 / 1000.0);
            break;
        }
        if reached == 0 && now.duration_since(started) >= Duration::from_secs(START_SECS) {
            eprintln!("# clip never started within {START_SECS}s{} — skipping so the queue advances",
                if reopened { " (even after re-opening the tab)" } else { "" });
            break;
        }
        if now >= max_deadline { break; }
        if now >= deadline { deadline = (deadline + Duration::from_secs(60)).min(max_deadline); }
        thread::sleep(Duration::from_millis(250));
    }
    false
}

/// Emit the machine-readable playback verdict for the clip that just finished
/// waiting — so the CLI (and any AI driving it) can confirm the clip actually
/// played end-to-end without a human watching.
fn log_clip_end(shared: &Arc<PlayerShared>, name: &str) -> (&'static str, f64, f64) {
    let dur = shared.dur_ms.load(Ordering::SeqCst) as f64 / 1000.0;
    let reached = shared.reached_ms.load(Ordering::SeqCst) as f64 / 1000.0;
    let err = shared.err_code.load(Ordering::SeqCst);
    let done = shared.done.load(Ordering::SeqCst);
    let full = done || (dur > 0.0 && reached >= dur - 0.75);
    let verdict = if err != 0 { "error" } else if full { "ok" } else if reached > 0.0 { "partial" } else { "no-playback" };
    let timeline = shared.events.lock().map(|l| l.join(" | ")).unwrap_or_default();
    eprintln!("PLAYBACK verdict={verdict} reported_duration={dur:.1}s reached={reached:.1}s ended={done} err_code={err}");
    if !timeline.is_empty() { eprintln!("PLAYBACK timeline: {timeline}"); }
    shared.served.fetch_add(1, Ordering::SeqCst);
    log_event("clip_end", serde_json::json!({
        "clip": name, "verdict": verdict, "dur_s": dur, "reached_s": reached,
        "ended": done, "err": err, "timeline": timeline,
    }));
    if verdict != "ok" && std::env::var("ADOM_TTS_NO_HINTS").is_err() {
        eprintln!("# hint: clip did NOT fully play (verdict={verdict}). The player auto-reopens the tab if it");
        eprintln!("#       vanished; if this persists the service may be flapping. Review `adom-tts logs` and");
        eprintln!("#       re-queue if the user needs to hear it. NEVER install a local TTS fallback.");
    }
    (verdict, reached, dur)
}

/// Resolve a CLI `<target>` to a concrete mp3 path.
///
/// Accepts: absolute/relative path, bare 12-char history hash, or `last`.
pub(crate) fn resolve_target(target: &str) -> Result<PathBuf> {
    let trimmed = target.trim();
    if trimmed.is_empty() {
        anyhow::bail!("empty target — pass a path, history id, or 'last'");
    }
    if trimmed == "last" {
        return history::latest_mp3()?
            .ok_or_else(|| anyhow!("no clips in ~/.adom/tts/history/ yet"));
    }
    let p = PathBuf::from(trimmed);
    if p.exists() && p.is_file() {
        return Ok(p);
    }
    // Maybe a history id (e.g. "20260506-141522-ab12cd34" or just "ab12cd34").
    if let Ok(hp) = history::audio_path(trimmed) {
        return Ok(hp);
    }
    // Last resort: scan history for any id whose filename contains the token
    // (lets users pass just the 8-char hash suffix).
    let dir = history::history_dir()?;
    for entry in std::fs::read_dir(&dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.extension().and_then(|s| s.to_str()) != Some("mp3") {
            continue;
        }
        let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
        if stem.contains(trimmed) {
            return Ok(path);
        }
    }
    anyhow::bail!("not a file, history id, or 'last': {trimmed}");
}

fn close_hydrogen_tab(name: &str) -> Result<()> {
    // First try the polite path: remove just this tab. Hydrogen rejects
    // this with HTTP 400 if our tab is the only one in its pane (you
    // can't empty a pane this way), so fall back to close-panel by ID.
    let out = Command::new("adom-cli")
        .args(["hydrogen", "workspace", "remove-tab", "--name", name])
        .output()?;
    let stdout_s = String::from_utf8_lossy(&out.stdout);
    let stderr_s = String::from_utf8_lossy(&out.stderr);
    let blocked = stdout_s.contains("Cannot remove all tabs")
        || stderr_s.contains("Cannot remove all tabs")
        || !out.status.success();
    if !blocked {
        return Ok(());
    }
    // Look up the panel id by tab name and close the whole pane.
    if let Some(panel_id) = find_panel_id_for_tab(name)? {
        let _ = Command::new("adom-cli")
            .args(["hydrogen", "workspace", "close-panel", &panel_id])
            .output();
    }
    Ok(())
}

fn find_panel_id_for_tab(name: &str) -> Result<Option<String>> {
    let out = Command::new("adom-cli")
        .args(["hydrogen", "workspace", "tabs"])
        .output()?;
    if !out.status.success() {
        return Ok(None);
    }
    let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
    let tabs = v.get("tabs").and_then(|t| t.as_array()).cloned().unwrap_or_default();
    Ok(tabs.iter()
        .find(|t| t.get("name").and_then(|n| n.as_str()) == Some(name))
        .and_then(|t| t.get("panelId").and_then(|p| p.as_str()).map(str::to_string)))
}

fn close_pup_window(session: &str) -> Result<()> {
    let payload = serde_json::json!({"sessionId": session}).to_string();
    let _ = Command::new("adom-desktop")
        .args(["browser_close_window", &payload])
        .output();
    Ok(())
}

fn open_pup_window(url: &str, session: &str, profile: &str) -> Result<()> {
    let open_args = serde_json::json!({
        "sessionId": session,
        "profile": profile,
        "url": url,
    }).to_string();
    let out = Command::new("adom-desktop")
        .arg("browser_open_window")
        .arg(&open_args)
        .output()
        .context("invoke adom-desktop browser_open_window")?;
    if !out.status.success() {
        anyhow::bail!("adom-desktop browser_open_window failed: {}",
            String::from_utf8_lossy(&out.stderr));
    }

    // Defeat any first-load autoplay block. The HTML's own retry covers
    // most cases; this is belt-and-braces for a stricter Chrome policy.
    // GUARD: only kick playback if it hasn't STARTED yet (paused at 0 and not
    // ended) — an unconditional play() here would RESTART a clip that already
    // finished (short clips that end before this 1.5s eval), i.e. loop it.
    thread::sleep(Duration::from_millis(1500));
    let eval_args = serde_json::json!({
        "sessionId": session,
        "expr": "(function(){var el=document.querySelector('audio');if(el&&el.paused&&!el.ended&&el.currentTime===0)el.play();})()",
    }).to_string();
    let _ = Command::new("adom-desktop")
        .arg("browser_eval")
        .arg(&eval_args)
        .output();
    Ok(())
}


/// Descriptor of the clip the persistent player is currently bound to.
/// `id == 0` means idle (no clip yet).
#[derive(Clone)]
struct CurrentClip {
    id: u64,
    bytes: Arc<Vec<u8>>,
    title: String,
    meta: String,
    label: String,
    desc: String,
    words_json: String,
}
impl Default for CurrentClip {
    fn default() -> Self {
        CurrentClip {
            id: 0, bytes: Arc::new(Vec::new()),
            title: String::new(), meta: String::new(), label: String::new(),
            desc: String::new(), words_json: "[]".into(),
        }
    }
}

/// Persist a just-played clip into the replayable HISTORY: the human misses
/// clips (drives through a dead zone, two updates land back-to-back) and needs
/// to go BACK. Each entry keeps the audio bytes + WHO sent it (thread label) +
/// WHAT it says (the sender's --desc) so the list is human-scannable, never
/// just mp3 filenames. Capped to the newest 40 entries.
fn record_play_history(name: &str, label: &str, desc: &str, verdict: &str, dur_s: f64, mp3: &Path, words_json: &str, hash: &str) {
    let Ok(home) = tts_home() else { return };
    let dir = home.join(".adom/tts/history/played");
    let _ = std::fs::create_dir_all(&dir);
    let seq = now_millis() as u64;
    let stored = dir.join(format!("{seq}.mp3"));
    if std::fs::copy(mp3, &stored).is_err() { return; }
    // words ride along so a REPLAY keeps its karaoke transcript (an empty
    // words list on replay used to blank the transcript panel).
    let words: serde_json::Value = serde_json::from_str(words_json).unwrap_or_else(|_| serde_json::json!([]));
    let entry = serde_json::json!({
        "seq": seq, "ts_ms": seq, "name": name, "label": label, "desc": desc,
        "verdict": verdict, "dur_s": (dur_s * 10.0).round() / 10.0,
        "file": stored.to_string_lossy(), "words": words, "hash": hash,
    });
    let log = home.join(".adom/tts/play_history.jsonl");
    let mut lines: Vec<String> = std::fs::read_to_string(&log).unwrap_or_default()
        .lines().map(str::to_string).collect();
    lines.push(entry.to_string());
    // prune: keep newest 40, delete evicted audio files
    while lines.len() > 40 {
        let old = lines.remove(0);
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&old) {
            if let Some(f) = v.get("file").and_then(|x| x.as_str()) { let _ = std::fs::remove_file(f); }
        }
    }
    let _ = std::fs::write(&log, lines.join("\n") + "\n");
}

/// Newest-first history entries for the player UI (label + desc + when).
fn read_play_history(limit: usize) -> Vec<serde_json::Value> {
    let Ok(home) = tts_home() else { return Vec::new() };
    let raw = std::fs::read_to_string(home.join(".adom/tts/play_history.jsonl")).unwrap_or_default();
    let mut v: Vec<serde_json::Value> = raw.lines()
        .filter_map(|l| serde_json::from_str(l).ok()).collect();
    v.reverse();
    v.truncate(limit);
    v
}

/// Stage a history entry for IMMEDIATE playback (the "play that one again"
/// button). The human preempts: the drainer aborts the current AI clip (which
/// re-queues and restarts afterward) and plays this now.
fn stage_history_preempt(shared: &Arc<PlayerShared>, seq: u64) -> bool {
    let Some(entry) = read_play_history(40).into_iter()
        .find(|e| e.get("seq").and_then(|s| s.as_u64()) == Some(seq)) else { return false };
    let ok = entry.get("file").and_then(|f| f.as_str()).map(|f| Path::new(f).exists()).unwrap_or(false);
    if !ok { return false; }
    if let Ok(mut p) = shared.preempt.lock() { *p = Some(entry); } else { return false; }
    log_event("history_replay", serde_json::json!({ "seq": seq, "immediate": true }));
    true
}

/// State shared between the persistent HTTP worker (serves the page + audio +
/// telemetry, all keyed to the current clip id) and the drainer loop (sets the
/// current clip, reads back the verdict).
struct PlayerShared {
    clip: std::sync::Mutex<CurrentClip>,
    dur_ms: AtomicU64,
    reached_ms: AtomicU64,
    last_ms: AtomicU64,
    err_code: AtomicU64,
    done: AtomicBool,
    events: std::sync::Mutex<Vec<String>>,
    cmd_seq: AtomicU64,
    cmd_str: std::sync::Mutex<String>,
    queued: AtomicU64,
    served: AtomicU64,
    last_poll_ms: AtomicU64,   // wall-clock ms of the page's last /poll (liveness)
    // The HUMAN paused the player. Pause = "stop talking to me": the drainer
    // holds the current clip AND the whole queue until they press play again.
    user_paused: AtomicBool,
    // HUMAN PREEMPT SLOT: a history-replay tap lands here and plays IMMEDIATELY,
    // interrupting the current AI clip (which re-queues and restarts after).
    // Principle: the human preempts; the AI queues.
    preempt: std::sync::Mutex<Option<serde_json::Value>>,
}
impl PlayerShared {
    /// Milliseconds since the page last polled. `u64::MAX` before any poll →
    /// the drainer treats a never-polling tab as gone and re-opens it.
    fn poll_age_ms(&self) -> u64 {
        let ms = self.last_poll_ms.load(Ordering::SeqCst);
        if ms == 0 { return u64::MAX; }
        now_ms().saturating_sub(ms)
    }
}

/// Live handle to the one persistent player server for a drainer's lifetime.
struct Player {
    shared: Arc<PlayerShared>,
    server: Arc<Server>,
    stop: Arc<AtomicBool>,
    worker: Option<thread::JoinHandle<()>>,
    port: u16,
}
impl Player {
    fn shutdown(mut self) {
        self.stop.store(true, Ordering::SeqCst);
        self.server.unblock();
        if let Some(w) = self.worker.take() { let _ = w.join(); }
    }
}

fn player_page_html() -> String {
    PLAYER_HTML.replace("{{VERSION}}", env!("CARGO_PKG_VERSION"))
}

/// Start the persistent player HTTP server on a stable loopback port. It serves
/// the page ONCE and, for the rest of its life, swaps which clip that page
/// plays via /poll — no per-clip tab navigation.
fn start_player_server() -> Result<Player> {
    let server = Server::http("127.0.0.1:0").map_err(|e| anyhow!("bind 127.0.0.1:0: {e}"))?;
    let port = server.server_addr().to_ip().ok_or_else(|| anyhow!("non-ip server addr"))?.port();
    let server = Arc::new(server);
    let shared = Arc::new(PlayerShared {
        clip: std::sync::Mutex::new(CurrentClip::default()),
        dur_ms: AtomicU64::new(0), reached_ms: AtomicU64::new(0), last_ms: AtomicU64::new(0),
        err_code: AtomicU64::new(0), done: AtomicBool::new(false),
        events: std::sync::Mutex::new(Vec::new()),
        cmd_seq: AtomicU64::new(0), cmd_str: std::sync::Mutex::new(String::new()),
        queued: AtomicU64::new(0), served: AtomicU64::new(0), last_poll_ms: AtomicU64::new(0),
        user_paused: AtomicBool::new(false),
        preempt: std::sync::Mutex::new(None),
    });
    let html = Arc::new(player_page_html());
    let stop = Arc::new(AtomicBool::new(false));

    let server_t = server.clone();
    let shared_t = shared.clone();
    let stop_t = stop.clone();
    let html_t = html.clone();
    let worker = thread::spawn(move || {
        for request in server_t.incoming_requests() {
            if stop_t.load(Ordering::SeqCst) { break; }
            let raw = request.url().to_string();
            let path = raw.split('?').next().unwrap_or("");
            let query = raw.split('?').nth(1).unwrap_or("").to_string();
            let method = request.method().clone();
            let route = strip_proxy_prefix(path);
            let _ = handle_player_request(request, &method, route, &query, &shared_t, &html_t);
        }
    });

    Ok(Player { shared, server, stop, worker: Some(worker), port })
}

/// True if the request's `?id=N` matches the current clip (or omits an id).
/// Guards telemetry so a late beacon from a previous clip can't corrupt the
/// verdict of the current one.
fn id_matches(query: &str, shared: &Arc<PlayerShared>) -> bool {
    let want = qnum(query, "id").map(|n| n as u64).unwrap_or(0);
    let cur = shared.clip.lock().map(|c| c.id).unwrap_or(0);
    want == 0 || want == cur
}

/// Serve the current clip's mp3 bytes (Range/206 aware). 409s a stale id so the
/// page never plays audio from a superseded clip.
fn serve_audio(request: tiny_http::Request, query: &str, shared: &Arc<PlayerShared>) -> std::io::Result<()> {
    let want = qnum(query, "id").map(|n| n as u64).unwrap_or(0);
    let clip = shared.clip.lock().map(|c| c.clone()).unwrap_or_default();
    if clip.id == 0 || (want != 0 && want != clip.id) {
        return respond_inline(request, 409, "text/plain", b"stale clip".to_vec());
    }
    let bytes = clip.bytes;
    let total = bytes.len();
    let range = request.headers().iter()
        .find(|h| h.field.equiv("Range"))
        .and_then(|h| parse_byte_range(h.value.as_str(), total));
    match range {
        Some((start, end)) => {
            let slice = bytes[start..=end].to_vec();
            let len = slice.len();
            let headers = vec![
                Header::from_bytes(&b"Content-Type"[..], b"audio/mpeg".to_vec()).unwrap(),
                Header::from_bytes(&b"Accept-Ranges"[..], b"bytes".to_vec()).unwrap(),
                Header::from_bytes(&b"Content-Range"[..], format!("bytes {start}-{end}/{total}").into_bytes()).unwrap(),
                Header::from_bytes(&b"Cache-Control"[..], b"no-store".to_vec()).unwrap(),
            ];
            request.respond(Response::new(StatusCode(206), headers, Cursor::new(slice), Some(len), None))
        }
        None => {
            let headers = vec![
                Header::from_bytes(&b"Content-Type"[..], b"audio/mpeg".to_vec()).unwrap(),
                Header::from_bytes(&b"Accept-Ranges"[..], b"bytes".to_vec()).unwrap(),
                Header::from_bytes(&b"Content-Length"[..], total.to_string().into_bytes()).unwrap(),
                Header::from_bytes(&b"Cache-Control"[..], b"no-store".to_vec()).unwrap(),
            ];
            request.respond(Response::new(StatusCode(200), headers, Cursor::new(bytes.as_ref().clone()), Some(total), None))
        }
    }
}

/// Route one request against the persistent player's shared state.
fn handle_player_request(request: tiny_http::Request, method: &Method, route: &str, query: &str,
    shared: &Arc<PlayerShared>, html: &Arc<String>) -> std::io::Result<()> {
    match (method, route) {
        (Method::Get, "/" | "/index.html" | "") =>
            respond_inline(request, 200, "text/html; charset=utf-8", html.as_bytes().to_vec()),
        (Method::Get, "/audio" | "audio") => serve_audio(request, query, shared),
        // The page's single poll: heartbeat + current clip + control command.
        // It also CARRIES telemetry (?id&t&dur&done) — after a hotspot gap the
        // first poll through updates the true playback state, so lost beacons
        // can't turn a fully-heard clip into a false "partial"/"no-playback".
        (Method::Get, "/poll" | "poll") => {
            shared.last_poll_ms.store(now_ms(), Ordering::SeqCst);
            if id_matches(query, shared) && qnum(query, "id").map(|n| n as u64).unwrap_or(0) != 0 {
                if let Some(t) = qnum(query, "t") {
                    let ms = (t * 1000.0) as u64;
                    if ms > 0 {
                        shared.reached_ms.fetch_max(ms, Ordering::SeqCst);
                        shared.last_ms.store(ms, Ordering::SeqCst);
                    }
                }
                if let Some(d) = qnum(query, "dur") {
                    if d > 0.0 { shared.dur_ms.store((d * 1000.0) as u64, Ordering::SeqCst); }
                }
                if qnum(query, "done").map(|x| x >= 1.0).unwrap_or(false) {
                    shared.reached_ms.fetch_max(shared.dur_ms.load(Ordering::SeqCst), Ordering::SeqCst);
                    shared.done.store(true, Ordering::SeqCst);
                }
                shared.user_paused.store(qnum(query, "paused").map(|x| x >= 1.0).unwrap_or(false), Ordering::SeqCst);
            }
            let clip = shared.clip.lock().map(|c| c.clone()).unwrap_or_default();
            let cmd = shared.cmd_str.lock().map(|s| s.clone()).unwrap_or_default();
            let words: serde_json::Value = serde_json::from_str(&clip.words_json).unwrap_or_else(|_| serde_json::json!([]));
            let body = serde_json::json!({
                "seq": shared.cmd_seq.load(Ordering::SeqCst),
                "cmd": cmd,
                "id": clip.id,
                "title": clip.title,
                "meta": clip.meta,
                "label": clip.label,
                "desc": clip.desc,
                "words": words,
                "queued": shared.queued.load(Ordering::SeqCst),
                "now_ms": now_ms(),
                // strip words from the listing — the page only needs them when a
                // replay actually starts (they'd bloat every 700ms poll otherwise)
                "history": read_play_history(12).into_iter().map(|mut e| {
                    if let Some(o) = e.as_object_mut() { o.remove("words"); }
                    e
                }).collect::<Vec<_>>(),
            }).to_string();
            respond_inline(request, 200, "application/json", body.into_bytes())
        }
        // "Play that one again" from the History drawer — plays IMMEDIATELY,
        // preempting the current AI clip (the human preempts; the AI queues).
        (Method::Get, "/histplay" | "histplay") => {
            let seq = qnum(query, "seq").map(|n| n as u64).unwrap_or(0);
            let ok = seq != 0 && stage_history_preempt(shared, seq);
            respond_inline(request, 200, "application/json",
                format!("{{\"ok\":{ok}}}").into_bytes())
        }
        (Method::Get, "/meta" | "meta") => {
            if id_matches(query, shared) { if let Some(d) = qnum(query, "dur") { shared.dur_ms.store((d * 1000.0) as u64, Ordering::SeqCst); } }
            respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())
        }
        (Method::Get, "/progress" | "progress") => {
            if id_matches(query, shared) {
                if let Some(t) = qnum(query, "t") {
                    let ms = (t * 1000.0) as u64;
                    shared.reached_ms.fetch_max(ms, Ordering::SeqCst); // monotonic high-water
                    shared.last_ms.store(ms, Ordering::SeqCst);        // live position
                }
            }
            respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())
        }
        (Method::Get, "/err" | "err") => {
            if id_matches(query, shared) { if let Some(c) = qnum(query, "code") { shared.err_code.store(c as u64, Ordering::SeqCst); } }
            respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())
        }
        (Method::Get, "/done" | "done") => {
            if id_matches(query, shared) {
                shared.reached_ms.fetch_max(shared.dur_ms.load(Ordering::SeqCst), Ordering::SeqCst);
                shared.done.store(true, Ordering::SeqCst);
            }
            respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())
        }
        (Method::Get, "/ev" | "ev") => {
            if let Ok(mut log) = shared.events.lock() { if log.len() >= 60 { log.remove(0); } log.push(query.to_string()); }
            respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())
        }
        (Method::Get, "/inject" | "inject") => {
            if let Some(c) = query.split('&').filter_map(|kv| kv.split_once('='))
                .find(|(k, _)| *k == "cmd").map(|(_, v)| v.to_string()) {
                if let Ok(mut s) = shared.cmd_str.lock() { *s = c; }
                shared.cmd_seq.fetch_add(1, Ordering::SeqCst);
            }
            respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())
        }
        (Method::Get, "/state" | "state") => {
            let timeline = shared.events.lock().map(|l| l.join(" | ")).unwrap_or_default();
            let clip = shared.clip.lock().map(|c| c.clone()).unwrap_or_default();
            let poll_age = shared.poll_age_ms().min(999_999_999);
            let body = format!(
                "{{\"dur_ms\":{},\"reached_ms\":{},\"last_ms\":{},\"done\":{},\"err\":{},\"clip\":\"{}\",\"served\":{},\"queued\":{},\"poll_age_ms\":{},\"paused\":{},\"timeline\":\"{}\"}}",
                shared.dur_ms.load(Ordering::SeqCst), shared.reached_ms.load(Ordering::SeqCst),
                shared.last_ms.load(Ordering::SeqCst), shared.done.load(Ordering::SeqCst),
                shared.err_code.load(Ordering::SeqCst), clip.title.replace('"', "'"),
                shared.served.load(Ordering::SeqCst), shared.queued.load(Ordering::SeqCst),
                poll_age, shared.user_paused.load(Ordering::SeqCst), timeline.replace('"', "'"));
            respond_inline(request, 200, "application/json", body.into_bytes())
        }
        _ => respond_inline(request, 404, "text/plain", b"not found".to_vec()),
    }
}

/// If the URL starts with `/proxy/<digits>/`, strip that prefix. The
/// container's `$VSCODE_PROXY_URI` proxy normally strips this before
/// forwarding to localhost, but we strip defensively so the same routes
/// work whether the request arrives directly on 127.0.0.1 or via a
/// proxy that doesn't strip.
/// Record the live player's loopback port so `adom-tts control <cmd>` can
/// reach it. Best-effort: control is a convenience, not load-bearing.
fn write_player_portfile(port: u16) {
    if let Ok(home) = std::env::var("HOME") {
        let dir = std::path::Path::new(&home).join(".adom/tts");
        let _ = std::fs::create_dir_all(&dir);
        let _ = std::fs::write(dir.join("player.json"), format!("{{\"port\":{port}}}"));
    }
}

/// Read the live player's port from the portfile (for `control`).
pub(crate) fn read_player_port() -> Option<u16> {
    let home = std::env::var("HOME").ok()?;
    let raw = std::fs::read_to_string(std::path::Path::new(&home).join(".adom/tts/player.json")).ok()?;
    let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
    v.get("port").and_then(|p| p.as_u64()).map(|p| p as u16)
}

/// Say-time LOUD warning (ask from a driving user who missed an hour of
/// replies): probe the live player and tell the caller RIGHT NOW if no page
/// is actively listening — instead of returning silently and letting `check`
/// discover the burn minutes later. Best-effort: silent if no player is up
/// yet (the drainer will spawn one and buffer).
pub fn warn_if_no_audience() {
    let Some(port) = read_player_port() else { return };
    let Ok(r) = reqwest::blocking::Client::new()
        .get(format!("http://127.0.0.1:{port}/state"))
        .timeout(Duration::from_millis(700)).send() else { return };
    let Ok(v) = r.json::<serde_json::Value>() else { return };
    let age = v.get("poll_age_ms").and_then(|x| x.as_u64()).unwrap_or(0);
    if age > 4000 {
        eprintln!("⚠ AUDIENCE OFFLINE: no player page has polled for {}s — the listener's device is likely", age / 1000);
        eprintln!("  locked/disconnected. Your clip is BUFFERED (up to 10 min, 3 retries) and will play on");
        eprintln!("  reconnect — it has NOT played yet. Use `--confirm` to block until heard, poll");
        eprintln!("  `adom-tts check <clip>`, or run `adom-tts reopen` to re-offer the player tab.");
    }
}

/// Re-offer the player surface for the CURRENT live player without restarting
/// anything — recovers a closed/dead Hydrogen tab in one command.
pub fn reopen() -> Result<()> {
    persist_surface_env();   // a manual recover also refreshes the env snapshot
    let port = read_player_port().ok_or_else(|| anyhow!("no live player (run `say --play` first — it spawns one)"))?;
    let url = server::webview_url(port);
    server::open_webview_tab_named(&url, PLAYER_TAB_NAME)?;
    log_event("manual_reopen", serde_json::json!({ "port": port }));
    println!("→ re-offered the TTS Player tab (player on :{port})");
    println!("# hint: check the header LED — green = the page is polling again. Buffered clips resume");
    println!("#       automatically; verify with `adom-tts status` / `adom-tts check <clip>`.");
    Ok(())
}

/// Inject a control command (replay/play/pause/toggle) into the live player
/// by hitting its loopback /inject endpoint. No window reload.
pub fn control(action: &str) -> Result<()> {
    let port = read_player_port().ok_or_else(|| anyhow!("no live player (run `adom-tts play …` or `say --play` first)"))?;
    let url = format!("http://127.0.0.1:{port}/inject?cmd={action}");
    let resp = reqwest::blocking::Client::new().get(&url).send()
        .map_err(|e| anyhow!("player not reachable on :{port} ({e}) — it may have closed"))?;
    log_event("control", serde_json::json!({ "action": action, "port": port, "ok": resp.status().is_success() }));
    if resp.status().is_success() {
        println!("→ sent '{action}' to live player on :{port}");
        if std::env::var("ADOM_TTS_NO_HINTS").is_err() {
            println!("# hint: this only queues the command — the page runs it on its next poll (<1s). See the");
            println!("#       result with `adom-tts status` (live position) or `adom-tts logs` (event beacons).");
        }
        Ok(())
    } else {
        Err(anyhow!("player returned HTTP {}", resp.status()))
    }
}

/// Pull a numeric query param (`?key=12.3`) out of a raw query string.
fn qnum(query: &str, key: &str) -> Option<f64> {
    query.split('&')
        .filter_map(|kv| kv.split_once('='))
        .find(|(k, _)| *k == key)
        .and_then(|(_, v)| v.parse::<f64>().ok())
}

fn strip_proxy_prefix(url: &str) -> &str {
    let Some(s) = url.strip_prefix("/proxy/") else { return url };
    match s.find('/') {
        Some(i) if s[..i].chars().all(|c| c.is_ascii_digit()) => &s[i..],
        _ => url,
    }
}

/// Parse a single-range HTTP `Range` header value (`bytes=START-END`,
/// `bytes=START-`, or suffix `bytes=-N`) into an inclusive `(start, end)`
/// byte span clamped to `total`. Returns None for absent/unsatisfiable/
/// multi-range values (caller then serves the full 200 body).
fn parse_byte_range(value: &str, total: usize) -> Option<(usize, usize)> {
    if total == 0 { return None; }
    let rest = value.trim().strip_prefix("bytes=")?;
    if rest.contains(',') { return None; } // multi-range: fall back to full body
    let (s, e) = rest.split_once('-')?;
    let (start, end) = if s.is_empty() {
        // suffix: last N bytes
        let n: usize = e.trim().parse().ok()?;
        if n == 0 { return None; }
        (total.saturating_sub(n), total - 1)
    } else {
        let start: usize = s.trim().parse().ok()?;
        let end: usize = if e.trim().is_empty() { total - 1 } else { e.trim().parse().ok()? };
        (start, end)
    };
    if start > end || start >= total { return None; }
    Some((start, end.min(total - 1)))
}

fn respond_inline(req: tiny_http::Request, status: u16, content_type: &str, body: Vec<u8>) -> std::io::Result<()> {
    let ct = Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes())
        .expect("valid header");
    let len = body.len();
    let cursor = Cursor::new(body);
    let resp = Response::new(StatusCode(status), vec![ct], cursor, Some(len), None);
    req.respond(resp)
}

/// Read the `label` (thread name) from a `<file>.json` sidecar, for the
/// player header. Empty when unset.
fn sidecar_label(mp3: &Path) -> String {
    sidecar_field(mp3, "label")
}

/// Read any string field from a `<file>.json` sidecar. Empty when unset.
fn sidecar_field(mp3: &Path, key: &str) -> String {
    let sidecar = mp3.with_extension("json");
    std::fs::read_to_string(&sidecar).ok()
        .and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
        .and_then(|v| v.get(key).and_then(|t| t.as_str()).map(str::to_string))
        .unwrap_or_default()
}

/// If a `<file>.json` sidecar exists (history clip metadata), build a
/// short pretty meta line: `voice · rate · hash`. Returns empty string
/// when no sidecar.
/// Read the narration `text` from a `<file>.json` sidecar, as a JSON array
/// of words for the player's transcript + karaoke highlighting. Empty `[]`
/// when there's no sidecar or no text.
fn sidecar_words_json(mp3: &Path) -> String {
    let sidecar = mp3.with_extension("json");
    let text = std::fs::read_to_string(&sidecar).ok()
        .and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
        .and_then(|v| v.get("text").and_then(|t| t.as_str()).map(str::to_string))
        .unwrap_or_default();
    let words: Vec<&str> = text.split_whitespace().collect();
    serde_json::to_string(&words).unwrap_or_else(|_| "[]".to_string())
}

fn sidecar_meta_line(mp3: &Path) -> String {
    let sidecar = mp3.with_extension("json");
    let raw = match std::fs::read_to_string(&sidecar) {
        Ok(s) => s,
        Err(_) => return String::new(),
    };
    let v: serde_json::Value = match serde_json::from_str(&raw) {
        Ok(v) => v,
        Err(_) => return String::new(),
    };
    let voice = v.get("voice").and_then(|s| s.as_str()).unwrap_or("");
    let rate = v.get("rate").and_then(|s| s.as_str()).unwrap_or("");
    let hash = v.get("tts_hash").and_then(|s| s.as_str()).unwrap_or("");
    let hash_short = &hash[..hash.len().min(12)];
    let mut parts = Vec::new();
    if !voice.is_empty() { parts.push(voice.to_string()); }
    if !rate.is_empty() && rate != "+0%" { parts.push(rate.to_string()); }
    if !hash_short.is_empty() { parts.push(hash_short.to_string()); }
    parts.join(" · ")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn player_page_html_substitutes_version_and_polls() {
        let h = player_page_html();
        // version token filled in, no template tokens left behind
        assert!(h.contains(env!("CARGO_PKG_VERSION")));
        assert!(!h.contains("{{VERSION}}"));
        assert!(!h.contains("{{AUDIO_URL}}"));
        // the persistent page is poll-driven (swaps clips, no per-clip reload),
        // with telemetry riding on the poll and blob-downloaded audio
        assert!(h.contains("'poll?id='"));
        assert!(h.contains("audio?id="));
        assert!(h.contains("createObjectURL"));
    }

    #[test]
    fn strip_proxy_prefix_strips_when_match() {
        assert_eq!(strip_proxy_prefix("/proxy/8888/audio.mp3"), "/audio.mp3");
        assert_eq!(strip_proxy_prefix("/proxy/8888/"), "/");
    }

    #[test]
    fn strip_proxy_prefix_leaves_unrelated_paths() {
        assert_eq!(strip_proxy_prefix("/audio.mp3"), "/audio.mp3");
        assert_eq!(strip_proxy_prefix("/proxy/abc/audio.mp3"), "/proxy/abc/audio.mp3");
        assert_eq!(strip_proxy_prefix("/"), "/");
    }
}