//! Tauri command surface. Delegates to the modules; holds no parsing logic.

use crate::db::Db;
use crate::downloader::{self, Progress};
use crate::feed;
use crate::models::{
    Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Prereqs,
};
use crate::net;
use crate::playlist_server::PlaylistServer;
use crate::takeout;
use crate::thumbs;

use futures::stream::StreamExt;
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::{Mutex, Semaphore};

/// How many channel feeds we fetch at once. YouTube serves these fine in
/// parallel; 8 keeps a 300-channel refresh brisk without hammering them.
const FEED_CONCURRENCY: usize = 8;
/// Video downloads are bandwidth-bound, so more than a couple at once just
/// makes every one of them slower.
const DOWNLOAD_CONCURRENCY: usize = 2;
/// Cap thumbnail work per refresh so a first-run import doesn't stall for ages.
const THUMB_BATCH: i64 = 600;

pub struct AppState {
    pub db: Arc<Mutex<Db>>,
    pub http: reqwest::Client,
    pub library: Arc<Mutex<PathBuf>>,
    pub app_data: PathBuf,
    pub children: Arc<Mutex<HashMap<String, tokio::process::Child>>>,
    pub download_slots: Arc<Semaphore>,
    pub playlists: PlaylistServer,
    /// yt-dlp's PyInstaller bundle costs several seconds to start, so its
    /// version is read once and kept.
    pub prereqs: Arc<Mutex<Option<Prereqs>>>,
    /// Resolved stream URLs, keyed by video and quality cap. YouTube's signed
    /// URLs last hours, so replaying a video should not pay for yt-dlp again.
    pub streams: Arc<Mutex<HashMap<(String, Option<u32>), (String, std::time::Instant)>>>,
    /// argv prefix that runs yt-dlp: either a system binary, or the bundled
    /// Python interpreter followed by the zipapp.
    pub yt_dlp: Vec<String>,
}

impl AppState {
    /// A ready-to-configure yt-dlp process.
    fn yt_dlp(&self) -> tokio::process::Command {
        let mut cmd = tokio::process::Command::new(&self.yt_dlp[0]);
        cmd.args(&self.yt_dlp[1..]);
        cmd
    }
}

/// Comfortably inside the ~6h lifetime of YouTube's signed URLs.
const STREAM_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3 * 3600);

#[derive(Clone, Serialize)]
struct RefreshProgress {
    done: usize,
    total: usize,
    channel: String,
}

#[derive(Clone, Serialize)]
struct DownloadProgressEvent {
    video_id: String,
    pct: Option<f64>,
    bytes_done: u64,
    bytes_total: Option<u64>,
    speed: Option<f64>,
    eta: Option<u64>,
}

#[derive(Clone, Serialize)]
struct DownloadStateEvent {
    video_id: String,
    state: DownloadState,
    error: Option<String>,
    path: Option<String>,
}

#[derive(Serialize)]
pub struct RefreshSummary {
    pub channels: usize,
    pub new_videos: usize,
    pub failures: Vec<String>,
}

/// Resolves a helper binary.
///
/// The app ships `yt-dlp`, `ffmpeg` and `ffprobe` as sidecars, so a fresh Mac
/// needs nothing installed. Tauri places them beside the executable inside
/// `Contents/MacOS/`, which is checked first. A copy on PATH still wins nothing
/// — but the Homebrew fallbacks remain for `cargo run` during development,
/// where there is no bundle.
fn bin(name: &str) -> String {
    if let Ok(exe) = std::env::current_exe() {
        if let Some(dir) = exe.parent() {
            let bundled = dir.join(name);
            if bundled.exists() {
                return bundled.to_string_lossy().to_string();
            }
        }
    }
    // GUI apps launched from Finder don't inherit a login shell PATH, so
    // Homebrew's bin dir is invisible to them; name the paths explicitly.
    for prefix in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] {
        let candidate = format!("{prefix}/{name}");
        if std::path::Path::new(&candidate).exists() {
            return candidate;
        }
    }
    name.to_string()
}

/// Works out how to run yt-dlp.
///
/// A system install wins when present — it is a plain Python script, starts in
/// milliseconds, and is easier to keep current than a bundled copy. Otherwise
/// the app runs its own interpreter against the yt-dlp zipapp. The 3MB zipapp
/// plus a portable Python starts in about half a second; the official
/// PyInstaller binary took eight, because it unpacks 37MB on every call.
fn resolve_yt_dlp(app: &AppHandle) -> Vec<String> {
    for prefix in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] {
        let candidate = format!("{prefix}/yt-dlp");
        if std::path::Path::new(&candidate).exists() {
            return vec![candidate];
        }
    }
    if let Ok(res) = app.path().resource_dir() {
        // python3 and python are symlinks; name the real file so the bundle
        // does not depend on symlinks surviving the copy.
        let python = res.join("python/bin/python3.12");
        let zipapp = res.join("yt-dlp.pyz");
        if python.exists() && zipapp.exists() {
            return vec![
                python.to_string_lossy().to_string(),
                zipapp.to_string_lossy().to_string(),
            ];
        }
    }
    vec!["yt-dlp".to_string()]
}

