123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
//! Tiny HTTP server for the TTS playground — Hydrogen webview backend.
//!
//! Every UI action has a curl-equivalent on this server (app-creator §7).
//! Synthesis hits the shared service-tts container so pronunciation
//! overrides + source-hash cache apply uniformly.

use crate::history;
use anyhow::Result;
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use serde::Deserialize;
use std::collections::VecDeque;
use std::io::Cursor;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};

const UI_HTML: &str = include_str!("ui.html");
pub(crate) const FAVICON_SVG: &str = include_str!("../docs/icon.svg");
const CONSOLE_CAP: usize = 500;
const TAB_NAME: &str = "TTS Playground";
pub(crate) const PANEL_TYPE: &str = "adom/a1b2c3d4-0031-4000-a000-000000000031";

pub struct ServeConfig {
    pub port: u16,
    pub service_url: String,
    pub open_tab: bool,
    /// Reserved for the /eval hot-patch channel (app-creator §7b).
    #[allow(dead_code)]
    pub dev: bool,
}

struct Shared {
    console: Mutex<VecDeque<serde_json::Value>>,
    last_synth_at: Mutex<Option<String>>,
    voices_cache: Mutex<Option<String>>,
    shutdown: AtomicBool,
}

pub fn run(cfg: ServeConfig) -> Result<()> {
    let addr = format!("0.0.0.0:{}", cfg.port);
    let server = Server::http(&addr)
        .map_err(|e| anyhow::anyhow!("bind {addr}: {e}"))?;
    let server = Arc::new(server);
    let shared = Arc::new(Shared {
        console: Mutex::new(VecDeque::with_capacity(CONSOLE_CAP)),
        last_synth_at: Mutex::new(None),
        voices_cache: Mutex::new(None),
        shutdown: AtomicBool::new(false),
    });

    let url = webview_url(cfg.port);
    println!("OK: adom-tts serve listening on {addr}");
    println!("OK: webview url: {url}");

    if cfg.open_tab {
        match open_webview_tab(&url) {
            Ok(()) => println!("OK: opened Hydrogen webview tab \"{TAB_NAME}\""),
            Err(e) => eprintln!("Hint: could not open webview tab ({e}) — visit {url} manually"),
        }
    }

    for request in server.incoming_requests() {
        if shared.shutdown.load(Ordering::SeqCst) {
            break;
        }
        let shared = shared.clone();
        let server = server.clone();
        let cfg_service_url = cfg.service_url.clone();
        thread::spawn(move || {
            handle(request, &shared, &cfg_service_url, &server);
        });
    }

    if cfg.open_tab {
        let _ = close_webview_tab();
    }
    println!("OK: shutdown complete");
    Ok(())
}

fn handle(request: Request, shared: &Shared, service_url: &str, server: &Server) {
    let method = request.method().clone();
    let raw = request.url().to_string();
    let url = raw.split('?').next().unwrap_or("").to_string();

    let result: std::io::Result<()> = match (&method, url.as_str()) {
        (Method::Get, "/" | "/index.html") => respond_html(request, UI_HTML),
        (Method::Get, "/favicon.svg") => respond(request, 200, "image/svg+xml", FAVICON_SVG.as_bytes().to_vec()),
        (Method::Get, "/favicon.ico") => respond(request, 200, "image/svg+xml", FAVICON_SVG.as_bytes().to_vec()),
        (Method::Get, "/state") => handle_state(request, shared, service_url),
        (Method::Get, "/voices") => handle_voices(request, shared, service_url),
        (Method::Get, "/health") => handle_health(request, service_url),
        (Method::Get, "/pronunciations") => handle_pronunciations(request, service_url),
        (Method::Post, "/pronunciation/propose") => handle_pron_propose(request),
        (Method::Get, "/pronunciation/proposals") => handle_pron_proposals(request),
        (Method::Post, "/synth") => handle_synth(request, shared, service_url),
        (Method::Get, "/history") => handle_history_list(request),
        (Method::Post, "/console") => handle_console_append(request, shared),
        (Method::Get, "/console") => handle_console_tail(request, shared),
        (Method::Delete, "/console") => handle_console_clear(request, shared),
        (Method::Post | Method::Get, "/shutdown") => {
            let _ = respond_json(request, 200, &serde_json::json!({"ok": true}));
            shared.shutdown.store(true, Ordering::SeqCst);
            server.unblock();
            return;
        }
        (Method::Get, path) if path.starts_with("/history/") => {
            let id = path.trim_start_matches("/history/").trim_end_matches(".mp3");
            handle_history_audio(request, id)
        }
        (Method::Delete, path) if path.starts_with("/history/") => {
            let id = path.trim_start_matches("/history/");
            handle_history_delete(request, id)
        }
        _ => respond(request, 404, "application/json",
            b"{\"ok\":false,\"error\":\"not found\"}".to_vec()),
    };
    if let Err(e) = result {
        eprintln!("adom-tts serve: request error: {e}");
    }
}

