123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699
//! adom-tts — thin Rust CLI for service-tts.
//!
//! Shells every `edge-tts` call through the shared service so (1) Adom
//! pronunciation overrides get auto-applied, (2) repeat calls hit the
//! source-hash cache, (3) swapping backends later is a no-op for callers.
//!
//! Prod URL is baked at compile time from service-tts/service.json via
//! build.rs; override per session via `$ADOM_TTS_API`.
//!
//! Mirrors the service-kicad / step2glb / adom-parts-search CLI pattern.

use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use std::path::PathBuf;

mod config;
mod history;
mod play;
mod server;

use config::Surface;

const PROD_URL: &str = env!("SERVICE_TTS_URL");
const VERSION: &str = env!("CARGO_PKG_VERSION");

fn base_url() -> String {
    std::env::var("ADOM_TTS_API")
        .unwrap_or_else(|_| PROD_URL.to_string())
        .trim_end_matches('/').to_string()
}

fn http_client() -> reqwest::blocking::Client {
    reqwest::blocking::Client::builder()
        .user_agent(concat!("adom-tts/", env!("CARGO_PKG_VERSION")))
        // Chunks are small (≤360 chars → a few seconds to synth), so a request
        // still open at 45s is hung, not slow — fail fast and let synth_chunk
        // retry rather than dangling toward a 60s+ hang.
        .timeout(std::time::Duration::from_secs(45))
        .build().expect("http client")
}