/// True when the binary we resolved is the one inside the app bundle.
fn is_bundled(name: &str) -> bool {
    std::env::current_exe()
        .ok()
        .and_then(|e| e.parent().map(|d| d.join(name).exists()))
        .unwrap_or(false)
}

/// Whether yt-dlp is the app's own copy rather than one found on the system.
fn yt_dlp_is_bundled(argv: &[String]) -> bool {
    argv.len() > 1
}

/// `flag` differs per tool: yt-dlp takes `--version`, ffmpeg only accepts
/// `-version` (it exits non-zero on `--version` and writes to stderr), so the
/// flag is passed in and both streams are consulted.
async fn version_of(argv: &[String], flag: &str) -> Option<String> {
    let mut cmd = tokio::process::Command::new(&argv[0]);
    cmd.args(&argv[1..]);
    let out = cmd.arg(flag).output().await.ok()?;
    if !out.status.success() {
        return None;
    }
    let text = if out.stdout.is_empty() {
        String::from_utf8_lossy(&out.stderr).to_string()
    } else {
        String::from_utf8_lossy(&out.stdout).to_string()
    };
    let first = text.lines().next().unwrap_or("").trim().to_string();
    (!first.is_empty()).then_some(first)
}

fn label(version: &str, name: &str) -> String {
    if is_bundled(name) {
        format!("{version} (bundled)")
    } else {
        format!("{version} (system)")
    }
}

#[tauri::command]
pub async fn check_prereqs(state: State<'_, AppState>) -> Result<Prereqs, String> {
    // The library path can change, so only the tool versions are cached.
    let library = state.library.lock().await.clone();
    if let Some(cached) = state.prereqs.lock().await.clone() {
        return Ok(Prereqs {
            library_path: library.to_string_lossy().to_string(),
            ..cached
        });
    }

    let fresh = Prereqs {
        yt_dlp: version_of(&state.yt_dlp, "--version").await.map(|v| {
            let origin = if yt_dlp_is_bundled(&state.yt_dlp) { "bundled" } else { "system" };
            format!("{v} ({origin})")
        }),
        ffmpeg: version_of(&[bin("ffmpeg")], "-version").await.map(|v| {
            // ffmpeg's first line is long; keep the useful head of it.
            let head = v.split_whitespace().take(3).collect::<Vec<_>>().join(" ");
            label(&head, "ffmpeg")
        }),
        library_path: library.to_string_lossy().to_string(),
    };
    *state.prereqs.lock().await = Some(fresh.clone());
    Ok(fresh)
}

async fn read_channels(path: &str) -> Result<Vec<Channel>, String> {
    let raw = tokio::fs::read(path)
        .await
        .map_err(|e| format!("Cannot read {path}: {e}"))?;
    // Takeout exports are UTF-8, sometimes with a BOM.
    let text = String::from_utf8_lossy(&raw);
    let text = text.strip_prefix('\u{feff}').unwrap_or(&text);

    let channels = takeout::parse_csv(text)?;
    if channels.is_empty() {
        return Err("No channels found in that file.".into());
    }
    Ok(channels)
}

/// Reports what a replacing import would add and destroy, so the UI can name
/// the consequences before the user commits to them.
#[tauri::command]
pub async fn preview_takeout_import(
    path: String,
    state: State<'_, AppState>,
) -> Result<ImportPreview, String> {
    let channels = read_channels(&path).await?;
    state.db.lock().await.preview_replace(&channels)
}

/// The imported CSV becomes the entire subscription list. Channels that are no
/// longer in it are removed along with their videos, download records, and the
/// downloaded files themselves — leaving those on disk would orphan gigabytes
/// the app can no longer show or delete.
#[tauri::command]
pub async fn import_takeout_csv(
    path: String,
    state: State<'_, AppState>,
) -> Result<usize, String> {
    let channels = read_channels(&path).await?;

    let doomed = state
        .db
        .lock()
        .await
        .paths_dropped_by_replace(&channels)?;
    for p in doomed {
        let _ = tokio::fs::remove_file(&p).await;
    }

    let mut db = state.db.lock().await;
    db.replace_channels(&channels)
}

/// Resolves a directly playable URL for a video we have NOT downloaded, so it
/// can stream inside the app's own player.
///
/// YouTube's iframe embed is not an option here: it rejects a Tauri window with
/// "Error 153" because the page origin is `tauri://localhost` rather than an
/// http(s) origin it will accept. Instead we ask yt-dlp for YouTube's HLS master
/// playlist, which lists H.264 + AAC variants up to 1080p with separate audio
/// tracks — exactly the shape AVFoundation plays natively in WKWebView, with
/// adaptive bitrate for free.
#[derive(Serialize)]
pub struct Stream {
    /// Direct URL to hand the player, when no filtering was needed.
    pub url: Option<String>,
    /// A rewritten HLS master playlist, when a quality cap was applied. The
    /// frontend turns this into a Blob URL — every URL inside is absolute, so
    /// the playlist works from anywhere.
    pub playlist: Option<String>,
}