fn handle_state(req: Request, shared: &Shared, service_url: &str) -> std::io::Result<()> {
    let count = history::list().map(|v| v.len()).unwrap_or(0);
    let last = shared.last_synth_at.lock().unwrap().clone();
    respond_json(req, 200, &serde_json::json!({
        "ok": true,
        "version": env!("CARGO_PKG_VERSION"),
        "service_url": service_url,
        "history_count": count,
        "last_synth_at": last,
    }))
}

fn handle_health(req: Request, service_url: &str) -> std::io::Result<()> {
    match reqwest::blocking::Client::new()
        .get(format!("{service_url}/health"))
        .timeout(std::time::Duration::from_secs(5))
        .send()
    {
        Ok(r) => {
            let status = r.status().as_u16();
            let body = r.text().unwrap_or_default();
            respond(req, status, "application/json", body.into_bytes())
        }
        Err(e) => respond_json(req, 502, &serde_json::json!({
            "ok": false, "error": format!("service-tts unreachable: {e}")
        })),
    }
}

fn handle_voices(req: Request, shared: &Shared, service_url: &str) -> std::io::Result<()> {
    {
        let cache = shared.voices_cache.lock().unwrap();
        if let Some(body) = cache.as_ref() {
            return respond(req, 200, "application/json", body.clone().into_bytes());
        }
    }
    match reqwest::blocking::Client::new()
        .get(format!("{service_url}/tts/voices"))
        .timeout(std::time::Duration::from_secs(15))
        .send()
    {
        Ok(r) if r.status().is_success() => {
            let body = r.text().unwrap_or_default();
            *shared.voices_cache.lock().unwrap() = Some(body.clone());
            respond(req, 200, "application/json", body.into_bytes())
        }
        Ok(r) => respond_json(req, r.status().as_u16(), &serde_json::json!({
            "ok": false, "error": format!("service-tts HTTP {}", r.status())
        })),
        Err(e) => respond_json(req, 502, &serde_json::json!({
            "ok": false, "error": format!("service-tts unreachable: {e}")
        })),
    }
}

#[derive(Deserialize)]
struct SynthReq {
    text: String,
    #[serde(default = "default_voice")]
    voice: String,
    #[serde(default = "default_rate")]
    rate: String,
    #[serde(default = "default_apply")]
    apply_pronunciations: bool,
}
fn default_voice() -> String { "en-US-AndrewNeural".into() }
fn default_rate() -> String { "+0%".into() }
fn default_apply() -> bool { true }

