//! 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,
    Video,
};
use crate::net;
use crate::playlist_server::PlaylistServer;
use crate::resolve;
use crate::subscriptions;
use crate::takeout;
use crate::thumbs;

use futures::stream::StreamExt;
use serde::Serialize;
use std::collections::HashMap;
use std::path::{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. Mutable so an in-app update
    /// takes effect without a restart.
    pub yt_dlp_argv: Arc<Mutex<Vec<String>>>,
    /// Quality and subtitle language the window is set to, so the menu bar
    /// can start the same work without the front end being open.
    pub download_defaults: Arc<Mutex<(String, String)>>,
    /// Value for yt-dlp's --cookies-from-browser, when signed in.
    pub cookies_from: Arc<Mutex<Option<String>>>,
}

impl AppState {
    /// A ready-to-configure yt-dlp process, carrying cookies when configured.
    async fn yt_dlp(&self) -> tokio::process::Command {
        let argv = self.yt_dlp_argv.lock().await.clone();
        let mut cmd = tokio::process::Command::new(&argv[0]);
        cmd.args(&argv[1..]);
        if let Some(from) = self.cookies_from.lock().await.clone() {
            cmd.arg("--cookies-from-browser").arg(from);
        }
        cmd
    }
}

/// Browsers yt-dlp can read cookies from, as (id, label, --cookies-from-browser
/// value). Arc is Chromium underneath but is not one of yt-dlp's known names,
/// so it is addressed by its profile directory.
fn browser_options() -> Vec<(String, String, String)> {
    let home = std::env::var("HOME").unwrap_or_default();
    vec![
        ("safari", "Safari", "/Applications/Safari.app", "safari".to_string()),
        ("arc", "Arc", "/Applications/Arc.app",
            format!("chrome:{home}/Library/Application Support/Arc/User Data")),
        ("chrome", "Google Chrome", "/Applications/Google Chrome.app", "chrome".to_string()),
        ("firefox", "Firefox", "/Applications/Firefox.app", "firefox".to_string()),
        ("brave", "Brave", "/Applications/Brave Browser.app", "brave".to_string()),
        ("edge", "Microsoft Edge", "/Applications/Microsoft Edge.app", "edge".to_string()),
        ("vivaldi", "Vivaldi", "/Applications/Vivaldi.app", "vivaldi".to_string()),
    ]
    .into_iter()
    .filter(|(_, _, app, _)| std::path::Path::new(app).exists())
    .map(|(id, label, _, value)| (id.to_string(), label.to_string(), value))
    .collect()
}

/// The browsers actually installed, for the Settings picker.
#[tauri::command]
pub async fn list_browsers() -> Result<Vec<(String, String)>, String> {
    Ok(browser_options()
        .into_iter()
        .map(|(id, label, _)| (id, label))
        .collect())
}

/// Chooses which browser's cookies yt-dlp should use. An empty id signs out.
#[tauri::command]
pub async fn set_cookie_source(
    browser: String,
    state: State<'_, AppState>,
) -> Result<(), String> {
    let value = browser_options()
        .into_iter()
        .find(|(id, _, _)| *id == browser)
        .map(|(_, _, value)| value);
    *state.cookies_from.lock().await = value;
    Ok(())
}

/// Turns yt-dlp's stderr into something worth showing.
///
/// The bot challenge is the one users hit most, and its stock message points at
/// command-line flags they have no way to type, so it is replaced with the
/// setting that actually fixes it.
fn explain_yt_dlp_error(stderr: &str, signed_in: bool) -> String {
    if stderr.contains("Sign in to confirm") || stderr.contains("not a bot") {
        return if signed_in {
            "YouTube is still refusing this machine even with browser cookies.              The sign-in may have expired — reopen YouTube in that browser, or wait              a while before trying again."
                .into()
        } else {
            "YouTube is asking this machine to prove it is not a bot. Open Settings              and pick a browser under Sign in to YouTube; the app will use that              browser's session."
                .into()
        };
    }
    if stderr.contains("429") || stderr.contains("Too Many Requests") {
        return "YouTube is rate-limiting this machine. Wait a few minutes, or sign in \
                under Settings → Sign in to YouTube, which raises the limit."
            .into();
    }
    if stderr.contains("Operation not permitted") && stderr.contains("Safari") {
        return "macOS blocked access to Safari's cookies. Give FlightTube Full Disk                 Access in System Settings → Privacy & Security, or pick a different                 browser."
            .into();
    }
    if stderr.contains("could not find") && stderr.contains("cookies database") {
        return "That browser has no cookie store on this Mac. Pick another under                 Settings → Sign in to YouTube."
            .into();
    }
    stderr
        .lines()
        .rev()
        .find(|l| l.contains("ERROR"))
        .unwrap_or("yt-dlp failed")
        .to_string()
}