#[tauri::command]
pub async fn resolve_stream(
    video_id: String,
    max_height: Option<u32>,
    state: State<'_, AppState>,
) -> Result<Stream, String> {
    let key = (video_id.clone(), max_height);
    {
        let mut cache = state.streams.lock().await;
        cache.retain(|_, (_, at)| at.elapsed() < STREAM_CACHE_TTL);
        if let Some((cached, _)) = cache.get(&key) {
            return Ok(Stream { url: Some(cached.clone()), playlist: None });
        }
    }

    let url = format!("https://www.youtube.com/watch?v={video_id}");

    // One call gives both the HLS master playlist and the video's own language.
    // Every m3u8 format shares the same manifest_url, so any one yields the master.
    let lines = yt_dlp_lines(
        &state,
        &[
            "-f",
            "bv*[protocol^=m3u8]",
            "--print",
            "%(manifest_url)s",
            "--print",
            "%(language)s",
            &url,
        ],
    )
    .await;

    if let Some(master) = lines.iter().find(|l| l.starts_with("http")).cloned() {
        let lang = lines.iter().find(|l| !l.starts_with("http")).cloned();

        // Always serve our own copy, even with no height cap: YouTube marks an
        // AI-dubbed track as the manifest default on some videos, and that has
        // to be corrected whether or not the quality is capped.
        match state.http.get(&master).send().await {
            Ok(resp) => match resp.text().await {
                Ok(body) => {
                    if let Some(rewritten) = rewrite_master(&body, max_height, lang.as_deref()) {
                        let served = state.playlists.publish(rewritten).await;
                        remember(&state, key, &served).await;
                        return Ok(Stream { url: Some(served), playlist: None });
                    }
                }
                Err(_) => {}
            },
            Err(_) => {}
        }
        // Anything unexpected about the manifest: fall back to YouTube's own.
        remember(&state, key, &master).await;
        return Ok(Stream { url: Some(master), playlist: None });
    }

    // Rare fallback: an old-style progressive muxed MP4.
    if let Some(u) = yt_dlp_print(&state, &[
        "-f",
        "b[ext=mp4][acodec!=none][vcodec!=none]",
        "--print",
        "%(url)s",
        &url,
    ])
    .await
    {
        return Ok(Stream { url: Some(u), playlist: None });
    }

    Err("Could not find a playable stream for this video.".into())
}

async fn remember(
    state: &State<'_, AppState>,
    key: (String, Option<u32>),
    url: &str,
) {
    state
        .streams
        .lock()
        .await
        .insert(key, (url.to_string(), std::time::Instant::now()));
}

/// Rewrites YouTube's HLS master playlist.
///
/// Two jobs. Optionally caps the video height. Always makes sure the *original*
/// audio is the default: YouTube marks an AI-dubbed track `DEFAULT=YES` on some
/// videos, and AVFoundation obeys the manifest, so you get a synthetic voice
/// over the original. Every audio group is still listed, so a player — or our
/// own track picker — can switch.
///
/// Returns `None` only when a height cap matched nothing, so the caller can
/// fall back to YouTube's own manifest rather than serve an empty playlist.
pub fn rewrite_master(
    body: &str,
    max_height: Option<u32>,
    original_lang: Option<&str>,
) -> Option<String> {
    let lines: Vec<&str> = body.lines().collect();
    let mut audio: Vec<&str> = Vec::new();
    let mut other_media: Vec<&str> = Vec::new();
    // (height, stream-inf line, url line)
    let mut variants: Vec<(u32, &str, &str)> = Vec::new();

    for (i, line) in lines.iter().enumerate() {
        if line.starts_with("#EXT-X-MEDIA:") {
            if attr(line, "TYPE").as_deref() == Some("AUDIO") {
                audio.push(line);
            } else {
                other_media.push(line);
            }
        } else if line.starts_with("#EXT-X-STREAM-INF:") {
            let Some(url) = lines.get(i + 1) else { continue };
            if url.starts_with('#') || url.trim().is_empty() {
                continue;
            }
            if let Some(h) = resolution_height(line) {
                variants.push((h, *line, *url));
            }
        }
    }

    let chosen: Vec<(u32, &str, &str)> = match max_height {
        Some(cap) => {
            let best = variants.iter().filter(|(h, _, _)| *h <= cap).max_by_key(|(h, _, _)| *h)?;
            vec![*best]
        }
        // No cap: keep every variant so the player can still adapt.
        None => variants,
    };
    if chosen.is_empty() {
        return None;
    }

    let preferred = preferred_audio(&audio, original_lang);

    let mut out = String::from("#EXTM3U\n#EXT-X-INDEPENDENT-SEGMENTS\n");
    for (i, line) in audio.iter().enumerate() {
        out.push_str(&set_default(line, Some(i) == preferred));
        out.push('\n');
    }
    for m in other_media {
        out.push_str(m);
        out.push('\n');
    }
    for (_, inf, url) in chosen {
        out.push_str(inf);
        out.push('\n');
        out.push_str(url);
        out.push('\n');
    }
    Some(out)
}