fn handle_synth(mut req: Request, shared: &Shared, service_url: &str) -> std::io::Result<()> {
    let mut body = String::new();
    std::io::Read::read_to_string(req.as_reader(), &mut body).ok();
    let parsed: SynthReq = match serde_json::from_str(&body) {
        Ok(v) => v,
        Err(e) => return respond_json(req, 400, &serde_json::json!({
            "ok": false, "error": format!("bad json: {e}")
        })),
    };
    if parsed.text.trim().is_empty() {
        return respond_json(req, 400, &serde_json::json!({
            "ok": false, "error": "text is empty"
        }));
    }
    let resp = match reqwest::blocking::Client::new()
        .post(format!("{service_url}/tts/say"))
        .json(&serde_json::json!({
            "text": parsed.text,
            "voice": parsed.voice,
            "rate": parsed.rate,
            "apply_pronunciations": parsed.apply_pronunciations,
        }))
        .timeout(std::time::Duration::from_secs(60))
        .send()
    {
        Ok(r) => r,
        Err(e) => return respond_json(req, 502, &serde_json::json!({
            "ok": false, "error": format!("service-tts unreachable: {e}")
        })),
    };
    if !resp.status().is_success() {
        let status = resp.status().as_u16();
        return respond_json(req, status, &serde_json::json!({
            "ok": false, "error": format!("service-tts HTTP {status}")
        }));
    }
    let hash = resp.headers().get("x-tts-hash")
        .and_then(|v| v.to_str().ok()).map(str::to_string);
    let bytes = match resp.bytes() {
        Ok(b) => b.to_vec(),
        Err(e) => return respond_json(req, 502, &serde_json::json!({
            "ok": false, "error": format!("read body: {e}")
        })),
    };
    let clip = match history::save(&bytes, &parsed.text, &parsed.voice, &parsed.rate, "ui", hash) {
        Ok(c) => c,
        Err(e) => return respond_json(req, 500, &serde_json::json!({
            "ok": false, "error": format!("save clip: {e}")
        })),
    };
    *shared.last_synth_at.lock().unwrap() = Some(clip.created_at.clone());
    respond_json(req, 200, &serde_json::json!({"ok": true, "clip": clip}))
}

fn handle_pronunciations(req: Request, service_url: &str) -> std::io::Result<()> {
    match reqwest::blocking::Client::new()
        .get(format!("{service_url}/tts/pronunciations"))
        .timeout(std::time::Duration::from_secs(10))
        .send()
    {
        Ok(r) => {
            let status = r.status().as_u16();
            let body = r.text().unwrap_or_default();
            respond(req, status, "application/json", body.into_bytes())
        }
        Err(e) => respond_json(req, 502, &serde_json::json!({
            "ok": false, "error": format!("service-tts unreachable: {e}")
        })),
    }
}

#[derive(Deserialize)]
struct PronProposal {
    written: String,
    narration: String,
    #[serde(default)]
    reason: String,
}

fn gallia_pronunciations_path() -> Option<std::path::PathBuf> {
    use std::path::PathBuf;
    let candidates: Vec<PathBuf> = [
        std::env::var("ADOM_GALLIA").ok(),
        Some(format!("{}/project/gallia", std::env::var("HOME").unwrap_or_default())),
        Some("/home/adom/project/gallia".into()),
    ].into_iter().flatten()
        .map(|s| PathBuf::from(s).join("skills/tts-pronunciation/pronunciations.json"))
        .collect();
    candidates.into_iter().find(|p| p.exists())
}

fn handle_pron_propose(mut req: Request) -> std::io::Result<()> {
    let mut body = String::new();
    std::io::Read::read_to_string(req.as_reader(), &mut body).ok();
    let p: PronProposal = match serde_json::from_str(&body) {
        Ok(v) => v,
        Err(e) => return respond_json(req, 400, &serde_json::json!({
            "ok": false, "error": format!("bad json: {e}")
        })),
    };
    if p.written.trim().is_empty() || p.narration.trim().is_empty() {
        return respond_json(req, 400, &serde_json::json!({
            "ok": false, "error": "written and narration are required"
        }));
    }
    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
    let added_by = std::env::var("ADOM_USER")
        .or_else(|_| std::env::var("USER"))
        .unwrap_or_else(|_| "unknown".into());
    let reason = if p.reason.trim().is_empty() {
        "Added via TTS Playground.".to_string()
    } else { p.reason.trim().to_string() };
    let entry = serde_json::json!({
        "written": p.written.trim(),
        "narration": p.narration.trim(),
        "engines_confirmed": ["edge-tts"],
        "reason": reason,
        "added_by": added_by,
        "added_date": today,
    });

    // Try to write directly into the gallia skill file.
    if let Some(path) = gallia_pronunciations_path() {
        match append_to_gallia(&path, &entry) {
            Ok(n) => {
                return respond_json(req, 200, &serde_json::json!({
                    "ok": true,
                    "target": "gallia",
                    "path": path.to_string_lossy(),
                    "total_entries": n,
                    "entry": entry,
                    "next_step": format!(
                        "commit + push {} so the service-tts container picks it up on next redeploy",
                        path.display()
                    ),
                }));
            }
            Err(e) => {
                eprintln!("adom-tts: gallia append failed ({e}) — falling back to proposal queue");
            }
        }
    }

    // Fallback: append to local proposals queue.
    let queue = proposals_path();
    if let Some(parent) = queue.parent() { let _ = 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());
    if let Err(e) = std::fs::write(&queue, serde_json::to_vec_pretty(&list).unwrap_or_default()) {
        return respond_json(req, 500, &serde_json::json!({
            "ok": false, "error": format!("write queue: {e}")
        }));
    }
    respond_json(req, 200, &serde_json::json!({
        "ok": true,
        "target": "queue",
        "path": queue.to_string_lossy(),
        "total_entries": list.len(),
        "entry": entry,
        "next_step": format!(
            "gallia checkout not found — entry saved to {}. Paste this JSON into gallia/skills/tts-pronunciation/pronunciations.json > entries when you're ready.",
            queue.display()
        ),
    }))
}