#[derive(Parser)]
#[command(version = VERSION, about = "Shared Adom TTS CLI — see service-tts container")]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// GET /health — reports service version + edge-tts version
    Health,
    /// GET /version — service metadata
    Version,
    /// Print which URL this binary is hitting (bake + env status)
    Config {
        /// Show the resolved play surface and config file location.
        #[arg(long)]
        show: bool,
    },

    /// Synthesize speech and write audio/mpeg to `--out <path>`.
    ///
    /// Pronunciation overrides auto-applied server-side — callers do NOT need
    /// to pre-phoneticize Adom terms like "adom-tsci". Pass
    /// `--no-pronunciations` for raw-text synthesis.
    Say {
        /// Text to synthesize — a string, `-` for stdin, or `@/path/file.txt`
        /// to speak an existing text file's CONTENTS. A bare path that exists
        /// is also read (with a notice); a path that doesn't exist is an
        /// error — the path itself is never spoken aloud.
        text: String,
        #[arg(long)]
        out: PathBuf,
        #[arg(long, default_value = "en-US-AndrewNeural")]
        voice: String,
        #[arg(long, default_value = "+0%")]
        rate: String,
        #[arg(long)]
        no_pronunciations: bool,
        /// Also register the clip in ~/.adom/tts/history/ so it shows up
        /// in the TTS Playground webview app (`adom-tts serve`).
        #[arg(long)]
        history: bool,
        /// After synthesis, auto-play the mp3 in a Hydrogen webview tab
        /// (or a pup browser window — see `--surface`). Detaches by
        /// default so the CLI returns immediately.
        #[arg(long)]
        play: bool,
        /// Override the play surface for this call only ("hydrogen" or "pup").
        #[arg(long, value_parser = parse_surface_arg)]
        surface: Option<Surface>,
        /// With `--play`, block until playback completes (or 5-min cap).
        #[arg(long)]
        wait: bool,
        /// Label this clip with the originating Claude thread name, shown in
        /// the shared player so the listener knows which thread is speaking.
        /// REQUIRED with --play (many threads share one player + history).
        #[arg(long)]
        thread: Option<String>,
        /// One short human sentence describing WHAT this clip is about (e.g.
        /// "fix for the login bug is deployed"). Shown in the player's History
        /// so the listener can find + replay a missed clip. REQUIRED with
        /// --play.
        #[arg(long)]
        desc: Option<String>,
        /// With `--play`, BLOCK until the shared player confirms the clip
        /// actually played to the human (verdict from the event log), and exit
        /// non-zero if it did not. Use this to GUARANTEE the human heard it —
        /// `--play` alone only queues the clip and returns immediately.
        #[arg(long)]
        confirm: bool,
        /// Queue identical audio again ON PURPOSE. Without this, content that
        /// is already queued or played in the last 15 min is REFUSED (exit 7)
        /// — resends double-play on the human.
        #[arg(long)]
        allow_repeat: bool,
    },

    /// Auto-play an mp3 (just-synthesized clip, history id, or `last`).
    ///
    /// Surface picked from --surface > $ADOM_TTS_SURFACE > ~/.adom/tts/config.toml > "hydrogen".
    /// `<target>` accepts: a path, a 12-char history hash (or full id), or
    /// the literal `last` for the newest clip in ~/.adom/tts/history/.
    Play {
        target: String,
        #[arg(long, value_parser = parse_surface_arg)]
        surface: Option<Surface>,
        /// Play identical audio again ON PURPOSE (bypasses duplicate refusal).
        #[arg(long)]
        allow_repeat: bool,
        /// Block until the audio's `ended` event arrives or timeout.
        #[arg(long)]
        wait: bool,
    },
    /// Control the live player without reloading it: replay | play | pause | toggle.
    ///
    /// Sends the command into the most-recently-opened player tab via its
    /// command channel (e.g. `adom-tts control replay` re-plays the clip).
    Control {
        /// One of: replay, play, pause, toggle.
        action: String,
    },
    /// Internal: the background queue drainer (spawned automatically; not for direct use).
    #[command(name = "__drain", hide = true)]
    Drain {
        #[arg(long, value_parser = parse_surface_arg)]
        surface: Option<Surface>,
    },
    /// Review the persistent event log (synthesis + playback + queue + control)
    /// with a summary and anomaly hints — for diagnosing silent clips, service
    /// flaps, and cross-thread player contention.
    Logs {
        /// How many recent events to show.
        #[arg(long, default_value_t = 40)]
        tail: usize,
    },
    /// Query the shared player like a broker: is a player active, what's
    /// playing now, and every clip waiting in the one shared queue (in order).
    /// Any thread can poll this to coordinate politely with other AI threads.
    Status,
    /// Non-blocking "did MY clip play?" query. Pass the clip's mp3 filename
    /// (the --out basename). Reports QUEUED / PLAYING / PLAYED / FAILED and
    /// exits non-zero if it failed or is unknown — so you never tell the human
    /// it played when it didn't. Use after a non-blocking `say --play`.
    Check {
        /// The clip's mp3 filename, e.g. `reply.mp3` (basename of --out).
        clip: String,
    },
    /// Re-offer the shared player tab for the current live player (recovers a
    /// closed/dead Hydrogen tab) without restarting playback or the queue.
    Reopen,
    /// GET /tts/voices — list edge-tts voices as JSON
    Voices,
    /// GET /tts/pronunciations — list the override table
    Pronunciations,
    /// Drop SKILL.md + completions (tool-publisher Tier B install pattern)
    Install,

    /// Start the TTS Playground webview server.
    ///
    /// Opens a Hydrogen webview tab with a UI for typing text, picking a
    /// voice, synthesizing clips, and replaying anything already in
    /// ~/.adom/tts/history/.
    Serve {
        #[arg(long, default_value_t = 8795)]
        port: u16,
        /// Skip opening the Hydrogen webview tab (useful for headless tests).
        #[arg(long)]
        no_tab: bool,
        /// Dev mode (enables extra logging — reserved for future eval channel).
        #[arg(long)]
        dev: bool,
    },

    /// Manage the shared pronunciation overrides.
    #[command(subcommand)]
    Pron(PronCmd),

    /// Copy an existing .mp3 into the playground history store.
    ///
    /// Use when another tool (or a prior `adom-tts say` run) has already
    /// produced an mp3 and you want it to show up in the playground list.
    Push {
        /// Path to the .mp3 file.
        file: PathBuf,
        /// Text label for the clip (shown in the UI).
        #[arg(long, default_value = "")]
        text: String,
        #[arg(long, default_value = "en-US-AndrewNeural")]
        voice: String,
        #[arg(long, default_value = "+0%")]
        rate: String,
    },
}