/// Index of the audio group that should be the default.
///
/// The video's own language wins. Failing that, anything not describing itself
/// as dubbed. A manifest with one audio group needs no opinion.
fn preferred_audio(audio: &[&str], original_lang: Option<&str>) -> Option<usize> {
    // With one track there is nothing to choose; it stays the default.
    if audio.len() < 2 {
        return (!audio.is_empty()).then_some(0);
    }
    if let Some(lang) = original_lang.filter(|l| !l.is_empty() && *l != "NA") {
        let base = lang.split('-').next().unwrap_or(lang).to_ascii_lowercase();
        if let Some(i) = audio.iter().position(|l| {
            attr(l, "LANGUAGE")
                .map(|v| v.to_ascii_lowercase().starts_with(&base))
                .unwrap_or(false)
        }) {
            return Some(i);
        }
    }
    audio
        .iter()
        .position(|l| {
            let name = attr(l, "NAME").unwrap_or_default().to_ascii_lowercase();
            !name.contains("dub") && !name.contains("auto")
        })
        .or(Some(0))
}

/// Rewrites the DEFAULT/AUTOSELECT flags on one EXT-X-MEDIA line.
fn set_default(line: &str, is_default: bool) -> String {
    let want = if is_default { "YES" } else { "NO" };
    let mut out = String::with_capacity(line.len() + 32);
    let mut rest = line;
    // Attributes are comma separated but URIs contain commas inside quotes, so
    // only rewrite the two flags by name and leave the rest of the line intact.
    for key in ["DEFAULT", "AUTOSELECT"] {
        let needle = format!(",{key}=");
        if let Some(at) = rest.find(&needle) {
            let value_start = at + needle.len();
            let value_end = rest[value_start..]
                .find(',')
                .map(|o| value_start + o)
                .unwrap_or(rest.len());
            out.clear();
            out.push_str(&rest[..value_start]);
            out.push_str(want);
            out.push_str(&rest[value_end..]);
            rest = Box::leak(out.clone().into_boxed_str());
        }
    }
    let mut s = rest.trim_end().to_string();
    for key in ["DEFAULT", "AUTOSELECT"] {
        if !s.contains(&format!(",{key}=")) {
            s.push_str(&format!(",{key}={want}"));
        }
    }
    s
}

/// Reads one attribute from an EXT-X tag line.
fn attr(line: &str, key: &str) -> Option<String> {
    let needle = format!("{key}=");
    let mut from = 0;
    while let Some(at) = line[from..].find(&needle) {
        let abs = from + at;
        // Must be preceded by ':' or ',' so LANGUAGE does not match INDEX-LANGUAGE.
        let ok = abs == 0 || matches!(line.as_bytes()[abs - 1], b',' | b':');
        let start = abs + needle.len();
        if ok {
            let bytes = line.as_bytes();
            if bytes.get(start) == Some(&b'"') {
                let end = line[start + 1..].find('"')? + start + 1;
                return Some(line[start + 1..end].to_string());
            }
            let end = line[start..]
                .find(',')
                .map(|o| start + o)
                .unwrap_or(line.len());
            return Some(line[start..end].trim().to_string());
        }
        from = start;
    }
    None
}

/// Pulls the vertical size out of a `RESOLUTION=1920x1080` attribute.
fn resolution_height(stream_inf: &str) -> Option<u32> {
    let at = stream_inf.find("RESOLUTION=")? + "RESOLUTION=".len();
    let rest = &stream_inf[at..];
    let value = rest.split(&[',', ' '][..]).next()?;
    value.split(&['x', 'X'][..]).nth(1)?.parse().ok()
}

/// Runs yt-dlp and returns every non-empty stdout line.
async fn yt_dlp_lines(state: &State<'_, AppState>, args: &[&str]) -> Vec<String> {
    let mut cmd = state.yt_dlp();
    cmd.args(["--no-warnings", "--no-playlist", "--simulate"]);
    cmd.args(args);
    let Ok(out) = cmd.output().await else { return Vec::new() };
    if !out.status.success() {
        return Vec::new();
    }
    String::from_utf8_lossy(&out.stdout)
        .lines()
        .map(|l| l.trim().to_string())
        .filter(|l| !l.is_empty())
        .collect()
}

/// Runs yt-dlp and returns its first non-empty stdout line, or None.
async fn yt_dlp_print(state: &State<'_, AppState>, args: &[&str]) -> Option<String> {
    let mut cmd = state.yt_dlp();
    cmd.args(["--no-warnings", "--no-playlist", "--simulate"]);
    cmd.args(args);
    let out = cmd.output().await.ok()?;
    if !out.status.success() {
        return None;
    }
    String::from_utf8_lossy(&out.stdout)
        .lines()
        .map(str::trim)
        .find(|l| l.starts_with("http"))
        .map(str::to_string)
}

/// Called periodically while a video plays, and once when the player closes.
#[tauri::command]
pub async fn save_playback(
    video_id: String,
    position: f64,
    duration: f64,
    state: State<'_, AppState>,
) -> Result<(), String> {
    state.db.lock().await.save_playback(&video_id, position, duration)
}