/// Tries a real extraction so Settings can report whether YouTube is reachable.
#[tauri::command]
pub async fn test_youtube(state: State<'_, AppState>) -> Result<String, String> {
    let signed_in = state.cookies_from.lock().await.is_some();
    // Test against the newest video in the feed. A hardcoded id is no good —
    // the one this used at first had been taken down, so the check reported a
    // dead video rather than the connection.
    let target = state
        .db
        .lock()
        .await
        .list_feed(&FeedFilter { limit: Some(1), ..Default::default() })?
        .first()
        .map(|v| v.id.clone())
        .ok_or("Import your subscriptions first — there is nothing to test with.")?;

    let mut cmd = state.yt_dlp().await;
    cmd.args([
        "--no-playlist",
        "--simulate",
        "--print",
        "%(id)s",
        &format!("https://www.youtube.com/watch?v={target}"),
    ]);
    let out = cmd
        .output()
        .await
        .map_err(|e| format!("Could not run yt-dlp: {e}"))?;
    if out.status.success() {
        return Ok(if signed_in {
            "YouTube is reachable, using your browser sign-in.".into()
        } else {
            "YouTube is reachable.".into()
        });
    }
    Err(explain_yt_dlp_error(
        &String::from_utf8_lossy(&out.stderr),
        signed_in,
    ))
}

/// 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.
/// Unix seconds. The database has its own copy; this is for rows built here.
fn now_secs() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

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, app_data: &std::path::Path) -> 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");
        // An in-app update lands in app data; the bundle is read-only.
        let updated = updated_yt_dlp(app_data);
        let zipapp = if updated.exists() { updated } else { 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: {
            let argv = state.yt_dlp_argv.lock().await.clone();
            version_of(&argv, "--version").await.map(|v| {
                let origin = if !yt_dlp_is_bundled(&argv) {
                    "system"
                } else if argv.last().map(|p| p.contains("/bin/")).unwrap_or(false) {
                    "updated"
                } else {
                    "bundled"
                };
                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 });
    }

    // Distinguish "YouTube is refusing us" from "this video has no stream".
    let signed_in = state.cookies_from.lock().await.is_some();
    let mut probe = state.yt_dlp().await;
    probe.args(["--no-playlist", "--simulate", "--print", "%(id)s", &url]);
    if let Ok(out) = probe.output().await {
        if !out.status.success() {
            return Err(explain_yt_dlp_error(
                &String::from_utf8_lossy(&out.stderr),
                signed_in,
            ));
        }
    }
    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().await;
    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().await;
    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)
}

/// How much of a watch page to read. `lengthSeconds` sits in the player
/// response near the top, so there is no need to pull a megabyte of HTML.
const DURATION_PROBE_BYTES: &str = "bytes=0-262143";
/// Deliberately small. An earlier version fetched 24 pages every 12 seconds at
/// six concurrent — about two requests a second, sustained — and YouTube
/// answered with "Sign in to confirm you're not a bot" for the whole IP,
/// breaking playback and downloads as well. Filling every length matters far
/// less than staying under the radar, so this trickles.
const DURATION_BATCH: usize = 4;
const DURATION_CONCURRENCY: usize = 1;

/// Fills in video lengths, which the Atom feed does not carry.
///
/// yt-dlp would cost seconds per video; a ranged GET of the watch page costs
/// about one, and only ever runs for videos whose length is still unknown.
///
/// `visible` is what the user is actually looking at. Filling globally by date
/// instead left most of a channel's videos blank forever, because the newest
/// few across all subscriptions always won the queue.
#[tauri::command]
pub async fn fetch_durations(
    visible: Vec<String>,
    state: State<'_, AppState>,
) -> Result<usize, String> {
    let ids = {
        let db = state.db.lock().await;
        if visible.is_empty() {
            db.videos_missing_duration(DURATION_BATCH as i64)?
        } else {
            db.filter_missing_duration(&visible, DURATION_BATCH as i64)?
        }
    };
    if ids.is_empty() {
        return Ok(0);
    }

    let http = state.http.clone();
    let found = futures::stream::iter(ids.into_iter().map(|id| {
        let http = http.clone();
        async move {
            let secs = probe_duration(&http, &id).await;
            (id, secs)
        }
    }))
    .buffer_unordered(DURATION_CONCURRENCY)
    .collect::<Vec<_>>()
    .await;

    let attempted = found.len();
    let db = state.db.lock().await;
    let mut n = 0;
    for (id, secs) in found {
        if let Some(secs) = secs {
            db.set_duration(&id, secs)?;
            n += 1;
        }
    }
    drop(db);

    // Every probe failing means YouTube is refusing us, not that these videos
    // have no length. Stop asking rather than hammering a closed door.
    if n == 0 && attempted > 0 {
        return Err("Duration lookup is being refused; backing off.".into());
    }
    Ok(n)
}