fn proposals_path() -> std::path::PathBuf {
    let home = std::env::var("HOME").unwrap_or_default();
    std::path::PathBuf::from(home).join(".adom/tts/proposed-pronunciations.json")
}

fn handle_pron_proposals(req: Request) -> std::io::Result<()> {
    let queue = proposals_path();
    let list: Vec<serde_json::Value> = std::fs::read_to_string(&queue).ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default();
    respond_json(req, 200, &serde_json::json!({
        "ok": true,
        "path": queue.to_string_lossy(),
        "proposals": list,
    }))
}

/// Append an entry to the gallia pronunciations.json `entries` array while
/// preserving the file's formatting. Returns the new entry count.
fn append_to_gallia(path: &std::path::Path, entry: &serde_json::Value) -> anyhow::Result<usize> {
    let src = std::fs::read_to_string(path)?;
    let mut doc: serde_json::Value = serde_json::from_str(&src)?;
    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
    if let Some(obj) = doc.as_object_mut() {
        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::anyhow!("no entries array"))?;
        // Dedupe by `written` (case-insensitive).
        let written_lc = entry.get("written").and_then(|s| s.as_str())
            .map(|s| s.to_lowercase()).unwrap_or_default();
        entries.retain(|e| {
            e.get("written").and_then(|s| s.as_str())
                .map(|s| s.to_lowercase()) != Some(written_lc.clone())
        });
        entries.push(entry.clone());
        let n = entries.len();
        std::fs::write(path, serde_json::to_string_pretty(&doc)? + "\n")?;
        Ok(n)
    } else {
        anyhow::bail!("pronunciations.json is not a JSON object");
    }
}

fn handle_history_list(req: Request) -> std::io::Result<()> {
    match history::list() {
        Ok(clips) => respond_json(req, 200, &serde_json::json!({"ok": true, "clips": clips})),
        Err(e) => respond_json(req, 500, &serde_json::json!({
            "ok": false, "error": format!("{e}")
        })),
    }
}

fn handle_history_audio(req: Request, id: &str) -> std::io::Result<()> {
    let path = match history::audio_path(id) {
        Ok(p) => p,
        Err(_) => return respond(req, 404, "application/json",
            b"{\"ok\":false,\"error\":\"not found\"}".to_vec()),
    };
    let bytes = std::fs::read(&path)?;
    respond(req, 200, "audio/mpeg", bytes)
}

fn handle_history_delete(req: Request, id: &str) -> std::io::Result<()> {
    match history::delete(id) {
        Ok(()) => respond_json(req, 200, &serde_json::json!({"ok": true, "id": id})),
        Err(e) => respond_json(req, 500, &serde_json::json!({
            "ok": false, "error": format!("{e}")
        })),
    }
}

fn handle_console_append(mut req: Request, shared: &Shared) -> std::io::Result<()> {
    let mut body = String::new();
    std::io::Read::read_to_string(req.as_reader(), &mut body).ok();
    let entry: serde_json::Value = serde_json::from_str(&body)
        .unwrap_or_else(|_| serde_json::json!({"level":"log","msg":body}));
    let mut buf = shared.console.lock().unwrap();
    if buf.len() >= CONSOLE_CAP { buf.pop_front(); }
    buf.push_back(entry);
    respond_json(req, 200, &serde_json::json!({"ok": true}))
}