#[derive(Subcommand)]
enum PronCmd {
    /// GET /tts/pronunciations — print the table loaded on the service.
    List,

    /// Add an entry to the gallia tts-pronunciation skill.
    ///
    /// Writes to gallia/skills/tts-pronunciation/pronunciations.json if a
    /// local gallia checkout is found; otherwise queues the entry at
    /// ~/.adom/tts/proposed-pronunciations.json and prints the JSON to
    /// paste manually.
    Add {
        /// The term as it appears in code / UIs / captions.
        written: String,
        /// Phonetic spelling for the TTS engine.
        narration: String,
        #[arg(long, default_value = "")]
        reason: String,
    },
}

fn parse_surface_arg(s: &str) -> Result<Surface, String> {
    use std::str::FromStr;
    Surface::from_str(s).map_err(|e| e.to_string())
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Health          => cmd_get("/health"),
        Cmd::Version         => cmd_get("/version"),
        Cmd::Config { show } => cmd_config(show),
        Cmd::Voices          => cmd_get("/tts/voices"),
        Cmd::Pronunciations  => cmd_get("/tts/pronunciations"),
        Cmd::Say { text, out, voice, rate, no_pronunciations, history, play, surface, wait, thread, desc, confirm, allow_repeat } =>
            cmd_say(&text, &out, &voice, &rate, !no_pronunciations, history, play, surface, wait, thread, desc, confirm, allow_repeat),
        Cmd::Install         => cmd_install(),
        Cmd::Serve { port, no_tab, dev } => cmd_serve(port, !no_tab, dev),
        Cmd::Push { file, text, voice, rate } => cmd_push(&file, &text, &voice, &rate),
        Cmd::Play { target, surface, allow_repeat, wait } => play::run(&target, surface, wait, allow_repeat),
        Cmd::Control { action } => play::control(&action),
        Cmd::Drain { surface } => play::drain(surface),
        Cmd::Logs { tail } => play::show_logs(tail),
        Cmd::Status => play::show_status(),
        Cmd::Check { clip } => play::check_clip(&clip),
        Cmd::Reopen => play::reopen(),
        Cmd::Pron(PronCmd::List) => cmd_get("/tts/pronunciations"),
        Cmd::Pron(PronCmd::Add { written, narration, reason }) =>
            cmd_pron_add(&written, &narration, &reason),
    }
}

fn cmd_pron_add(written: &str, narration: &str, reason: &str) -> Result<()> {
    let written = written.trim();
    let narration = narration.trim();
    if written.is_empty() || narration.is_empty() {
        return Err(anyhow!("written and narration are required"));
    }
    let reason = if reason.trim().is_empty() {
        "Added via adom-tts pron add.".to_string()
    } else { reason.trim().to_string() };
    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
    let added_by = std::env::var("ADOM_USER").ok()
        .or_else(|| std::env::var("USER").ok())
        .unwrap_or_else(|| "unknown".into());
    let entry = serde_json::json!({
        "written": written,
        "narration": narration,
        "engines_confirmed": ["edge-tts"],
        "reason": reason,
        "added_by": added_by,
        "added_date": today,
    });

    let home = std::env::var("HOME").context("no $HOME")?;
    let gallia_candidates = [
        std::env::var("ADOM_GALLIA").ok(),
        Some(format!("{home}/project/gallia")),
        Some("/home/adom/project/gallia".into()),
    ];
    let path = gallia_candidates.into_iter().flatten()
        .map(|s| PathBuf::from(s).join("skills/tts-pronunciation/pronunciations.json"))
        .find(|p| p.exists());

    if let Some(path) = path {
        let src = std::fs::read_to_string(&path)?;
        let mut doc: serde_json::Value = serde_json::from_str(&src)?;
        let obj = doc.as_object_mut().ok_or_else(|| anyhow!("not an object"))?;
        obj.insert("updated".to_string(), serde_json::Value::String(today));
        let entries = obj.get_mut("entries")
            .and_then(|v| v.as_array_mut())
            .ok_or_else(|| anyhow!("no entries array"))?;
        let w_lc = written.to_lowercase();
        entries.retain(|e| e.get("written").and_then(|s| s.as_str())
            .map(|s| s.to_lowercase()) != Some(w_lc.clone()));
        entries.push(entry.clone());
        let n = entries.len();
        std::fs::write(&path, serde_json::to_string_pretty(&doc)? + "\n")?;
        println!("OK: {} entries in {}", n, path.display());
        eprintln!("Hint: commit + push {} so service-tts picks it up on next redeploy", path.display());
        return Ok(());
    }

    // Fallback: write to a local proposal queue.
    let queue = PathBuf::from(&home).join(".adom/tts/proposed-pronunciations.json");
    if let Some(parent) = queue.parent() { std::fs::create_dir_all(parent)?; }
    let mut list: Vec<serde_json::Value> = std::fs::read_to_string(&queue).ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default();
    list.push(entry.clone());
    std::fs::write(&queue, serde_json::to_string_pretty(&list)? + "\n")?;
    println!("OK: queued in {} ({} pending)", queue.display(), list.len());
    eprintln!("Hint: no gallia checkout found. JSON entry:");
    println!("{}", serde_json::to_string_pretty(&entry)?);
    Ok(())
}