async fn probe_duration(http: &reqwest::Client, video_id: &str) -> Option<i64> {
    let body = http
        .get(format!("https://www.youtube.com/watch?v={video_id}"))
        .header(reqwest::header::RANGE, DURATION_PROBE_BYTES)
        .send()
        .await
        .ok()?
        .text()
        .await
        .ok()?;
    parse_length_seconds(&body)
}

/// Pulls `"lengthSeconds":"1315"` out of the watch page.
pub fn parse_length_seconds(body: &str) -> Option<i64> {
    let needle = "\"lengthSeconds\":\"";
    let at = body.find(needle)? + needle.len();
    let rest = &body[at..];
    let end = rest.find('"')?;
    rest[..end].parse().ok().filter(|n| *n > 0)
}

/// Where an updated yt-dlp is kept. The bundle is read-only, so a newer copy
/// lives in app data and takes precedence over the shipped one.
fn updated_yt_dlp(app_data: &std::path::Path) -> PathBuf {
    app_data.join("bin").join("yt-dlp.pyz")
}

#[derive(Serialize)]
pub struct UpdateStatus {
    pub current: Option<String>,
    pub latest: Option<String>,
    pub up_to_date: bool,
}

/// Asks GitHub what the newest yt-dlp release is.
///
/// Only yt-dlp is checked. It breaks whenever YouTube changes something, so
/// staying current matters; ffmpeg is stable and ships with the app.
#[tauri::command]
pub async fn check_yt_dlp_update(state: State<'_, AppState>) -> Result<UpdateStatus, String> {
    let current = version_of(&state.yt_dlp_argv.lock().await.clone(), "--version").await;

    let latest = state
        .http
        .get("https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest")
        .header(reqwest::header::ACCEPT, "application/vnd.github+json")
        .send()
        .await
        .map_err(|e| format!("Could not reach GitHub: {e}"))?
        .text()
        .await
        .map_err(|e| format!("Unexpected reply from GitHub: {e}"))?;
    let latest = serde_json::from_str::<serde_json::Value>(&latest)
        .ok()
        .and_then(|v| v.get("tag_name").and_then(|t| t.as_str()).map(str::to_string));

    let up_to_date = match (&current, &latest) {
        (Some(c), Some(l)) => c.trim() == l.trim(),
        _ => false,
    };
    Ok(UpdateStatus { current, latest, up_to_date })
}

/// Downloads the newest yt-dlp zipapp into app data and switches to it.
#[tauri::command]
pub async fn update_yt_dlp(state: State<'_, AppState>) -> Result<String, String> {
    let dest = updated_yt_dlp(&state.app_data);
    if let Some(dir) = dest.parent() {
        tokio::fs::create_dir_all(dir)
            .await
            .map_err(|e| format!("Cannot create {}: {e}", dir.display()))?;
    }

    let bytes = state
        .http
        .get("https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp")
        .send()
        .await
        .map_err(|e| format!("Download failed: {e}"))?
        .bytes()
        .await
        .map_err(|e| format!("Download failed: {e}"))?;

    if bytes.len() < 1_000_000 {
        return Err("That download does not look like yt-dlp; leaving the current one in place.".into());
    }

    // Write beside the target then rename, so a failure never leaves a
    // half-written interpreter in place of a working one.
    let tmp = dest.with_extension("pyz.part");
    tokio::fs::write(&tmp, &bytes)
        .await
        .map_err(|e| format!("Cannot write update: {e}"))?;
    tokio::fs::rename(&tmp, &dest)
        .await
        .map_err(|e| format!("Cannot install update: {e}"))?;

    // Point at the new copy without a restart.
    let mut argv = state.yt_dlp_argv.lock().await;
    if argv.len() > 1 {
        let last = argv.len() - 1;
        argv[last] = dest.to_string_lossy().to_string();
    }
    let probe = argv.clone();
    drop(argv);

    *state.prereqs.lock().await = None;
    version_of(&probe, "--version")
        .await
        .ok_or_else(|| "The update was installed but will not run.".to_string())
}

#[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) => {
                let mut db = state.db.lock().await;
                if !videos.is_empty() {
                    new_videos += db.upsert_videos(&videos)?;
                }
                // A channel that recovers should stop being flagged.
                db.set_channel_result(&cid, None)?;
            }
            Err(e) => {
                state.db.lock().await.set_channel_result(&cid, Some(&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,
    })
}