fn handle_console_tail(req: Request, shared: &Shared) -> std::io::Result<()> {
    let buf = shared.console.lock().unwrap();
    let all: Vec<_> = buf.iter().cloned().collect();
    respond_json(req, 200, &serde_json::json!({"ok": true, "entries": all}))
}

fn handle_console_clear(req: Request, shared: &Shared) -> std::io::Result<()> {
    shared.console.lock().unwrap().clear();
    respond_json(req, 200, &serde_json::json!({"ok": true}))
}

fn respond(req: 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)
}

fn respond_html(req: Request, body: &str) -> std::io::Result<()> {
    respond(req, 200, "text/html; charset=utf-8", body.as_bytes().to_vec())
}

fn respond_json(req: Request, status: u16, value: &serde_json::Value) -> std::io::Result<()> {
    let bytes = serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec());
    respond(req, status, "application/json", bytes)
}

pub(crate) fn webview_url(port: u16) -> String {
    match std::env::var("VSCODE_PROXY_URI") {
        Ok(template) => template.replace("{{port}}", &port.to_string()),
        Err(_) => format!("http://127.0.0.1:{port}/"),
    }
}

fn open_webview_tab(url: &str) -> Result<()> {
    open_webview_tab_named(url, TAB_NAME)
}

pub(crate) fn open_webview_tab_named(url: &str, display_name: &str) -> Result<()> {
    let workspace = Command::new("adom-cli")
        .args(["hydrogen", "workspace", "get"])
        .output()?;
    if !workspace.status.success() {
        anyhow::bail!("adom-cli hydrogen workspace get failed");
    }
    let stdout = String::from_utf8_lossy(&workspace.stdout);
    let icon_data = format!("data:image/svg+xml;base64,{}", B64.encode(FAVICON_SVG.as_bytes()));
    let initial = serde_json::json!({"url": url}).to_string();

    if let Some(leaf_id) = pick_non_vscode_leaf(&stdout) {
        // open-or-refresh REUSES the existing "TTS Player" tab (navigates +
        // reloads it) if one exists, else creates it in `leaf_id`. The stable
        // name means every clip from every thread lands in ONE tab — no pile.
        let out = Command::new("adom-cli")
            .args([
                "hydrogen", "webview", "open-or-refresh",
                "--name", display_name,
                "--url", url,
                "--panel-id", &leaf_id,
                "--panel-type", PANEL_TYPE,
                "--display-icon", &icon_data,
            ])
            .output()?;
        if !out.status.success() {
            anyhow::bail!("open-or-refresh failed: {}", String::from_utf8_lossy(&out.stderr));
        }
        // Blink the tab so a foregrounded webview renders + autoplays (a
        // backgrounded tab is deferred and never plays) and the user notices.
        let _ = Command::new("adom-cli")
            .args(["hydrogen", "alert", "set", "--name", display_name])
            .output();
        return Ok(());
    }

    // No non-VS-Code panel exists — split the VS Code pane to make one.
    // Per `feedback_never_cover_vscode.md` we never put a tab on top of
    // VS Code; splitting creates a sibling pane instead.
    let vscode_id = pick_vscode_leaf(&stdout)
        .ok_or_else(|| anyhow::anyhow!("no panel found in workspace"))?;
    let split = Command::new("adom-cli")
        .args([
            "hydrogen", "workspace", "split",
            "--panel-id", &vscode_id,
            "--direction", "horizontal",
            "--position", "after",
            // ratio = fraction for the FIRST child (the existing VS Code pane,
            // since the player is added "after"/right). 0.80 → VS Code keeps
            // four-fifths, the player takes the right 20% (hardcoded here in the
            // CODE — never rely on a skill; the AI won't follow it perfectly).
            "--ratio", "0.80",
            "--panel-type", PANEL_TYPE,
            "--display-name", display_name,
            "--display-icon", &icon_data,
            "--initial-state", &initial,
        ])
        .output()?;
    if !split.status.success() {
        anyhow::bail!("split failed: {}", String::from_utf8_lossy(&split.stderr));
    }
    Ok(())
}