fn cmd_serve(port: u16, open_tab: bool, dev: bool) -> Result<()> {
    server::run(server::ServeConfig {
        port,
        service_url: base_url(),
        open_tab,
        dev,
    })
}

fn cmd_push(file: &std::path::Path, text: &str, voice: &str, rate: &str) -> Result<()> {
    let label = if text.is_empty() {
        file.file_stem().and_then(|s| s.to_str()).unwrap_or("clip").to_string()
    } else {
        text.to_string()
    };
    let clip = history::push_file(file, &label, voice, rate)?;
    println!("OK: pushed {} ({} bytes) → id={}", file.display(), clip.size_bytes, clip.id);
    Ok(())
}

fn cmd_config(show: bool) -> Result<()> {
    let from_env = std::env::var("ADOM_TTS_API").ok();
    println!("url       : {}", base_url());
    println!("source    : {}",
        if from_env.is_some() { "env $ADOM_TTS_API (overriding)" } else { "compile-time baked-in URL" });
    println!("prod bake : {PROD_URL}");
    if show {
        let cfg = config::load();
        let surface = config::resolve_surface(None, &cfg.play);
        let path = config::config_path()
            .map(|p| p.display().to_string())
            .unwrap_or_else(|| "(no $HOME)".into());
        println!();
        println!("[play]");
        println!("config file : {}", path);
        println!("surface     : {}  (effective, after env override)", surface.as_str());
        println!("pup_session : {}", cfg.play.pup_session);
        println!("pup_profile : {}", cfg.play.pup_profile);
    }
    Ok(())
}

fn cmd_get(path: &str) -> Result<()> {
    let url = format!("{}{}", base_url(), path);
    let resp = http_client().get(&url).send().unwrap_or_else(|e| {
        eprintln!("adom-tts: unreachable ({e})");
        std::process::exit(2);
    });
    if !resp.status().is_success() {
        eprintln!("adom-tts: HTTP {}", resp.status());
        std::process::exit(4);
    }
    print!("{}", resp.text().context("read body")?);
    println!();
    Ok(())
}

