123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877
//! Voiceover-specific HTTP handlers.
//!
//! Plugs into the shared `webapp` module via `impl Routes for
//! VoiceoverRoutes`. The built-in webapp routes (UI, console, /eval,
//! /shutdown, favicon) are handled by `webapp::server`; this file
//! only contains the voiceover-specific surface: audio start/stop,
//! normalize, finalize/mux, take history, waveform, timeline,
//! reveal-in-explorer, and the /video + /final-video range streams.
//!
//! Voiceover is audio-only: Hydrogen's first-class audio API
//! (`adom-cli hydrogen audio start/stop`) owns mic capture. Our
//! role is to orchestrate start/stop, mux the resulting .webm
//! against the input video, and expose a UI for reviewing takes.

use super::{mux, VoiceoverConfig};
use crate::webapp::{video as webapp_video, RouteOutcome, Routes};
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};
use tiny_http::{Header, Method, Request, Response};

#[derive(Default)]
pub struct AppState {
    recording: bool,
    audio_path: Option<PathBuf>,
    audio_size: u64,
    finalize_path: Option<PathBuf>,
    /// Sidecar preview path produced by /normalize. The UI's /final-video
    /// endpoint serves this if set, so users can audition the loudness-
    /// normalized version before committing. /save-final copies this
    /// over finalize_path and clears the preview.
    preview_path: Option<PathBuf>,
    phase: String,
}

pub type SharedState = Arc<Mutex<AppState>>;

/// The voiceover subcommand's `impl Routes`. Captures the config,
/// the shared AppState, and the result slot that `run()` waits on.
pub struct VoiceoverRoutes {
    pub cfg: VoiceoverConfig,
    pub state: SharedState,
    pub result_slot: Arc<Mutex<Option<Result<PathBuf, String>>>>,
}

const UI_HTML: &str = include_str!("ui.html");
const FAVICON_SVG: &str = include_str!("icon.svg");

impl VoiceoverRoutes {
    pub fn new(
        cfg: VoiceoverConfig,
        result_slot: Arc<Mutex<Option<Result<PathBuf, String>>>>,
    ) -> Self {
        Self {
            cfg,
            state: Arc::new(Mutex::new(AppState {
                phase: "idle".to_string(),
                ..Default::default()
            })),
            result_slot,
        }
    }
}

impl Routes for VoiceoverRoutes {
    fn ui_html(&self) -> &str {
        UI_HTML
    }

    fn favicon_svg(&self) -> &str {
        FAVICON_SVG
    }

    fn handle(&self, request: Request, method: &Method, url: &str) -> RouteOutcome {
        let res: Result<(), String> = match (method, url) {
            (Method::Get, "/video") => {
                webapp_video::serve_file_range(request, &self.cfg.input, "video/webm")
            }
            (Method::Get, "/final-video") => {
                // Prefer the preview (normalized) version if one exists,
                // otherwise fall back to the committed final output.
                let path = self
                    .state
                    .lock()
                    .unwrap()
                    .preview_path
                    .clone()
                    .unwrap_or_else(|| self.cfg.output.clone());
                webapp_video::serve_file_range(request, &path, "video/webm")
            }
            (Method::Post, "/normalize") => handle_normalize(request, &self.state, &self.cfg.output),
            (Method::Post, "/revert-to-raw") => handle_revert_to_raw(request, &self.state),
            (Method::Post, "/save-final") => handle_save_final(request, &self.state, &self.cfg.output),
            (Method::Post, "/reveal") => handle_reveal(request, &self.state, &self.cfg.output),
            (Method::Post, "/adopt-latest-audio") => handle_adopt_latest_audio(request, &self.state),
            (Method::Get, "/takes") => handle_list_takes(request, &self.state, &self.cfg.input, &self.cfg.output),
            (Method::Get, "/timeline") => handle_timeline(request, &self.cfg.input, self.cfg.markers_file.as_deref()),
            (Method::Post, "/select-take") => handle_select_take(request, &self.state),
            (Method::Get, path)
                if path == "/waveform" || path == "/waveform-raw" || path == "/waveform-preview" =>
            {
                let which = if path.ends_with("-raw") {
                    "raw"
                } else {
                    "preview"
                };
                handle_waveform(request, &self.state, &self.cfg.output, which)
            }
            (Method::Get, "/state") => handle_get_state(request, &self.state),
            (Method::Post, "/start-recording") => handle_start_recording(request, &self.state),
            (Method::Post, "/stop-recording") => handle_stop_recording(request, &self.state),
            (Method::Post, "/discard") => handle_discard(request, &self.state),
            (Method::Post, "/replay") => handle_replay(request, &self.state, &self.cfg.output),
            (Method::Post, "/finalize") => {
                let r = handle_finalize(request, &self.cfg.input, &self.state, &self.cfg.output);
                if let Some(path) = r {
                    *self.result_slot.lock().unwrap() = Some(Ok(path));
                }
                Ok(())
            }
            _ => return RouteOutcome::NotHandled(request),
        };
        RouteOutcome::Handled(res)
    }
}