/// Fetches one channel's feed and stores it. Used when a channel is added by
/// hand, so it is populated before the user sees it.
async fn refresh_one(
    app: &AppHandle,
    state: &State<'_, AppState>,
    channel_id: &str,
) -> Result<usize, String> {
    let res = feed::fetch_channel(&state.http, channel_id).await;
    let mut db = state.db.lock().await;
    match res {
        Ok(videos) => {
            let n = if videos.is_empty() { 0 } else { db.upsert_videos(&videos)? };
            db.set_channel_result(channel_id, None)?;
            drop(db);
            cache_thumbnails(state).await;
            let _ = app.emit("feed:changed", ());
            Ok(n)
        }
        Err(e) => {
            db.set_channel_result(channel_id, Some(&e))?;
            Err(e)
        }
    }
}

/// 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_lang: 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)?;
        db.set_download_request(&video_id, &quality, &sub_lang)?;
    }
    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}"))?;

    // A whole channel can be enqueued at once, so the wait for a slot can be
    // long. Cancelling during that wait has to actually stop the download
    // rather than have it start later anyway.
    {
        let db = state.db.lock().await;
        if db.download_state(&video_id)?.as_deref() != Some(DownloadState::Queued.as_str()) {
            return Ok(());
        }
    }

    let out_template = library
        .join(downloader::OUTPUT_TEMPLATE)
        .to_string_lossy()
        .to_string();
    let sub_langs = downloader::embed_sub_langs_for(&sub_lang);
    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()
        .await
        .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,
                },
            );
        }
    }

    // Gone from the map means it was cancelled: the state is already recorded
    // and the event already sent. Stopping something is a normal outcome, not
    // a failure to report — a rejection here would raise a dialog per download
    // the moment you press Stop all.
    let Some(mut child) = state.children.lock().await.remove(&video_id) else {
        return Ok(());
    };

    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);

    // yt-dlp exits non-zero if *anything* failed, including a subtitle it could
    // not fetch. If the video itself landed, the download succeeded — throwing
    // away a finished file over a missing caption would be absurd.
    let landed = match final_path {
        Some(p) if tokio::fs::metadata(&p).await.is_ok() => Some(p),
        _ => find_by_video_id(&library, &video_id).await,
    };

    if let Some(path) = landed {
        // YouTube's captions arrive pinned to the left edge and full of
        // karaoke timing tags; clean them before they reach the player.
        tidy_subtitles(&library, &video_id).await;

        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 signed_in = state.cookies_from.lock().await.is_some();
        let joined = stderr_lines.join("\n");
        let msg = if joined.trim().is_empty() {
            format!("yt-dlp exited with {status}")
        } else {
            explain_yt_dlp_error(&joined, signed_in)
        };
        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(())
}

/// Rewrites downloaded WebVTT so it renders as ordinary subtitles.
///
/// YouTube's auto-captions carry `align:start position:0%` on every cue, which
/// pins them to the left edge where long lines are clipped, plus inline
/// `<00:00:12.480><c>word</c>` timing tags that render as half-grey karaoke
/// text. Both are stripped; the timings themselves are untouched.
async fn tidy_subtitles(library: &std::path::Path, 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.ends_with(".vtt") {
            continue;
        }
        let path = entry.path();
        if let Ok(text) = tokio::fs::read_to_string(&path).await {
            let _ = tokio::fs::write(&path, tidy_vtt(&text)).await;
        }
    }
}

pub fn tidy_vtt(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    for line in input.lines() {
        if line.contains("-->") {
            // Keep the timing, drop every cue setting after it.
            let end = line.find("-->").map(|i| i + 3).unwrap_or(0);
            let rest = &line[end..];
            let stamp = rest.split_whitespace().next().unwrap_or("");
            out.push_str(&line[..end]);
            out.push(' ');
            out.push_str(stamp);
        } else {
            out.push_str(&strip_cue_tags(line));
        }
        out.push('\n');
    }
    out
}

/// Removes `<...>` spans — both timestamps and `<c>` wrappers.
fn strip_cue_tags(line: &str) -> String {
    let mut out = String::with_capacity(line.len());
    let mut depth = 0usize;
    for ch in line.chars() {
        match ch {
            '<' => depth += 1,
            '>' => depth = depth.saturating_sub(1),
            _ if depth == 0 => out.push(ch),
            _ => {}
        }
    }
    out
}

/// 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;
        }
        // Downloads made before the cleanup existed still carry YouTube's
        // edge-pinned cues, so tidy them the first time they are listed.
        let path = entry.path();
        if let Ok(text) = tokio::fs::read_to_string(&path).await {
            if text.contains("align:") || text.contains("position:") || text.contains("<c>") {
                let _ = tokio::fs::write(&path, tidy_vtt(&text)).await;
            }
        }

        // yt-dlp names them "<base>.<lang>.vtt".
        let lang = name
            .trim_end_matches(".vtt")
            .rsplit('.')
            .next()
            .unwrap_or("")
            .to_string();
        let Ok(text) = tokio::fs::read_to_string(&path).await else {
            continue;
        };
        out.push((lang, tidy_vtt(&text)));
    }
    out.sort();
    Ok(out)
}