/// Synthesize ONE (short) chunk via service-tts, retrying transient flaps with
/// backoff. Returns (mp3_bytes, cache, hash). Exits the process on a
/// non-recoverable failure — NEVER falls back to a local engine (shared-infra).
fn synth_chunk(text: &str, voice: &str, rate: &str, apply: bool) -> (Vec<u8>, String, String) {
    let body = serde_json::json!({ "text": text, "voice": voice, "rate": rate, "apply_pronunciations": apply });
    let url = format!("{}/tts/say", base_url());
    const MAX_ATTEMPTS: u32 = 4;
    let mut attempt = 0u32;
    let resp = loop {
        attempt += 1;
        match http_client().post(&url).json(&body).send() {
            Ok(r) if r.status().is_success() => break r,
            Ok(r) => {
                let code = r.status().as_u16();
                let transient = code >= 500 || code == 408 || code == 429;
                play::log_event("synth_attempt", serde_json::json!({ "attempt": attempt, "http": code, "transient": transient }));
                if transient && attempt < MAX_ATTEMPTS {
                    eprintln!("adom-tts: HTTP {code} (attempt {attempt}/{MAX_ATTEMPTS}) — service flapping, retrying");
                    std::thread::sleep(std::time::Duration::from_millis(400 * attempt as u64));
                    continue;
                }
                play::log_event("synth_failed", serde_json::json!({ "http": code, "attempts": attempt }));
                eprintln!("adom-tts: HTTP {code} after {attempt} attempt(s)");
                eprintln!("# hint: service-tts failing — retry later. Long text is already auto-chunked; do NOT install a local TTS engine.");
                std::process::exit(4);
            }
            Err(e) => {
                play::log_event("synth_attempt", serde_json::json!({ "attempt": attempt, "error": e.to_string() }));
                if attempt < MAX_ATTEMPTS {
                    eprintln!("adom-tts: unreachable (attempt {attempt}/{MAX_ATTEMPTS}) — retrying");
                    std::thread::sleep(std::time::Duration::from_millis(400 * attempt as u64));
                    continue;
                }
                play::log_event("synth_failed", serde_json::json!({ "error": e.to_string(), "attempts": attempt }));
                eprintln!("adom-tts: unreachable after {attempt} attempts ({e})");
                eprintln!("# hint: service-tts down/flapping — retry later. Do NOT install a local TTS engine (shared-infra rule).");
                std::process::exit(2);
            }
        }
    };
    if attempt > 1 { play::log_event("synth_recovered", serde_json::json!({ "attempts": attempt })); }
    let cache = resp.headers().get("x-tts-cache").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
    let hash = resp.headers().get("x-tts-hash").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
    let bytes = resp.bytes().map(|b| b.to_vec()).unwrap_or_default();
    (bytes, cache, hash)
}

/// Split text into `<= max`-char chunks at sentence boundaries (falling back to
/// word boundaries for a single over-long sentence) so each synth request stays
/// short and fast — service-tts drops long single requests.
fn split_into_chunks(text: &str, max: usize) -> Vec<String> {
    let chars: Vec<char> = text.chars().collect();
    let mut sentences: Vec<String> = Vec::new();
    let mut cur = String::new();
    for (i, &c) in chars.iter().enumerate() {
        cur.push(c);
        let boundary = matches!(c, '.' | '!' | '?') && chars.get(i + 1).map(|n| n.is_whitespace()).unwrap_or(true);
        if boundary { sentences.push(std::mem::take(&mut cur)); }
    }
    if !cur.trim().is_empty() { sentences.push(cur); }
    let mut chunks: Vec<String> = Vec::new();
    let mut buf = String::new();
    for s in sentences {
        if s.chars().count() > max {
            if !buf.trim().is_empty() { chunks.push(std::mem::take(&mut buf)); }
            let mut w = String::new();
            for word in s.split_whitespace() {
                if !w.is_empty() && w.chars().count() + word.chars().count() + 1 > max { chunks.push(std::mem::take(&mut w)); }
                if !w.is_empty() { w.push(' '); }
                w.push_str(word);
            }
            if !w.trim().is_empty() { chunks.push(w); }
            continue;
        }
        if !buf.trim().is_empty() && buf.chars().count() + s.chars().count() > max { chunks.push(std::mem::take(&mut buf)); }
        buf.push_str(&s);
    }
    if !buf.trim().is_empty() { chunks.push(buf); }
    if chunks.is_empty() { chunks.push(text.to_string()); }
    chunks
}