fn handle_start_recording(request: Request, state: &SharedState) -> Result<(), String> {
    println!("starting voiceover audio-only recording with caption countdown");
    // `hydrogen audio start` is audio-only (does NOT capture the screen),
    // but has no native countdown primitive. We drive a 3-2-1 overlay
    // server-side via `hydrogen caption show` so both the UI-click path
    // and the external curl/AI path get the same countdown.
    let _ = Command::new("adom-cli")
        .args(["hydrogen", "audio", "enable", "--reason", "Voiceover recording"])
        .output();

    for digit in ["3", "2", "1"] {
        let _ = Command::new("adom-cli")
            .args(["hydrogen", "caption", "show", digit, "-d", "1", "-s", "large", "-p", "center"])
            .output();
        std::thread::sleep(std::time::Duration::from_millis(1000));
    }
    let _ = Command::new("adom-cli")
        .args(["hydrogen", "caption", "show", "GO", "-d", "1", "-s", "large", "-p", "center"])
        .output();
    std::thread::sleep(std::time::Duration::from_millis(400));

    let result = Command::new("adom-cli")
        .args(["hydrogen", "audio", "start"])
        .output()
        .map_err(|e| format!("adom-cli spawn failed: {}", e))?;
    if !result.status.success() {
        let stderr = String::from_utf8_lossy(&result.stderr);
        let _ = request.respond(json_response(
            500,
            &format!(r#"{{"ok":false,"error":"audio start failed: {}"}}"#, stderr.replace('"', "\\\"")),
        ));
        return Ok(());
    }
    {
        let mut s = state.lock().unwrap();
        s.recording = true;
        s.phase = "recording".to_string();
        s.audio_path = None;
        s.audio_size = 0;
    }
    let _ = request.respond(json_response(200, r#"{"ok":true}"#));
    Ok(())
}

fn handle_stop_recording(request: Request, state: &SharedState) -> Result<(), String> {
    println!("stopping Hydrogen audio recording...");
    let result = Command::new("adom-cli")
        .args(["hydrogen", "audio", "stop"])
        .output()
        .map_err(|e| format!("adom-cli spawn failed: {}", e))?;
    if !result.status.success() {
        let stderr = String::from_utf8_lossy(&result.stderr);
        let _ = request.respond(json_response(
            500,
            &format!(r#"{{"ok":false,"error":"audio stop failed: {}"}}"#, stderr.replace('"', "\\\"")),
        ));
        return Ok(());
    }
    let stdout = String::from_utf8_lossy(&result.stdout);
    let parsed: serde_json::Value = serde_json::from_str(&stdout)
        .map_err(|e| format!("parse audio stop response: {} ({})", e, stdout))?;
    let file_rel = parsed
        .get("filePath")
        .and_then(|v| v.as_str())
        .ok_or_else(|| format!("audio stop response missing filePath: {}", stdout))?;
    let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
    let absolute = PathBuf::from(home).join("project").join(file_rel);
    let size = parsed.get("size").and_then(|v| v.as_u64()).unwrap_or(0);
    println!("Hydrogen saved audio: {} ({} bytes)", absolute.display(), size);

    {
        let mut s = state.lock().unwrap();
        s.recording = false;
        s.phase = "recorded".to_string();
        s.audio_path = Some(absolute.clone());
        s.audio_size = size;
    }

    let body = format!(
        r#"{{"ok":true,"path":"{}","size":{}}}"#,
        absolute.display(),
        size
    );
    let _ = request.respond(json_response(200, &body));
    Ok(())
}

fn next_take_number(output: &std::path::Path) -> u32 {
    let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));
    let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
    let prefix = format!("{}-take-", stem);
    let mut max_n = 0u32;
    if let Ok(read) = std::fs::read_dir(parent) {
        for e in read.flatten() {
            if let Some(name) = e.file_name().to_str().map(|s| s.to_string()) {
                if let Some(rest) = name.strip_prefix(&prefix) {
                    let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
                    if let Ok(n) = num.parse::<u32>() { if n > max_n { max_n = n; } }
                }
            }
        }
    }
    max_n + 1
}

fn take_raw_path(output: &std::path::Path, n: u32) -> PathBuf {
    let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));
    let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
    let ext = output.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "webm".to_string());
    parent.join(format!("{}-take-{}.{}", stem, n, ext))
}