/// Reads the WebVTT files in a directory as (language, text), newest naming
/// convention `<id>.<lang>.vtt`. Identical texts are collapsed: YouTube serves
/// the same auto-generated captions under both `en` and `en-orig`, and offering
/// the same track twice is just noise in the menu.
async fn read_vtt_dir(dir: &Path) -> Vec<(String, String)> {
    let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
        return Vec::new();
    };
    let mut out: Vec<(String, String)> = Vec::new();
    while let Ok(Some(entry)) = entries.next_entry().await {
        let name = entry.file_name().to_string_lossy().to_string();
        let Some(stem) = name.strip_suffix(".vtt") else { continue };
        let Some((_, lang)) = stem.rsplit_once('.') else { continue };
        let Ok(text) = tokio::fs::read_to_string(entry.path()).await else {
            continue;
        };
        out.push((lang.to_string(), tidy_vtt(&text)));
    }
    // Sorting first makes the survivor of a duplicate the plainer code: "en"
    // rather than "en-orig".
    out.sort();
    let mut seen: Vec<String> = Vec::new();
    out.retain(|(_, text)| {
        if seen.iter().any(|t| t == text) {
            false
        } else {
            seen.push(text.clone());
            true
        }
    });
    out
}

/// Subtitles muxed into a downloaded file, as (language, WebVTT text).
///
/// The file keeps them, but the player does not use the element's own in-band
/// rendering: WebKit hands those to the media pipeline, which places them
/// wherever the container's text box says — bottom right, in practice — and no
/// CSS can reach them. Read out as WebVTT they are ordinary cues, centred and
/// styleable like any other. Reading one costs about 40ms and no re-encoding.
#[tauri::command]
pub async fn embedded_subtitles(
    video_id: String,
    state: State<'_, AppState>,
) -> Result<Vec<(String, String)>, String> {
    let Some(path) = state.db.lock().await.get_download_path(&video_id)? else {
        return Ok(Vec::new());
    };
    if tokio::fs::metadata(&path).await.is_err() {
        return Ok(Vec::new());
    }

    let probe = tokio::process::Command::new(bin("ffprobe"))
        .args([
            "-v", "error",
            "-select_streams", "s",
            "-show_entries", "stream=index:stream_tags=language",
            "-of", "csv=p=0",
            &path,
        ])
        .output()
        .await
        .map_err(|e| format!("Could not read the file: {e}"))?;

    let mut out: Vec<(String, String)> = Vec::new();
    // ffprobe prints "<index>,<language>" per subtitle stream, and the index is
    // the absolute stream index — the mapping below counts subtitle streams, so
    // position in this list is what matters, not the number printed.
    for (nth, line) in String::from_utf8_lossy(&probe.stdout).lines().enumerate() {
        let lang = line
            .split(',')
            .nth(1)
            .map(|l| l.trim())
            .filter(|l| !l.is_empty() && *l != "und")
            .unwrap_or("en")
            .to_string();
        let dump = tokio::process::Command::new(bin("ffmpeg"))
            .args([
                "-v", "error",
                "-i", &path,
                "-map", &format!("0:s:{nth}"),
                "-f", "webvtt",
                "-",
            ])
            .output()
            .await;
        let Ok(dump) = dump else { continue };
        let text = String::from_utf8_lossy(&dump.stdout).to_string();
        if text.contains("-->") {
            // ISO 639-2 is what a container stores; the player labels by the
            // two-letter code everything else uses.
            let short = match lang.as_str() {
                "eng" => "en",
                "nld" | "dut" => "nl",
                "deu" | "ger" => "de",
                "fra" | "fre" => "fr",
                "spa" => "es",
                "ita" => "it",
                "por" => "pt",
                other => other,
            };
            out.push((short.to_string(), tidy_vtt(&text)));
        }
    }
    Ok(out)
}