#[allow(clippy::too_many_arguments)]
/// FILE INPUT is a first-class citizen — and speaking a raw path aloud is a
/// hard error. AIs keep passing files expecting the CLI to read them (a
/// thread once sent `@/tmp/.../colby-message.txt` and the human heard the
/// PATH read out, twice). Rules:
/// - `@/path/file.txt`  -> explicit file input: read the contents (error if
///   missing/binary/huge).
/// - a bare arg that LOOKS like a path (no whitespace, starts with / ./ ~/,
///   plausible length) and EXISTS -> read it, with a notice teaching the
///   explicit `@` form.
/// - looks like a path but does NOT exist -> refuse with a teaching hint
///   (never speak "slash tmp slash ..." to a human).
fn resolve_text_input(text: &str) -> Result<String> {
    const MAX_BYTES: u64 = 200_000;
    let read_file = |path: &str| -> Result<String> {
        let expanded = if let Some(rest) = path.strip_prefix("~/") {
            format!("{}/{rest}", std::env::var("HOME").unwrap_or_default())
        } else { path.to_string() };
        let p = std::path::Path::new(&expanded);
        if !p.is_file() {
            eprintln!("adom-tts: \"{path}\" is not an existing file.");
            eprintln!("# hint: to speak a FILE's contents pass `@/abs/path.txt` (file must exist), pipe it");
            eprintln!("#       (`cat file | adom-tts say -`), or pass the text itself as a normal string.");
            std::process::exit(1);
        }
        let meta = std::fs::metadata(p)?;
        if meta.len() > MAX_BYTES {
            eprintln!("adom-tts: {path} is {} KB — too large to narrate in one clip (max {} KB).", meta.len()/1024, MAX_BYTES/1024);
            eprintln!("# hint: split it, or summarize it and speak the summary. Long text auto-chunks fine,");
            eprintln!("#       but a 200 KB read is ~4 hours of audio — almost certainly not what you meant.");
            std::process::exit(1);
        }
        let bytes = std::fs::read(p)?;
        match String::from_utf8(bytes) {
            Ok(s) => Ok(s),
            Err(_) => {
                eprintln!("adom-tts: {path} is not UTF-8 text (binary file?) — refusing to narrate it.");
                eprintln!("# hint: pass a text file, or the text itself as a string.");
                std::process::exit(1);
            }
        }
    };
    // Explicit @file syntax (curl-style) — always file input.
    if let Some(path) = text.strip_prefix('@') {
        let s = read_file(path)?;
        eprintln!("# read {} chars from {path} — speaking the file's CONTENTS.", s.chars().count());
        return Ok(s);
    }
    // Bare path detection: a "sentence" with no whitespace that starts like a
    // path is never something a human should hear read out character by character.
    let path_like = !text.contains(char::is_whitespace)
        && (text.starts_with('/') || text.starts_with("./") || text.starts_with("~/"))
        && text.len() < 300;
    if path_like {
        let s = read_file(text)?;   // exits with the teaching hint if missing
        eprintln!("# \"{text}\" is a file path — read {} chars and speaking the CONTENTS, not the path.", s.chars().count());
        eprintln!("# hint: this worked, but be explicit next time: `adom-tts say @{text} ...`");
        return Ok(s);
    }
    Ok(text.to_string())
}