fn take_norm_path(output: &std::path::Path, n: u32) -> PathBuf {
    let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));
    let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
    let ext = output.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "webm".to_string());
    parent.join(format!("{}-take-{}-norm.{}", stem, n, ext))
}

fn run_loudnorm(source: &std::path::Path, dest: &std::path::Path) -> Result<(), String> {
    let result = Command::new("ffmpeg")
        .args(["-y", "-i"])
        .arg(source)
        .args([
            "-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
            "-c:v", "copy",
            "-c:a", "libopus",
            "-b:a", "128k",
        ])
        .arg(dest)
        .output()
        .map_err(|e| format!("ffmpeg spawn failed: {}", e))?;
    if !result.status.success() {
        let stderr = String::from_utf8_lossy(&result.stderr);
        return Err(format!("ffmpeg loudnorm failed: {}", stderr.lines().rev().take(5).collect::<Vec<_>>().join("; ")));
    }
    Ok(())
}

fn handle_finalize(
    request: Request,
    input_video: &std::path::Path,
    state: &SharedState,
    output: &std::path::Path,
) -> Option<PathBuf> {
    let audio = state.lock().unwrap().audio_path.clone();
    let audio = match audio {
        Some(p) if p.exists() => p,
        _ => {
            let _ = request.respond(json_response(
                400,
                r#"{"ok":false,"error":"no audio recorded yet, click Record first"}"#,
            ));
            return None;
        }
    };
    state.lock().unwrap().phase = "finalizing".to_string();

    // Versioned raw mux: output-take-N.webm. Then auto-loudnorm to
    // output-take-N-norm.webm so every finalize produces both tabs
    // (raw and auto-leveled) at once, no extra clicks.
    let take_n = next_take_number(output);
    let raw = take_raw_path(output, take_n);
    let norm = take_norm_path(output, take_n);
    println!("muxing take {}: {} + {} -> {}", take_n, input_video.display(), audio.display(), raw.display());

    if let Err(e) = mux(input_video, &audio, &raw) {
        state.lock().unwrap().phase = "error".to_string();
        let body = format!(r#"{{"ok":false,"error":"{}"}}"#, e.replace('"', "\\\""));
        let _ = request.respond(json_response(500, &body));
        return None;
    }
    println!("mux complete: {}", raw.display());

    let norm_ok = run_loudnorm(&raw, &norm).is_ok();
    if norm_ok {
        println!("auto-loudnorm complete: {}", norm.display());
    } else {
        println!("auto-loudnorm failed; falling back to raw only for take {}", take_n);
    }

    // Default selection: auto-leveled version (usually the user's preferred).
    {
        let mut s = state.lock().unwrap();
        s.phase = "done".to_string();
        s.finalize_path = Some(raw.clone());
        s.preview_path = Some(if norm_ok { norm.clone() } else { raw.clone() });
    }
    let body = format!(
        r#"{{"ok":true,"take":{},"rawPath":"{}","normalizedPath":"{}","normalizedOk":{}}}"#,
        take_n, raw.display(), norm.display(), norm_ok
    );
    let _ = request.respond(json_response(200, &body));
    Some(raw)
}

fn normalized_sidecar_path(output: &std::path::Path) -> PathBuf {
    use std::time::{SystemTime, UNIX_EPOCH};
    let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
    let ext = output.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "webm".to_string());
    let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));
    let ts = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
    parent.join(format!("{}-norm-{}.{}", stem, ts, ext))
}