#[tauri::command]
pub async fn list_channels(state: State<'_, AppState>) -> Result<Vec<ChannelWithCount>, String> {
    state.db.lock().await.list_channels()
}

#[tauri::command]
pub async fn list_feed(
    filter: FeedFilter,
    state: State<'_, AppState>,
) -> Result<Vec<FeedItem>, String> {
    state.db.lock().await.list_feed(&filter)
}

#[tauri::command]
pub async fn get_connectivity(state: State<'_, AppState>) -> Result<bool, String> {
    Ok(net::is_online(&state.http).await)
}

#[tauri::command]
pub async fn refresh_feeds(
    app: AppHandle,
    state: State<'_, AppState>,
) -> Result<RefreshSummary, String> {
    let channel_ids = state.db.lock().await.channel_ids()?;
    if channel_ids.is_empty() {
        return Err("No subscriptions imported yet. Import your Takeout CSV first.".into());
    }

    let total = channel_ids.len();
    let http = state.http.clone();

    // Fetch with bounded concurrency. One channel failing must not abort the run.
    let results = futures::stream::iter(channel_ids.into_iter().map(|cid| {
        let http = http.clone();
        async move {
            let res = feed::fetch_channel(&http, &cid).await;
            (cid, res)
        }
    }))
    .buffer_unordered(FEED_CONCURRENCY)
    .collect::<Vec<_>>()
    .await;

    let mut new_videos = 0usize;
    let mut failures = Vec::new();
    let mut done = 0usize;

    for (cid, res) in results {
        done += 1;
        match res {
            Ok(videos) => {
                if !videos.is_empty() {
                    let mut db = state.db.lock().await;
                    new_videos += db.upsert_videos(&videos)?;
                }
            }
            Err(e) => failures.push(format!("{cid}: {e}")),
        }
        let _ = app.emit(
            "refresh:progress",
            RefreshProgress {
                done,
                total,
                channel: cid,
            },
        );
    }

    cache_thumbnails(&state).await;

    Ok(RefreshSummary {
        channels: total,
        new_videos,
        failures,
    })
}

/// Mirrors thumbnails to disk so the feed still renders with no network.
async fn cache_thumbnails(state: &State<'_, AppState>) {
    let pending = match state.db.lock().await.videos_missing_thumbs(THUMB_BATCH) {
        Ok(p) => p,
        Err(_) => return,
    };
    if pending.is_empty() {
        return;
    }

    let dir = thumbs::cache_dir(&state.app_data);
    let http = state.http.clone();

    let cached = futures::stream::iter(pending.into_iter().map(|(id, url)| {
        let http = http.clone();
        let dir = dir.clone();
        async move {
            let res = thumbs::cache_one(&http, &id, &url, &dir).await;
            (id, res)
        }
    }))
    .buffer_unordered(FEED_CONCURRENCY)
    .collect::<Vec<_>>()
    .await;

    let db = state.db.lock().await;
    for (id, res) in cached {
        if let Ok(path) = res {
            let _ = db.set_thumb_path(&id, &path.to_string_lossy());
        }
    }
}