fn cmd_say(
    text: &str,
    out: &PathBuf,
    voice: &str,
    rate: &str,
    apply: bool,
    register_history: bool,
    play_after: bool,
    surface: Option<Surface>,
    wait: bool,
    thread: Option<String>,
    desc: Option<String>,
    confirm: bool,
    allow_repeat: bool,
) -> Result<()> {
    // FORCED IDENTIFICATION: many AI threads share ONE player + ONE history.
    // A history entry named "reply.mp3" is useless to the human — they need
    // WHO sent it (thread) and WHAT it says (desc) to find + replay a missed
    // clip. So --play refuses to queue anonymous clips. This is deliberate:
    // the error teaches the calling AI the contract.
    if play_after && (thread.as_deref().map_or(true, |s| s.trim().is_empty())
                   || desc.as_deref().map_or(true, |s| s.trim().is_empty())) {
        eprintln!("adom-tts: --play requires BOTH --thread and --desc (refusing to queue an anonymous clip).");
        eprintln!("# hint: many AI threads share one player + a replayable History. The human needs to know");
        eprintln!("#       WHO is speaking and WHAT the clip is about to find/replay it. Re-run with:");
        eprintln!("#         --thread \"<your thread's short name, e.g. login-bug>\"");
        eprintln!("#         --desc   \"<ONE short sentence: what this update says, e.g. 'login fix deployed'>\"");
        std::process::exit(6);
    }
    let text = if text == "-" {
        let mut s = String::new();
        std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
        s
    } else {
        resolve_text_input(text)?
    };
    if text.trim().is_empty() {
        return Err(anyhow!("empty text — pass a string or '-' for stdin"));
    }
    // service-tts synthesizes the WHOLE body before responding (no streaming),
    // so synthesis time scales with length and a longer request sits closer to
    // the proxy's read timeout — making long says an intermittent coin-flip
    // (worked once at 1,800 chars, failed later at 1,100). Fix in the CLI: keep
    // EVERY request short and deterministic by chunking aggressively at sentence
    // boundaries, synth each small piece SEQUENTIALLY (fast, individually cached,
    // and gentle on a single-worker service — parallel requests would worsen the
    // contention the service already shows), then concatenate into ONE clip.
    // Transparent to callers — `say "<1800 chars>"` just works, as one clip.
    const CHUNK_LIMIT: usize = 360;
    let nchars = text.chars().count();
    let chunks = if nchars > CHUNK_LIMIT { split_into_chunks(&text, CHUNK_LIMIT) } else { vec![text.clone()] };
    if chunks.len() > 1 {
        play::log_event("synth_chunked", serde_json::json!({ "chunks": chunks.len(), "chars": nchars }));
        if std::env::var("ADOM_TTS_NO_HINTS").is_err() {
            eprintln!("# long text ({nchars} chars) auto-split into {} chunks (service-tts drops long single requests); stitched into one clip", chunks.len());
        }
    }
    let mut bytes: Vec<u8> = Vec::new();
    let (mut cache, mut hash) = (String::new(), String::new());
    for (i, chunk) in chunks.iter().enumerate() {
        let (cb, cc, ch) = synth_chunk(chunk, voice, rate, apply);
        if i == 0 { cache = cc; hash = ch; }
        bytes.extend_from_slice(&cb);
    }
    // Guard against a flap that returns 200 but a truncated/empty body → silence.
    if bytes.len() < 512 {
        play::log_event("synth_short_body", serde_json::json!({ "bytes": bytes.len() }));
        eprintln!("adom-tts: WARNING — synthesized body is only {} bytes (likely a service hiccup); the clip may be silent.", bytes.len());
    }
    play::log_event("synth_ok", serde_json::json!({ "out": out.to_string_lossy(), "bytes": bytes.len(), "cache": cache, "chunks": chunks.len() }));
    std::fs::write(out, &bytes)?;
    // Write a `<out>.json` sidecar with the text + voice/rate/hash so the
    // player can show the transcript (and karaoke-highlight it) and a meta line.
    let sidecar = out.with_extension("json");
    let sc = serde_json::json!({ "text": text, "voice": voice, "rate": rate, "tts_hash": hash,
        "label": thread.clone().unwrap_or_default(), "desc": desc.clone().unwrap_or_default() });
    let _ = std::fs::write(&sidecar, serde_json::to_vec_pretty(&sc).unwrap_or_default());
    // Self-identify hint: clips from all the user's parallel Claude threads
    // share one player, so each should announce which thread it is out loud.
    if thread.is_none() && std::env::var("ADOM_TTS_NO_HINTS").is_err() {
        eprintln!("# hint: clips from ALL your Claude threads play through ONE shared player. Begin your");
        eprintln!("#       narration by saying which thread you are (e.g. \"This is the <thread> thread —\"),");
        eprintln!("#       and pass `--thread \"<name>\"` so the player shows the label too.");
    }
    println!("→ {} ({} bytes, cache={}, hash={})", out.display(), bytes.len(), cache, &hash[..hash.len().min(12)]);
    if register_history {
        let clip = history::save(&bytes, &text, voice, rate, "cli", Some(hash.clone()))?;
        eprintln!("✓ registered in playground history: {}", clip.id);
    }
    if play_after {
        let target = out.to_string_lossy().to_string();
        let since = play::now_ms();
        play::run(&target, surface, wait, allow_repeat)?;
        let clip = out.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();
        if confirm {
            // GUARANTEE the human heard it: block until the drainer logs this
            // clip's playback verdict. Timeout scales with the clip length.
            let est = std::fs::metadata(out).map(|m| (m.len() / 6000).max(3)).unwrap_or(30);
            // Generous cap: clips now BUFFER while the listener's device is
            // offline (drive-mode flaps), so confirmation can legitimately
            // arrive minutes later when the phone reconnects.
            let timeout = (est + 150).min(600);
            play::confirm_playback(&clip, since, timeout)?;
        } else if std::env::var("ADOM_TTS_NO_HINTS").is_err() {
            eprintln!("# hint: --play only QUEUED this clip — it does NOT confirm the human heard it. Add");
            eprintln!("#       `--confirm` to BLOCK until playback is verified (do this for hands-free/driving),");
            eprintln!("#       or run `adom-tts logs`. Never tell the human it played unless it's confirmed.");
            // FAIL LOUD at say-time if nobody is listening (don't let the caller
            // discover a burned clip minutes later via `check`).
            play::warn_if_no_audience();
        }
    } else if std::env::var("ADOM_TTS_NO_HINTS").is_err() {
        // Self-teach: most callers want to play the mp3 they just made.
        // Tell them how before they go write a custom autoplay HTML.
        eprintln!("# hint: pass --play to auto-play this clip in a Hydrogen tab or pup window");
        eprintln!("# hint: or run `adom-tts play {}` separately (same effect)", out.display());
        eprintln!("# hint: don't author a custom autoplay HTML or create a port mapping —");
        eprintln!("#       adom-tts ships the auto-player. silence: ADOM_TTS_NO_HINTS=1");
    }
    Ok(())
}