/// Subtitles for a video, as (language, WebVTT text).
///
/// Streaming has no other source: YouTube's HLS manifest lists a dozen audio
/// renditions and no subtitles at all. A download saved before subtitles were
/// switched on has none beside it either, and this fills those in without
/// fetching the video again. Results are cached per video and language, so
/// replaying something costs nothing and works offline.
#[tauri::command]
pub async fn fetch_subtitles(
    video_id: String,
    lang: String,
    state: State<'_, AppState>,
) -> Result<Vec<(String, String)>, String> {
    let sub_langs = downloader::sub_langs_for(&lang);
    if sub_langs.is_empty() {
        return Ok(Vec::new());
    }

    let dir = state.app_data.join("subs").join(&video_id).join(&lang);
    let cached = read_vtt_dir(&dir).await;
    if !cached.is_empty() {
        return Ok(cached);
    }

    tokio::fs::create_dir_all(&dir)
        .await
        .map_err(|e| format!("Cannot create subtitle cache: {e}"))?;

    let template = dir.join("%(id)s.%(ext)s").to_string_lossy().to_string();
    let mut args = downloader::subs_only_args(&video_id, &template, &sub_langs);
    args.push("--ffmpeg-location".into());
    args.push(bin("ffmpeg"));

    // A video with no captions in this language is an ordinary outcome, not a
    // failure worth reporting, so the exit status is not consulted: whatever
    // landed on disk is the answer.
    let _ = state.yt_dlp().await.args(&args).output().await;
    Ok(read_vtt_dir(&dir).await)
}

/// Records one video from its URL and returns its id and title, ready to
/// download. Its channel is stored as a parent row only — saving a video is not
/// subscribing to the channel, which is a separate choice.
pub async fn save_video(state: &AppState, url: &str) -> Result<(String, String), String> {
    let Some(video_id) = resolve::video_id_from_url(url) else {
        return Err("That link is not a video.".into());
    };
    let html = fetch_page(state, url).await?;
    let Some((channel_id, channel_title)) = resolve::parse_channel(&html) else {
        return Err("Could not read that video's page.".into());
    };
    let meta = resolve::parse_video(&html);

    let channel = Channel {
        id: channel_id.clone(),
        title: channel_title,
        url: resolve::channel_url(&channel_id),
    };
    let video = Video {
        id: video_id.clone(),
        channel_id,
        title: meta.title.clone(),
        description: String::new(),
        published: meta
            .published
            .as_deref()
            .and_then(resolve::iso_date_to_unix)
            .unwrap_or_else(now_secs),
        thumb_url: format!("https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"),
        views: 0,
        is_short: url.contains("/shorts/"),
    };

    {
        let mut db = state.db.lock().await;
        db.ensure_channel(&channel)?;
        db.upsert_videos(&[video])?;
    }
    Ok((video_id, meta.title))
}

/// The quality and subtitle language the app is set to, so work started from
/// the menu bar matches work started from the window.
#[tauri::command]
pub async fn set_download_defaults(
    quality: String,
    sub_lang: String,
    state: State<'_, AppState>,
) -> Result<(), String> {
    *state.download_defaults.lock().await = (quality, sub_lang);
    Ok(())
}

/// Turns a scraped list into channels, and says what it could not resolve.
#[derive(Serialize)]
pub struct ScrapeResult {
    pub channels: Vec<Channel>,
    /// Names whose channel could not be identified, so the user knows the list
    /// handed on is short rather than wondering later.
    pub unresolved: Vec<String>,
    pub looked_up: usize,
}

/// Reads the subscription list out of the browser and resolves it to channels.
///
/// The page gives handles, not channel ids. Most of them belong to channels
/// already known here, and those are matched by name — no request at all.
/// Only genuinely new ones are looked up, a few at a time, because a burst of
/// two hundred requests is how you earn an HTTP 429.
#[tauri::command]
pub async fn scrape_subscriptions(
    app: AppHandle,
    state: State<'_, AppState>,
) -> Result<ScrapeResult, String> {
    let rows = crate::tray::scrape_subscriptions(&app).await?;

    let known: HashMap<String, ChannelWithCount> = state
        .db
        .lock()
        .await
        .list_channels()?
        .into_iter()
        .map(|c| (subscriptions::name_key(&c.title), c))
        .collect();

    let mut channels: Vec<Channel> = Vec::new();
    let mut to_look_up: Vec<(String, String)> = Vec::new();

    for (href, name) in rows {
        // A link that already carries the id costs nothing.
        if let Some(id) = subscriptions::id_from_href(&href) {
            channels.push(Channel { url: resolve::channel_url(&id), id, title: name });
            continue;
        }
        match known.get(&subscriptions::name_key(&name)) {
            Some(c) if !name.is_empty() => channels.push(Channel {
                id: c.id.clone(),
                title: name,
                url: c.url.clone(),
            }),
            _ => to_look_up.push((href, name)),
        }
    }

    let looked_up = to_look_up.len();
    let mut unresolved = Vec::new();

    // Four at a time: enough to finish a normal import quickly, gentle enough
    // that YouTube does not start refusing.
    let found = futures::stream::iter(to_look_up.into_iter().map(|(href, name)| {
        let http = state.http.clone();
        async move {
            let url = subscriptions::handle_url(&href);
            let page = match http
                .get(&url)
                .header("Accept-Language", "en-US,en;q=0.9")
                .header("Cookie", "CONSENT=YES+1")
                .send()
                .await
            {
                Ok(r) => r.text().await.ok(),
                Err(_) => None,
            };
            let resolved = page
                .as_deref()
                .and_then(resolve::parse_channel)
                .map(|(id, parsed)| Channel {
                    url: resolve::channel_url(&id),
                    id,
                    // The page's own name beats a scraped one only when the
                    // scrape had none.
                    title: if name.is_empty() { parsed } else { name.clone() },
                });
            (name, href, resolved)
        }
    }))
    .buffer_unordered(4)
    .collect::<Vec<_>>()
    .await;

    for (name, href, resolved) in found {
        match resolved {
            Some(c) => channels.push(c),
            None => unresolved.push(if name.is_empty() { href } else { name }),
        }
    }

    if channels.is_empty() {
        return Err("None of the channels on that page could be identified.".into());
    }
    Ok(ScrapeResult { channels, unresolved, looked_up })
}