#[tauri::command]
pub async fn download_video(
    video_id: String,
    quality: String,
    sub_langs: String,
    app: AppHandle,
    state: State<'_, AppState>,
) -> Result<(), String> {
    let library = state.library.lock().await.clone();
    tokio::fs::create_dir_all(&library)
        .await
        .map_err(|e| format!("Cannot create library dir: {e}"))?;

    {
        let db = state.db.lock().await;
        db.set_download_state(&video_id, DownloadState::Queued, None)?;
    }
    let _ = app.emit(
        "download:state",
        DownloadStateEvent {
            video_id: video_id.clone(),
            state: DownloadState::Queued,
            error: None,
            path: None,
        },
    );

    let permit = state
        .download_slots
        .clone()
        .acquire_owned()
        .await
        .map_err(|e| format!("Download queue closed: {e}"))?;

    let out_template = library
        .join(downloader::OUTPUT_TEMPLATE)
        .to_string_lossy()
        .to_string();
    let mut args = downloader::build_args(&video_id, &out_template, &quality, &sub_langs);
    // Without this yt-dlp looks for ffmpeg on PATH, which a bundled app has no
    // reason to have. Merging video and audio would fail on a clean machine.
    args.push("--ffmpeg-location".into());
    args.push(bin("ffmpeg"));

    let mut child = state
        .yt_dlp()
        .args(&args)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .map_err(|e| format!("Could not start yt-dlp: {e}"))?;

    let stdout = child.stdout.take().ok_or("yt-dlp produced no stdout")?;
    let stderr = child.stderr.take().ok_or("yt-dlp produced no stderr")?;

    state
        .children
        .lock()
        .await
        .insert(video_id.clone(), child);

    {
        let db = state.db.lock().await;
        db.set_download_state(&video_id, DownloadState::Running, None)?;
    }
    let _ = app.emit(
        "download:state",
        DownloadStateEvent {
            video_id: video_id.clone(),
            state: DownloadState::Running,
            error: None,
            path: None,
        },
    );

    // Collect stderr concurrently so a failure has a real message, and so a
    // full stderr pipe can't deadlock the child.
    let stderr_task = tokio::spawn(async move {
        let mut lines = BufReader::new(stderr).lines();
        let mut buf = Vec::new();
        while let Ok(Some(l)) = lines.next_line().await {
            buf.push(l);
        }
        buf
    });

    let mut final_path: Option<String> = None;
    let mut reader = BufReader::new(stdout).lines();
    while let Ok(Some(line)) = reader.next_line().await {
        if let Some(p) = downloader::parse_final_path(&line) {
            final_path = Some(p);
            continue;
        }
        if let Some(Progress {
            downloaded,
            total,
            speed,
            eta,
        }) = downloader::parse_progress_line(&line)
        {
            let pct = Progress {
                downloaded,
                total,
                speed,
                eta,
            }
            .pct();
            {
                let db = state.db.lock().await;
                let _ = db.set_download_progress(&video_id, downloaded, total, pct);
            }
            let _ = app.emit(
                "download:progress",
                DownloadProgressEvent {
                    video_id: video_id.clone(),
                    pct,
                    bytes_done: downloaded,
                    bytes_total: total,
                    speed,
                    eta,
                },
            );
        }
    }

    let mut child = state
        .children
        .lock()
        .await
        .remove(&video_id)
        .ok_or_else(|| "Download was cancelled.".to_string())?;

    let status = child
        .wait()
        .await
        .map_err(|e| format!("yt-dlp did not exit cleanly: {e}"))?;
    let stderr_lines = stderr_task.await.unwrap_or_default();
    drop(permit);

    if status.success() {
        // yt-dlp normally reports the path via `--print after_move:`; if that
        // line went missing, find the file it wrote by its embedded video id.
        let path = match final_path {
            Some(p) => p,
            None => find_by_video_id(&library, &video_id)
                .await
                .ok_or("Download finished but the file could not be located.")?,
        };
        let db = state.db.lock().await;
        db.set_download_state(&video_id, DownloadState::Done, None)?;
        db.set_download_path(&video_id, &path)?;
        drop(db);
        let _ = app.emit(
            "download:state",
            DownloadStateEvent {
                video_id,
                state: DownloadState::Done,
                error: None,
                path: Some(path),
            },
        );
        Ok(())
    } else {
        let msg = stderr_lines
            .iter()
            .rev()
            .find(|l| l.contains("ERROR"))
            .cloned()
            .unwrap_or_else(|| format!("yt-dlp exited with {status}"));
        let db = state.db.lock().await;
        db.set_download_state(&video_id, DownloadState::Failed, Some(&msg))?;
        drop(db);
        let _ = app.emit(
            "download:state",
            DownloadStateEvent {
                video_id,
                state: DownloadState::Failed,
                error: Some(msg.clone()),
                path: None,
            },
        );
        Err(msg)
    }
}

#[tauri::command]
pub async fn cancel_download(
    video_id: String,
    app: AppHandle,
    state: State<'_, AppState>,
) -> Result<(), String> {
    if let Some(mut child) = state.children.lock().await.remove(&video_id) {
        let _ = child.kill().await;
    }
    {
        let db = state.db.lock().await;
        db.set_download_state(&video_id, DownloadState::Cancelled, None)?;
    }
    cleanup_partials(state.library.lock().await.clone(), &video_id).await;
    let _ = app.emit(
        "download:state",
        DownloadStateEvent {
            video_id,
            state: DownloadState::Cancelled,
            error: None,
            path: None,
        },
    );
    Ok(())
}

/// Locates a finished download by the `[<id>]` tag in its filename.
async fn find_by_video_id(library: &std::path::Path, video_id: &str) -> Option<String> {
    let mut entries = tokio::fs::read_dir(library).await.ok()?;
    while let Ok(Some(entry)) = entries.next_entry().await {
        let name = entry.file_name().to_string_lossy().to_string();
        if name.contains(video_id) && !name.contains(".part") && !name.ends_with(".ytdl") {
            return Some(entry.path().to_string_lossy().to_string());
        }
    }
    None
}

/// yt-dlp leaves `.part`, `.ytdl` and format-specific fragments behind when
/// killed; without this the library slowly fills with dead bytes.
async fn cleanup_partials(library: PathBuf, video_id: &str) {
    let Ok(mut entries) = tokio::fs::read_dir(&library).await else {
        return;
    };
    while let Ok(Some(entry)) = entries.next_entry().await {
        let name = entry.file_name().to_string_lossy().to_string();
        if name.contains(video_id) && (name.contains(".part") || name.ends_with(".ytdl")) {
            let _ = tokio::fs::remove_file(entry.path()).await;
        }
    }
}