/// Inverse of `pick_non_vscode_leaf` — returns the first panel id whose
/// tabs include the VS Code panelType. Used as a split anchor when no
/// other host panel exists yet.
fn pick_vscode_leaf(workspace_json: &str) -> Option<String> {
    const VSCODE_SUFFIX: &str = "eeee-4000-a000-00000000000e";
    let v: serde_json::Value = serde_json::from_str(workspace_json).ok()?;

    fn collect(node: &serde_json::Value, out: &mut Vec<serde_json::Value>) {
        if let Some(obj) = node.as_object() {
            if obj.get("type").and_then(|s| s.as_str()) == Some("leaf")
                && obj.contains_key("id")
                && obj.get("tabs").map(|t| t.is_array()).unwrap_or(false)
            {
                out.push(node.clone());
            }
            for (_, v) in obj { collect(v, out); }
        } else if let Some(arr) = node.as_array() {
            for v in arr { collect(v, out); }
        }
    }
    let mut panels = Vec::new();
    collect(&v, &mut panels);

    panels.iter()
        .find(|p| p.get("tabs").and_then(|t| t.as_array()).map(|tabs| {
            tabs.iter().any(|t| t.get("panelType")
                .and_then(|s| s.as_str())
                .map(|s| s.ends_with(VSCODE_SUFFIX))
                .unwrap_or(false))
        }).unwrap_or(false))
        .and_then(|p| p.get("id").and_then(|s| s.as_str()).map(str::to_string))
}

fn pick_non_vscode_leaf(workspace_json: &str) -> Option<String> {
    // Workspace shape: tree of split containers (with `first`/`second`) and
    // panels (with `tabs`). Panels are the leaf nodes that hold tabs — a tab
    // itself carries the `panelType`, not the panel. We want a panel id
    // whose tabs do NOT include the VS Code panelType (never cover VS Code).
    let v: serde_json::Value = serde_json::from_str(workspace_json).ok()?;
    let focused_panel = v.get("focusedPanelId").and_then(|s| s.as_str());
    // VS Code tab panelType ends in "...eeee-4000-a000-00000000000e".
    const VSCODE_SUFFIX: &str = "eeee-4000-a000-00000000000e";

    fn collect_panels(node: &serde_json::Value, out: &mut Vec<serde_json::Value>) {
        if let Some(obj) = node.as_object() {
            if obj.get("type").and_then(|s| s.as_str()) == Some("leaf")
                && obj.contains_key("id")
                && obj.get("tabs").map(|t| t.is_array()).unwrap_or(false)
            {
                out.push(node.clone());
            }
            for (_, v) in obj { collect_panels(v, out); }
        } else if let Some(arr) = node.as_array() {
            for v in arr { collect_panels(v, out); }
        }
    }
    let mut panels = Vec::new();
    collect_panels(&v, &mut panels);

    let covers_vscode = |p: &serde_json::Value| -> bool {
        p.get("tabs").and_then(|t| t.as_array()).map(|tabs| {
            tabs.iter().any(|t| t.get("panelType")
                .and_then(|s| s.as_str())
                .map(|s| s.ends_with(VSCODE_SUFFIX))
                .unwrap_or(false))
        }).unwrap_or(false)
    };

    let ok_panels: Vec<_> = panels.into_iter().filter(|p| !covers_vscode(p)).collect();
    if ok_panels.is_empty() { return None; }

    // Prefer the focused panel if it's not a VS Code host.
    if let Some(fid) = focused_panel {
        if let Some(m) = ok_panels.iter()
            .find(|p| p.get("id").and_then(|s| s.as_str()) == Some(fid))
        {
            return m.get("id").and_then(|s| s.as_str()).map(str::to_string);
        }
    }
    ok_panels.first()
        .and_then(|p| p.get("id").and_then(|s| s.as_str()).map(str::to_string))
}

fn close_webview_tab() -> Result<()> {
    let _ = Command::new("adom-cli")
        .args(["hydrogen", "workspace", "remove-tab", "--name", TAB_NAME])
        .output();
    Ok(())
}