/// What replacing the subscription list with a scraped one would change.
#[tauri::command]
pub async fn preview_scraped_import(
    channels: Vec<Channel>,
    state: State<'_, AppState>,
) -> Result<ImportPreview, String> {
    state.db.lock().await.preview_replace(&channels)
}

/// Replaces the subscription list with a scraped one, exactly as a Takeout
/// import does, files and all.
#[tauri::command]
pub async fn import_scraped(
    channels: Vec<Channel>,
    state: State<'_, AppState>,
) -> Result<usize, String> {
    let dropped = state.db.lock().await.paths_dropped_by_replace(&channels)?;
    for p in &dropped {
        let _ = tokio::fs::remove_file(p).await;
    }
    state.db.lock().await.replace_channels(&channels)
}

/// What clearing the subscription list would take with it.
#[tauri::command]
pub async fn preview_remove_all_subscriptions(
    state: State<'_, AppState>,
) -> Result<ImportPreview, String> {
    state.db.lock().await.preview_replace(&[])
}

/// Empties the subscription list, and the videos and files hanging off it.
///
/// The same path an import takes when nothing survives it, so a video saved on
/// its own from the menu bar is left alone here too: its channel was never a
/// subscription, and this is about subscriptions.
#[tauri::command]
pub async fn remove_all_subscriptions(state: State<'_, AppState>) -> Result<usize, String> {
    let dropped = state.db.lock().await.paths_dropped_by_replace(&[])?;
    for path in &dropped {
        let _ = tokio::fs::remove_file(path).await;
    }
    let before = state.db.lock().await.list_channels()?.len();
    state.db.lock().await.replace_channels(&[])?;
    Ok(before)
}

/// What deleting one subscription would take with it.
#[derive(Serialize)]
pub struct RemovalPreview {
    pub title: String,
    pub videos: i64,
    pub downloaded: usize,
}

#[tauri::command]
pub async fn preview_delete_channel(
    channel_id: String,
    state: State<'_, AppState>,
) -> Result<RemovalPreview, String> {
    let db = state.db.lock().await;
    let (videos, paths) = db.channel_removal(&channel_id)?;
    let title = db
        .list_channels()?
        .into_iter()
        .find(|c| c.id == channel_id)
        .map(|c| c.title)
        .unwrap_or_else(|| "this channel".to_string());
    Ok(RemovalPreview { title, videos, downloaded: paths.len() })
}

/// Unsubscribes in the app only. Nothing is touched on YouTube.
#[tauri::command]
pub async fn delete_channel(
    channel_id: String,
    state: State<'_, AppState>,
) -> Result<(), String> {
    let paths = {
        let db = state.db.lock().await;
        db.channel_removal(&channel_id)?.1
    };
    for p in &paths {
        let _ = tokio::fs::remove_file(p).await;
    }
    state.db.lock().await.delete_channel(&channel_id)
}

/// Fetches a YouTube page as a browser would, so the embedded player data is
/// there to read.
async fn fetch_page(state: &AppState, url: &str) -> Result<String, String> {
    let resp = state
        .http
        .get(url)
        .header("Accept-Language", "en-US,en;q=0.9")
        // Without a consent cookie some regions get an interstitial instead of
        // the page, and none of the player data is in that.
        .header("Cookie", "CONSENT=YES+1")
        .send()
        .await
        .map_err(|e| format!("Could not reach YouTube: {e}"))?;
    if !resp.status().is_success() {
        return Err(format!("YouTube returned {}", resp.status()));
    }
    resp.text()
        .await
        .map_err(|e| format!("Could not read the page: {e}"))
}