/// WebVTT files yt-dlp wrote beside a download, as (language, path) pairs.
#[tauri::command]
pub async fn list_subtitles(
    video_id: String,
    state: State<'_, AppState>,
) -> Result<Vec<(String, String)>, String> {
    let library = state.library.lock().await.clone();
    let Ok(mut entries) = tokio::fs::read_dir(&library).await else {
        return Ok(Vec::new());
    };
    let mut out = Vec::new();
    while let Ok(Some(entry)) = entries.next_entry().await {
        let name = entry.file_name().to_string_lossy().to_string();
        if !name.contains(&video_id) || !name.ends_with(".vtt") {
            continue;
        }
        // yt-dlp names them "<base>.<lang>.vtt".
        let lang = name
            .trim_end_matches(".vtt")
            .rsplit('.')
            .next()
            .unwrap_or("")
            .to_string();
        out.push((lang, entry.path().to_string_lossy().to_string()));
    }
    out.sort();
    Ok(out)
}

/// Removes every download and the files behind them, including subtitles.
#[tauri::command]
pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result<usize, String> {
    let paths = state.db.lock().await.all_download_paths()?;
    for p in &paths {
        let _ = tokio::fs::remove_file(p).await;
    }
    // Subtitle sidecars are not tracked in the database, so sweep them here.
    let library = state.library.lock().await.clone();
    if let Ok(mut entries) = tokio::fs::read_dir(&library).await {
        while let Ok(Some(entry)) = entries.next_entry().await {
            let name = entry.file_name().to_string_lossy().to_string();
            if name.ends_with(".vtt") || name.contains(".part") || name.ends_with(".ytdl") {
                let _ = tokio::fs::remove_file(entry.path()).await;
            }
        }
    }
    state.db.lock().await.clear_all_downloads()
}

#[tauri::command]
pub async fn delete_download(
    video_id: String,
    state: State<'_, AppState>,
) -> Result<(), String> {
    let path = state.db.lock().await.get_download_path(&video_id)?;
    if let Some(p) = path {
        let _ = tokio::fs::remove_file(&p).await;
    }
    let library = state.library.lock().await.clone();
    cleanup_partials(library.clone(), &video_id).await;
    // The .vtt sidecars belong to the video, so they go with it.
    if let Ok(mut entries) = tokio::fs::read_dir(&library).await {
        while let Ok(Some(entry)) = entries.next_entry().await {
            let name = entry.file_name().to_string_lossy().to_string();
            if name.contains(&video_id) && name.ends_with(".vtt") {
                let _ = tokio::fs::remove_file(entry.path()).await;
            }
        }
    }
    state.db.lock().await.clear_download(&video_id)
}

#[tauri::command]
pub async fn set_library_path(
    path: String,
    state: State<'_, AppState>,
) -> Result<String, String> {
    let p = PathBuf::from(&path);
    tokio::fs::create_dir_all(&p)
        .await
        .map_err(|e| format!("Cannot use that folder: {e}"))?;
    *state.library.lock().await = p.clone();
    Ok(p.to_string_lossy().to_string())
}

#[tauri::command]
pub async fn open_external(app: AppHandle, url: String) -> Result<(), String> {
    use tauri_plugin_opener::OpenerExt;
    app.opener()
        .open_url(url, None::<&str>)
        .map_err(|e| format!("Could not open link: {e}"))
}

pub fn default_library() -> PathBuf {
    dirs_home()
        .map(|h| h.join("Movies").join("FlightTube"))
        .unwrap_or_else(|| PathBuf::from("FlightTube"))
}

fn dirs_home() -> Option<PathBuf> {
    std::env::var_os("HOME").map(PathBuf::from)
}