// The USER skill ships inside the binary so `adom-tts install` (the Tier B
// curl-install path, no package dir around) always writes a skill that matches
// this exact build. Dev/publish skills are source-only (dev-skills/,
// publish-skills/ on the wiki page repo) and are NEVER installed to users.
const USER_SKILL: &str = include_str!("../skills/adom-tts/SKILL.md");

fn cmd_install() -> Result<()> {
    let home = std::env::var("HOME").map(PathBuf::from)
        .map_err(|_| anyhow!("no $HOME"))?;
    let skill_dir = home.join(".claude/skills/adom-tts");
    std::fs::create_dir_all(&skill_dir)?;
    std::fs::write(skill_dir.join("SKILL.md"), USER_SKILL)?;
    eprintln!("✓ skill → {}", skill_dir.join("SKILL.md").display());
    // Clean up the legacy build-skill leak (shipped to users before the
    // three-tier layout; it belongs in dev-skills/ on the wiki page only).
    let legacy = home.join(".claude/skills/adom-tts-build");
    if legacy.exists() {
        let _ = std::fs::remove_dir_all(&legacy);
        eprintln!("✓ removed legacy dev-skill leak {}", legacy.display());
    }
    write_completions(&home)
}

fn write_completions(home: &std::path::Path) -> Result<()> {
    let comp_dir = home.join(".bash_completion.d");
    std::fs::create_dir_all(&comp_dir)?;
    std::fs::write(comp_dir.join("adom-tts"),
        "_adom_tts() {\n\
         \tlocal cur=\"${COMP_WORDS[COMP_CWORD]}\"\n\
         \tlocal prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n\
         \tcase \"$prev\" in\n\
         \t\t--surface) COMPREPLY=($(compgen -W \"hydrogen pup\" -- \"$cur\")); return ;;\n\
         \tesac\n\
         \tif [[ \"$cur\" == --* ]]; then\n\
         \t\tCOMPREPLY=($(compgen -W \"--play --surface --wait --out --voice --rate --history --no-pronunciations --show\" -- \"$cur\"))\n\
         \t\treturn\n\
         \tfi\n\
         \tCOMPREPLY=($(compgen -W \"health version config say voices pronunciations install serve push play pron\" -- \"$cur\"))\n\
         }\n\
         complete -F _adom_tts adom-tts\n")?;
    eprintln!("✓ completions at {}", comp_dir.join("adom-tts").display());
    Ok(())
}