fn handle_normalize(request: Request, state: &SharedState, output: &std::path::Path) -> Result<(), String> {
    let source = {
        let s = state.lock().unwrap();
        s.preview_path.clone().unwrap_or_else(|| {
            s.finalize_path.clone().unwrap_or_else(|| output.to_path_buf())
        })
    };
    if !source.exists() {
        let _ = request.respond(json_response(
            400,
            r#"{"ok":false,"error":"nothing to normalize; finalize a recording first"}"#,
        ));
        return Ok(());
    }
    let preview = normalized_sidecar_path(output);
    println!("normalizing {} -> {} (loudnorm I=-16 TP=-1.5 LRA=11)", source.display(), preview.display());

    let result = Command::new("ffmpeg")
        .args(["-y", "-i"])
        .arg(&source)
        .args([
            "-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
            "-c:v", "copy",
            "-c:a", "libopus",
            "-b:a", "128k",
        ])
        .arg(&preview)
        .output();
    match result {
        Ok(out) if out.status.success() => {
            state.lock().unwrap().preview_path = Some(preview.clone());
            let body = format!(r#"{{"ok":true,"previewPath":"{}"}}"#, preview.display());
            let _ = request.respond(json_response(200, &body));
        }
        Ok(out) => {
            let stderr = String::from_utf8_lossy(&out.stderr);
            let tail: Vec<&str> = stderr.lines().rev().take(10).collect();
            let tail: Vec<&str> = tail.into_iter().rev().collect();
            let err = tail.join("\\n").replace('"', "\\\"");
            let body = format!(r#"{{"ok":false,"error":"ffmpeg loudnorm failed: {}"}}"#, err);
            let _ = request.respond(json_response(500, &body));
        }
        Err(e) => {
            let body = format!(r#"{{"ok":false,"error":"ffmpeg spawn failed: {}"}}"#, e);
            let _ = request.respond(json_response(500, &body));
        }
    }
    Ok(())
}

fn handle_waveform(
    request: Request,
    state: &SharedState,
    output: &std::path::Path,
    which: &str,
) -> Result<(), String> {
    // For "raw" we want the most recent RAW take. That's the
    // `finalize_path` from state (e.g. output-take-1.webm) if a take
    // has been finalized this session, falling back to the canonical
    // `cfg.output` only when it already exists on disk from a previous
    // save-final. For "preview" we want the auto-leveled version —
    // `preview_path` if set, same fallback otherwise.
    let source = {
        let s = state.lock().unwrap();
        match which {
            "raw" => s
                .finalize_path
                .clone()
                .unwrap_or_else(|| output.to_path_buf()),
            _ => s
                .preview_path
                .clone()
                .or_else(|| s.finalize_path.clone())
                .unwrap_or_else(|| output.to_path_buf()),
        }
    };
    if !source.exists() {
        let _ = request.respond(text_response(404, "audio file not found"));
        return Ok(());
    }
    let tmp_png = std::env::temp_dir().join(format!("adom-video-post-waveform-{}.png", which));
    let result = Command::new("ffmpeg")
        .args(["-y", "-i"])
        .arg(&source)
        .args([
            "-filter_complex",
            "showwavespic=s=1024x144:colors=0x00b8b0|0x8C6BF7:split_channels=0",
            "-frames:v", "1",
        ])
        .arg(&tmp_png)
        .output();
    match result {
        Ok(out) if out.status.success() => {
            let bytes = match std::fs::read(&tmp_png) {
                Ok(b) => b,
                Err(e) => {
                    let _ = request.respond(text_response(500, &format!("waveform read failed: {}", e)));
                    return Ok(());
                }
            };
            let resp = Response::from_data(bytes).with_header(
                Header::from_bytes(&b"Content-Type"[..], &b"image/png"[..]).unwrap(),
            );
            let _ = request.respond(resp);
        }
        Ok(out) => {
            let stderr = String::from_utf8_lossy(&out.stderr);
            let _ = request.respond(text_response(500, &format!("ffmpeg showwavespic failed: {}", stderr)));
        }
        Err(e) => {
            let _ = request.respond(text_response(500, &format!("ffmpeg spawn failed: {}", e)));
        }
    }
    Ok(())
}

fn handle_timeline(
    request: Request,
    input: &std::path::Path,
    markers_file: Option<&std::path::Path>,
) -> Result<(), String> {
    let markers_path = match markers_file {
        Some(p) => p,
        None => {
            let _ = request.respond(json_response(
                200,
                r#"{"ok":true,"available":false,"reason":"no markers file passed to --markers"}"#,
            ));
            return Ok(());
        }
    };
    if !markers_path.exists() {
        let body = format!(
            r#"{{"ok":true,"available":false,"reason":"markers file not found: {}"}}"#,
            markers_path.display()
        );
        let _ = request.respond(json_response(200, &body));
        return Ok(());
    }

    let events = match crate::markers::read_all(markers_path) {
        Ok(ev) => ev,
        Err(e) => {
            let body = format!(
                r#"{{"ok":false,"error":"could not parse markers: {}"}}"#,
                e.replace('"', "\\\"")
            );
            let _ = request.respond(json_response(500, &body));
            return Ok(());
        }
    };
    let parsed = match crate::markers::parse(&events) {
        Ok(p) => p,
        Err(e) => {
            let body = format!(
                r#"{{"ok":false,"error":"could not parse markers: {}"}}"#,
                e.replace('"', "\\\"")
            );
            let _ = request.respond(json_response(500, &body));
            return Ok(());
        }
    };

    let probed_input_duration = match crate::ffmpeg::probe_duration(input) {
        Ok(d) => d,
        Err(e) => {
            let body = format!(
                r#"{{"ok":false,"error":"could not probe input duration: {}"}}"#,
                e.replace('"', "\\\"")
            );
            let _ = request.respond(json_response(500, &body));
            return Ok(());
        }
    };

    let mut saved_from_speedups = 0.0_f64;
    for seg in &parsed.segments {
        saved_from_speedups += seg.duration() - seg.sped_duration();
    }
    let raw_duration = probed_input_duration + saved_from_speedups;

    let mut sections: Vec<String> = Vec::new();
    let mut cursor = 0.0_f64;
    let mut speedup_count = 0usize;
    let mut original_speedup_dur = 0.0_f64;
    let mut compressed_speedup_dur = 0.0_f64;

    for seg in &parsed.segments {
        if seg.start_offset > cursor + 0.001 {
            sections.push(format!(
                r#"{{"kind":"normal","start":{:.3},"end":{:.3},"duration":{:.3}}}"#,
                cursor, seg.start_offset, seg.start_offset - cursor
            ));
        }
        let dur = seg.duration();
        let sped = seg.sped_duration();
        original_speedup_dur += dur;
        compressed_speedup_dur += sped;
        speedup_count += 1;
        let label = seg.label.replace('"', "\\\"");
        sections.push(format!(
            r#"{{"kind":"speedup","start":{:.3},"end":{:.3},"duration":{:.3},"speed":{},"label":"{}","compressedDuration":{:.3}}}"#,
            seg.start_offset, seg.end_offset, dur, seg.speed, label, sped
        ));
        cursor = seg.end_offset;
    }
    if cursor < raw_duration - 0.001 {
        sections.push(format!(
            r#"{{"kind":"normal","start":{:.3},"end":{:.3},"duration":{:.3}}}"#,
            cursor, raw_duration, raw_duration - cursor
        ));
    }

    let normal_dur = raw_duration - original_speedup_dur;
    let output_duration = normal_dur + compressed_speedup_dur;
    let input_duration = raw_duration;
    let saved = input_duration - output_duration;
    let reduction_pct = if input_duration > 0.0 { (saved / input_duration) * 100.0 } else { 0.0 };

    let body = format!(
        r#"{{"ok":true,"available":true,"inputDuration":{:.3},"outputDuration":{:.3},"savedSeconds":{:.3},"reductionPct":{:.2},"speedupSegmentCount":{},"segments":[{}]}}"#,
        input_duration, output_duration, saved, reduction_pct, speedup_count, sections.join(",")
    );
    let _ = request.respond(json_response(200, &body));
    Ok(())
}

fn handle_adopt_latest_audio(request: Request, state: &SharedState) -> Result<(), String> {
    let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
    let audio_dir = PathBuf::from(home).join("project").join("audio");
    let mut entries: Vec<(std::time::SystemTime, PathBuf, u64)> = Vec::new();
    if let Ok(read) = std::fs::read_dir(&audio_dir) {
        for e in read.flatten() {
            let path = e.path();
            if path.extension().and_then(|s| s.to_str()) != Some("webm") {
                continue;
            }
            if let Ok(md) = e.metadata() {
                if let Ok(mt) = md.modified() {
                    entries.push((mt, path, md.len()));
                }
            }
        }
    }
    entries.sort_by(|a, b| b.0.cmp(&a.0));
    match entries.first() {
        Some((_, path, size)) => {
            let mut s = state.lock().unwrap();
            s.audio_path = Some(path.clone());
            s.audio_size = *size;
            s.phase = "recorded".to_string();
            let body = format!(
                r#"{{"ok":true,"path":"{}","size":{}}}"#,
                path.display(),
                size
            );
            let _ = request.respond(json_response(200, &body));
        }
        None => {
            let _ = request.respond(json_response(
                404,
                &format!(r#"{{"ok":false,"error":"no audio files found in {}"}}"#, audio_dir.display()),
            ));
        }
    }
    Ok(())
}

fn handle_list_takes(
    request: Request,
    state: &SharedState,
    input: &std::path::Path,
    output: &std::path::Path,
) -> Result<(), String> {
    let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));
    let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
    let committed_name = format!("{}.webm", stem);

    let mut takes_by_n: std::collections::BTreeMap<u32, (Option<PathBuf>, Option<PathBuf>)> = std::collections::BTreeMap::new();
    let mut legacy_norms: Vec<(PathBuf, u64)> = Vec::new();
    let mut committed: Option<PathBuf> = None;

    if let Ok(read) = std::fs::read_dir(parent) {
        for e in read.flatten() {
            let path = e.path();
            let name = match path.file_name().and_then(|n| n.to_str()) {
                Some(s) => s.to_string(),
                None => continue,
            };
            if path.extension().and_then(|s| s.to_str()) != Some("webm") { continue; }
            if !name.starts_with(&stem) { continue; }

            let take_prefix = format!("{}-take-", stem);
            if let Some(rest) = name.strip_prefix(&take_prefix) {
                let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
                if let Ok(n) = digits.parse::<u32>() {
                    let after = &rest[digits.len()..];
                    let entry = takes_by_n.entry(n).or_insert((None, None));
                    if after == ".webm" {
                        entry.0 = Some(path);
                    } else if after == "-norm.webm" {
                        entry.1 = Some(path);
                    }
                    continue;
                }
            }
            if name.starts_with(&format!("{}-norm-", stem)) {
                if let Ok(md) = e.metadata() {
                    let mtime = md.modified().ok()
                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                        .map(|d| d.as_secs())
                        .unwrap_or(0);
                    legacy_norms.push((path, mtime));
                }
                continue;
            }
            if name == committed_name {
                committed = Some(path);
            }
        }
    }

    let selected = {
        let s = state.lock().unwrap();
        s.preview_path.clone().or_else(|| s.finalize_path.clone())
    };

    let mut items: Vec<String> = Vec::new();

    // 1. Original (synthetic) — points at cfg.input so the user can always
    // go back to the silent source. input is now available via self.cfg.
    let input_size = std::fs::metadata(input).map(|m| m.len()).unwrap_or(0);
    items.push(format!(
        r#"{{"label":"Original","kind":"original","path":"{}","size":{},"selected":false}}"#,
        input.display(), input_size
    ));

    for (n, (raw, norm)) in takes_by_n.iter() {
        if let Some(p) = raw {
            let is_sel = selected.as_ref().map(|s| s == p).unwrap_or(false);
            let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
            let mtime = std::fs::metadata(p).ok()
                .and_then(|m| m.modified().ok())
                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                .map(|d| d.as_secs())
                .unwrap_or(0);
            items.push(format!(
                r#"{{"label":"Take {}","kind":"raw","take":{},"path":"{}","size":{},"mtime":{},"selected":{}}}"#,
                n, n, p.display(), size, mtime, is_sel
            ));
        }
        if let Some(p) = norm {
            let is_sel = selected.as_ref().map(|s| s == p).unwrap_or(false);
            let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
            let mtime = std::fs::metadata(p).ok()
                .and_then(|m| m.modified().ok())
                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                .map(|d| d.as_secs())
                .unwrap_or(0);
            items.push(format!(
                r#"{{"label":"Take {} (auto-leveled)","kind":"normalized","take":{},"path":"{}","size":{},"mtime":{},"selected":{}}}"#,
                n, n, p.display(), size, mtime, is_sel
            ));
        }
    }

    if let Some(p) = &committed {
        let is_sel = selected.as_ref().map(|s| s == p).unwrap_or(false);
        let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
        let mtime = std::fs::metadata(p).ok()
            .and_then(|m| m.modified().ok())
            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
            .map(|d| d.as_secs())
            .unwrap_or(0);
        items.push(format!(
            r#"{{"label":"Recovered mux","kind":"committed","path":"{}","size":{},"mtime":{},"selected":{}}}"#,
            p.display(), size, mtime, is_sel
        ));
    }

    legacy_norms.sort_by(|a, b| a.1.cmp(&b.1));
    for (i, (p, mtime)) in legacy_norms.iter().enumerate() {
        let is_sel = selected.as_ref().map(|s| s == p).unwrap_or(false);
        let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
        items.push(format!(
            r#"{{"label":"Legacy #{}","kind":"legacy","path":"{}","size":{},"mtime":{},"selected":{}}}"#,
            i + 1, p.display(), size, mtime, is_sel
        ));
    }

    let body = format!(r#"{{"ok":true,"takes":[{}]}}"#, items.join(","));
    let _ = request.respond(json_response(200, &body));
    Ok(())
}

fn handle_select_take(mut request: Request, state: &SharedState) -> Result<(), String> {
    let mut body = String::new();
    request.as_reader().read_to_string(&mut body).map_err(|e| e.to_string())?;
    let parsed: serde_json::Value = match serde_json::from_str(&body) {
        Ok(v) => v,
        Err(e) => {
            let _ = request.respond(json_response(
                400,
                &format!(r#"{{"ok":false,"error":"invalid json: {}"}}"#, e),
            ));
            return Ok(());
        }
    };
    let path_str = match parsed.get("path").and_then(|v| v.as_str()) {
        Some(s) => s.to_string(),
        None => {
            let _ = request.respond(json_response(
                400,
                r#"{"ok":false,"error":"missing 'path' field"}"#,
            ));
            return Ok(());
        }
    };
    let path = PathBuf::from(&path_str);
    if !path.exists() {
        let _ = request.respond(json_response(
            404,
            &format!(r#"{{"ok":false,"error":"file not found: {}"}}"#, path_str),
        ));
        return Ok(());
    }
    state.lock().unwrap().preview_path = Some(path);
    let _ = request.respond(json_response(200, r#"{"ok":true}"#));
    Ok(())
}

fn handle_reveal(request: Request, state: &SharedState, output: &std::path::Path) -> Result<(), String> {
    let target = {
        let s = state.lock().unwrap();
        s.preview_path.clone().unwrap_or_else(|| {
            s.finalize_path.clone().unwrap_or_else(|| output.to_path_buf())
        })
    };
    if !target.exists() {
        let _ = request.respond(json_response(
            404,
            &format!(r#"{{"ok":false,"error":"no file to reveal at {}"}}"#, target.display()),
        ));
        return Ok(());
    }
    let code_bin = "/usr/lib/code-server/lib/vscode/bin/remote-cli/code-server";
    let result = Command::new(code_bin)
        .args(["--reuse-window"])
        .arg(&target)
        .output();
    match result {
        Ok(_) => {
            let body = format!(r#"{{"ok":true,"path":"{}"}}"#, target.display());
            let _ = request.respond(json_response(200, &body));
        }
        Err(e) => {
            let body = format!(r#"{{"ok":false,"error":"code-server spawn failed: {}"}}"#, e);
            let _ = request.respond(json_response(500, &body));
        }
    }
    Ok(())
}

fn handle_revert_to_raw(request: Request, state: &SharedState) -> Result<(), String> {
    state.lock().unwrap().preview_path = None;
    let _ = request.respond(json_response(200, r#"{"ok":true}"#));
    Ok(())
}

fn handle_save_final(request: Request, state: &SharedState, output: &std::path::Path) -> Result<(), String> {
    let preview = state.lock().unwrap().preview_path.clone();
    match preview {
        Some(p) if p.exists() => {
            if let Err(e) = std::fs::copy(&p, output) {
                let body = format!(r#"{{"ok":false,"error":"save failed: {}"}}"#, e);
                let _ = request.respond(json_response(500, &body));
                return Ok(());
            }
            let body = format!(r#"{{"ok":true,"path":"{}","preservedSidecar":"{}"}}"#, output.display(), p.display());
            let _ = request.respond(json_response(200, &body));
        }
        _ => {
            let body = format!(r#"{{"ok":true,"path":"{}","note":"nothing to save, output already holds the raw mux"}}"#, output.display());
            let _ = request.respond(json_response(200, &body));
        }
    }
    Ok(())
}

fn handle_replay(request: Request, state: &SharedState, output: &std::path::Path) -> Result<(), String> {
    if !output.exists() {
        let _ = request.respond(json_response(
            404,
            &format!(r#"{{"ok":false,"error":"final file not found: {}"}}"#, output.display()),
        ));
        return Ok(());
    }
    {
        let mut s = state.lock().unwrap();
        s.phase = "done".to_string();
        s.finalize_path = Some(output.to_path_buf());
    }
    let _ = request.respond(json_response(200, r#"{"ok":true}"#));
    Ok(())
}

fn handle_discard(request: Request, state: &SharedState) -> Result<(), String> {
    println!("discarding voiceover take");
    let mut s = state.lock().unwrap();
    s.recording = false;
    s.phase = "idle".to_string();
    s.audio_path = None;
    s.audio_size = 0;
    s.finalize_path = None;
    drop(s);
    let _ = request.respond(json_response(200, r#"{"ok":true}"#));
    Ok(())
}

fn handle_get_state(request: Request, state: &SharedState) -> Result<(), String> {
    let s = state.lock().unwrap();
    let audio_path = s.audio_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default();
    let final_path = s.finalize_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default();
    let body = format!(
        r#"{{"phase":"{}","recording":{},"audioPath":"{}","audioSize":{},"finalizePath":"{}"}}"#,
        s.phase, s.recording, audio_path, s.audio_size, final_path
    );
    let _ = request.respond(json_response(200, &body));
    Ok(())
}

fn text_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string(body)
        .with_status_code(status)
        .with_header(
            Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).unwrap(),
        )
}

fn json_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string(body)
        .with_status_code(status)
        .with_header(
            Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
        )
}