pub fn build_state(app: &AppHandle) -> Result<AppState, String> {
    let app_data = app
        .path()
        .app_data_dir()
        .map_err(|e| format!("No app data dir: {e}"))?;
    std::fs::create_dir_all(&app_data).map_err(|e| format!("Cannot create app data dir: {e}"))?;

    let db = Db::open(&app_data.join("flighttube.db"))?;
    let library = default_library();
    let _ = std::fs::create_dir_all(&library);

    let http = reqwest::Client::builder()
        .user_agent("FlightTube/0.1 (+desktop)")
        .timeout(std::time::Duration::from_secs(20))
        .build()
        .map_err(|e| format!("Cannot build HTTP client: {e}"))?;

    Ok(AppState {
        db: Arc::new(Mutex::new(db)),
        http,
        library: Arc::new(Mutex::new(library)),
        app_data,
        children: Arc::new(Mutex::new(HashMap::new())),
        download_slots: Arc::new(Semaphore::new(DOWNLOAD_CONCURRENCY)),
        playlists: PlaylistServer::start()?,
        prereqs: Arc::new(Mutex::new(None)),
        streams: Arc::new(Mutex::new(HashMap::new())),
        yt_dlp: resolve_yt_dlp(app),
    })
}

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

    /// Two audio languages, with the dub marked default — the case that puts a
    /// synthetic voice over the original.
    const DUBBED: &str = concat!(
        "#EXTM3U\n#EXT-X-INDEPENDENT-SEGMENTS\n",
        "#EXT-X-MEDIA:URI=\"https://a/en.m3u8\",TYPE=AUDIO,GROUP-ID=\"en\",LANGUAGE=\"en\",NAME=\"English original\",DEFAULT=NO,AUTOSELECT=NO\n",
        "#EXT-X-MEDIA:URI=\"https://a/nl.m3u8\",TYPE=AUDIO,GROUP-ID=\"nl\",LANGUAGE=\"nl\",NAME=\"Nederlands (dubbed)\",DEFAULT=YES,AUTOSELECT=YES\n",
        "#EXT-X-MEDIA:URI=\"https://a/sub.m3u8\",TYPE=SUBTITLES,GROUP-ID=\"vtt\",LANGUAGE=\"en\",NAME=\"English\",DEFAULT=NO,AUTOSELECT=YES\n",
        "#EXT-X-STREAM-INF:BANDWIDTH=756324,RESOLUTION=640x360,AUDIO=\"en\"\n",
        "https://a/360.m3u8\n",
        "#EXT-X-STREAM-INF:BANDWIDTH=3878958,RESOLUTION=1280x720,AUDIO=\"en\"\n",
        "https://a/720.m3u8\n",
        "#EXT-X-STREAM-INF:BANDWIDTH=6039686,RESOLUTION=1920x1080,AUDIO=\"en\"\n",
        "https://a/1080.m3u8\n",
    );

    fn audio_line(out: &str, lang: &str) -> String {
        out.lines()
            .find(|l| l.contains("TYPE=AUDIO") && l.contains(&format!("LANGUAGE=\"{lang}\"")))
            .unwrap_or("")
            .to_string()
    }

    #[test]
    fn the_original_language_becomes_the_default_track() {
        let out = rewrite_master(DUBBED, None, Some("en")).unwrap();
        assert!(audio_line(&out, "en").contains("DEFAULT=YES"));
        assert!(audio_line(&out, "en").contains("AUTOSELECT=YES"));
        assert!(audio_line(&out, "nl").contains("DEFAULT=NO"));
        assert!(audio_line(&out, "nl").contains("AUTOSELECT=NO"));
    }

    #[test]
    fn every_audio_track_is_still_offered() {
        // Demoting a dub must not remove it; the player still has to be able
        // to switch to it.
        let out = rewrite_master(DUBBED, None, Some("en")).unwrap();
        assert!(out.contains("https://a/en.m3u8"));
        assert!(out.contains("https://a/nl.m3u8"));
    }

    #[test]
    fn subtitles_survive_untouched() {
        let out = rewrite_master(DUBBED, None, Some("en")).unwrap();
        assert!(out.contains("TYPE=SUBTITLES"));
        assert!(out.contains("https://a/sub.m3u8"));
    }

    #[test]
    fn without_a_language_it_avoids_anything_calling_itself_dubbed() {
        let out = rewrite_master(DUBBED, None, None).unwrap();
        assert!(audio_line(&out, "en").contains("DEFAULT=YES"));
        assert!(audio_line(&out, "nl").contains("DEFAULT=NO"));
    }

    #[test]
    fn an_unknown_language_still_picks_a_sane_default() {
        // A language we cannot match must not leave every track demoted.
        let out = rewrite_master(DUBBED, None, Some("zz")).unwrap();
        let defaults = out
            .lines()
            .filter(|l| l.contains("TYPE=AUDIO") && l.contains("DEFAULT=YES"))
            .count();
        assert_eq!(defaults, 1);
    }

    #[test]
    fn no_cap_keeps_every_variant_so_the_player_can_adapt() {
        let out = rewrite_master(DUBBED, None, Some("en")).unwrap();
        for u in ["https://a/360.m3u8", "https://a/720.m3u8", "https://a/1080.m3u8"] {
            assert!(out.contains(u), "missing {u}");
        }
    }

    #[test]
    fn a_cap_keeps_the_best_variant_at_or_below_it() {
        let out = rewrite_master(DUBBED, Some(720), Some("en")).unwrap();
        assert!(out.contains("https://a/720.m3u8"));
        assert!(!out.contains("https://a/1080.m3u8"));
        assert!(!out.contains("https://a/360.m3u8"));
        // The audio fix still applies when capping.
        assert!(audio_line(&out, "en").contains("DEFAULT=YES"));
    }

    #[test]
    fn nothing_below_the_cap_yields_none_so_the_caller_can_fall_back() {
        assert!(rewrite_master(DUBBED, Some(144), Some("en")).is_none());
        assert!(rewrite_master("#EXTM3U\n", Some(1080), None).is_none());
        assert!(rewrite_master("", None, None).is_none());
    }

    #[test]
    fn a_single_audio_track_is_left_as_the_default() {
        let single = concat!(
            "#EXTM3U\n",
            "#EXT-X-MEDIA:URI=\"https://a/a.m3u8\",TYPE=AUDIO,GROUP-ID=\"1\",NAME=\"Default\",DEFAULT=YES,AUTOSELECT=YES\n",
            "#EXT-X-STREAM-INF:BANDWIDTH=1,RESOLUTION=640x360,AUDIO=\"1\"\n",
            "https://a/360.m3u8\n",
        );
        let out = rewrite_master(single, None, None).unwrap();
        assert!(out.contains("TYPE=AUDIO"));
        assert!(out.contains("DEFAULT=YES"));
    }
}