/// Adds one channel from any YouTube URL — the channel's own page, a handle, or
/// a video of theirs. Returns the channel's title.
#[tauri::command]
pub async fn add_channel(
    url: String,
    app: AppHandle,
    state: State<'_, AppState>,
) -> Result<String, String> {
    let url = url.trim().to_string();
    if url.is_empty() {
        return Err("Paste a YouTube channel or video link first.".into());
    }
    let url = if url.starts_with("http") { url } else { format!("https://{url}") };
    if !resolve::is_youtube_url(&url) {
        return Err("That is not a YouTube link.".into());
    }

    let html = fetch_page(&state, &url).await?;
    let Some((id, title)) = resolve::parse_channel(&html) else {
        return Err("No channel found at that link. A channel page or one of its videos works best.".into());
    };

    if state.db.lock().await.is_subscribed(&id)? {
        return Err(format!("{title} is already in your subscriptions."));
    }

    let channel = Channel { id: id.clone(), title: title.clone(), url: resolve::channel_url(&id) };
    state.db.lock().await.upsert_channels(&[channel])?;

    // Fill the new channel in straight away, or it sits there empty.
    let _ = refresh_one(&app, &state, &id).await;
    Ok(title)
}

/// Downloads that were still going when the app last closed.
///
/// Killing the app kills yt-dlp with it, leaving rows queued or running that no
/// process backs. The front end hands these straight back to `download_video`,
/// so they rejoin the same queue rather than needing a second code path.
#[tauri::command]
pub async fn interrupted_downloads(
    state: State<'_, AppState>,
) -> Result<Vec<(String, String, String)>, String> {
    state.db.lock().await.interrupted_downloads()
}

/// Stops everything downloading or waiting to download.
///
/// Kills the running processes, then marks every row the database still calls
/// queued or running as cancelled — which covers downloads parked on a slot,
/// and any left behind by a crash that no process backs any more.
#[tauri::command]
pub async fn cancel_all_downloads(
    app: AppHandle,
    state: State<'_, AppState>,
) -> Result<usize, String> {
    // Drain first and kill after, so no child is killed while the map is held.
    let children: Vec<(String, tokio::process::Child)> =
        state.children.lock().await.drain().collect();
    for (_, mut child) in children {
        let _ = child.kill().await;
    }

    let ids = state.db.lock().await.active_downloads()?;
    let library = state.library.lock().await.clone();
    for id in &ids {
        state
            .db
            .lock()
            .await
            .set_download_state(id, DownloadState::Cancelled, None)?;
        cleanup_partials(library.clone(), id).await;
        let _ = app.emit(
            "download:state",
            DownloadStateEvent {
                video_id: id.clone(),
                state: DownloadState::Cancelled,
                error: None,
                path: None,
            },
        );
    }
    Ok(ids.len())
}

/// 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;
            }
        }
    }
    // Fetched captions live in the cache rather than beside the video, so they
    // would otherwise survive a wipe.
    let _ = tokio::fs::remove_dir_all(state.app_data.join("subs")).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}"))?;

    let yt_dlp_argv = resolve_yt_dlp(app, &app_data);

    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_argv: Arc::new(Mutex::new(yt_dlp_argv)),
        download_defaults: Arc::new(Mutex::new(("best".into(), "en".into()))),
        cookies_from: Arc::new(Mutex::new(None)),
    })
}

#[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()
    }

    use super::{parse_length_seconds, tidy_vtt};

    /// Exactly the shape yt-dlp writes for YouTube auto-captions.
    const RAW_VTT: &str = concat!(
        "WEBVTT\nKind: captions\nLanguage: en\n\n",
        "00:00:12.400 --> 00:00:26.950 align:start position:0%\n",
        "Heat<00:00:12.480><c> up</c><00:00:12.480><c> here.</c>\n",
    );

    #[test]
    fn cue_settings_that_pin_subtitles_to_the_edge_are_removed() {
        let out = tidy_vtt(RAW_VTT);
        assert!(!out.contains("align:start"));
        assert!(!out.contains("position:0%"));
        // The timing itself must survive intact.
        assert!(out.contains("00:00:12.400 --> 00:00:26.950"));
    }

    #[test]
    fn karaoke_timing_tags_are_stripped_leaving_plain_text() {
        let out = tidy_vtt(RAW_VTT);
        assert!(out.contains("Heat up here."));
        assert!(!out.contains("<c>"));
        assert!(!out.contains("00:00:12.480>"));
    }

    #[test]
    fn the_header_survives_or_the_file_stops_being_webvtt() {
        assert!(tidy_vtt(RAW_VTT).starts_with("WEBVTT"));
    }

    #[test]
    fn reads_the_length_out_of_a_watch_page() {
        assert_eq!(
            parse_length_seconds(r#"...,"lengthSeconds":"1315","isLive"..."#),
            Some(1315)
        );
        assert_eq!(parse_length_seconds("nothing here"), None);
        // A zero length is meaningless and must not be stored.
        assert_eq!(parse_length_seconds(r#""lengthSeconds":"0""#), None);
    }

    #[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"));
    }
}
