//! Scanning the `channels/` directory tree into channels, playlists, and videos.
//!
//! # Directory layout expected
//!
//! ```text
//! channels/
//!   <channel-name>/
//!     Title [VIDEO_ID].mkv
//!     Title [VIDEO_ID].webp          ← thumbnail
//!     Title [VIDEO_ID].description
//!     Title [VIDEO_ID].info.json
//!     Title [VIDEO_ID].en.vtt        ← subtitle (lang = "en")
//!     <playlist-name>/
//!       Title [VIDEO_ID].mkv
//!       …
//! ```
//!
//! Files that don't match the `Title [ID].ext` naming convention are silently
//! ignored.  Hidden directories (name starts with `.`) and directories that
//! contain no recognisable video files are skipped.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::database::Database;
use crate::download_options::DownloadOptions;
use crate::platform::{self, Platform};

const VIDEO_EXTS: &[&str] = &["mkv", "mp4", "webm", "m4v", "mov", "avi"];
const AUDIO_EXTS: &[&str] = &["mp3", "m4a", "opus", "flac", "ogg", "wav", "aac"];
const THUMB_EXTS: &[&str] = &["webp", "jpg", "jpeg", "png"];

/// A single WebVTT subtitle track discovered alongside a video file.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Subtitle {
    /// ISO 639-1/2 language code extracted from the `.lang.vtt` filename suffix.
    pub lang: String,
    pub path: PathBuf,
}

/// An audio track in the music library.
#[derive(Clone, Debug)]
pub struct Track {
    pub id: String,
    pub title: String,
    pub artist: String,
    pub path: PathBuf,
    pub thumb_path: Option<PathBuf>,
    #[allow(dead_code)]
    pub info_path: Option<PathBuf>,
    pub duration_secs: Option<f64>,
    pub file_size: Option<u64>,
}

/// A fully enriched video entry, ready to serve to the UI.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Video {
    /// yt-dlp video ID (the part inside `[…]` in the filename).
    #[serde(default)]
    pub id: String,
    #[serde(default)]
    pub title: String,
    #[serde(default)]
    #[allow(dead_code)]
    pub stem: String,
    #[serde(default)]
    pub video_path: Option<PathBuf>,
    #[serde(default)]
    pub thumb_path: Option<PathBuf>,
    #[serde(default)]
    pub description_path: Option<PathBuf>,
    /// Path to the `.info.json` sidecar — used to read duration, chapters, etc.
    #[serde(default)]
    pub info_path: Option<PathBuf>,
    #[serde(default)]
    pub subtitles: Vec<Subtitle>,
    #[serde(default)]
    pub has_live_chat: bool,
    /// Duration read from `info.json`; `None` if the sidecar is missing.
    #[serde(default)]
    pub duration_secs: Option<f64>,
    /// Whether `info.json` lists a non-empty `chapters` array. Cached at scan
    /// time so the web layer needn't re-read and parse the sidecar per request.
    #[serde(default)]
    pub has_chapters: bool,
    /// Size of the video file on disk; `None` if the video file is missing.
    #[serde(default)]
    pub file_size: Option<u64>,
    /// Filesystem mtime of the video file as a UNIX timestamp (seconds).
    /// Used by the activity feed to surface recent additions. `None` if the
    /// video file is missing or the system clock returned an error.
    #[serde(default)]
    pub mtime_unix: Option<u64>,
    /// Upload date as `YYYYMMDD` (yt-dlp's native format from info.json).
    /// `None` if the info.json sidecar is missing or lacks the field.
    #[serde(default)]
    pub upload_date: Option<String>,
}

/// A sub-directory inside a channel that contains videos (treated as a playlist).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Playlist {
    pub name: String,
    #[allow(dead_code)]
    pub path: PathBuf,
    pub videos: Vec<Video>,
}

/// Channel-level metadata pulled from the first available `info.json`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ChannelMeta {
    pub subscriber_count: Option<u64>,
    pub channel_url: Option<String>,
    pub uploader: Option<String>,
}

/// A top-level channel directory with all its videos and playlists.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Channel {
    #[serde(default)]
    pub name: String,
    #[serde(default)]
    pub path: PathBuf,
    /// Source platform — drives sidebar grouping and the re-check URL.
    #[serde(default)]
    pub platform: Platform,
    /// Originating URL read from the `.source-url` sidecar. Falls back to a
    /// folder-name heuristic for legacy YouTube libraries that predate it.
    #[serde(default)]
    pub source_url: Option<String>,
    /// Videos stored directly inside the channel directory (not in a sub-folder).
    #[serde(default)]
    pub videos: Vec<Video>,
    /// Sub-directories that contain at least one video.
    #[serde(default)]
    pub playlists: Vec<Playlist>,
    #[serde(default)]
    pub meta: Option<ChannelMeta>,
    /// Cached sum of `videos.len() + playlists[*].videos.len()`.
    #[serde(default)]
    pub total_videos_cached: usize,
    /// Cached sum of all video file sizes.
    #[serde(default)]
    pub total_size_cached: u64,
    /// Per-channel download-option overrides loaded from the SQLite
    /// `channel_options` table. Empty by default, meaning "use globals".
    /// The scanner leaves this as `default()`; callers populate it after the
    /// scan by reading the DB once via
    /// [`crate::database::Database::get_all_channel_options`].
    #[serde(default)]
    pub download_options: DownloadOptions,
    /// Optional folder grouping. `None` means the channel is "Unfiled" and
    /// appears under its platform's heading. Populated post-scan by
    /// [`apply_channel_folders`] from the
    /// [`crate::database::Database::get_all_channel_assignments`] map.
    #[serde(default)]
    pub folder_id: Option<i64>,
}

impl Channel {
    pub fn total_videos(&self) -> usize {
        self.total_videos_cached
    }

    /// Iterate over every [`Video`] in this channel, including those nested
    /// inside playlists. Used widely; previously open-coded at each call site.
    pub fn all_videos(&self) -> impl Iterator<Item = &Video> {
        self.videos
            .iter()
            .chain(self.playlists.iter().flat_map(|p| p.videos.iter()))
    }
}

/// Mutate `channels` in place, filling each one's [`Channel::folder_id`]
/// from the supplied `(platform_dir_name, handle) → folder_id` map.
pub fn apply_channel_folders(
    channels: &mut [Channel],
    folder_map: &std::collections::HashMap<(String, String), i64>,
) {
    for ch in channels {
        let key = (ch.platform.dir_name().to_string(), ch.name.clone());
        ch.folder_id = folder_map.get(&key).copied();
    }
}

/// Mutate `channels` in place, filling each one's [`Channel::download_options`]
/// from the supplied `(platform_dir_name, handle) → options_json` map.
///
/// The caller normally builds the map with
/// [`crate::database::Database::get_all_channel_options`] right after a
/// scan / rescan and before publishing the library to the UI.
pub fn apply_channel_options(
    channels: &mut [Channel],
    options_map: &std::collections::HashMap<(String, String), String>,
) {
    for ch in channels {
        let key = (ch.platform.dir_name().to_string(), ch.name.clone());
        if let Some(json) = options_map.get(&key) {
            ch.download_options = DownloadOptions::from_json(json);
        }
    }
}

/// Find a video by ID across a slice of channels. Returns the matching
/// [`Video`] alongside the channel it belongs to.
pub fn find_video<'a>(channels: &'a [Channel], id: &str) -> Option<(&'a Video, &'a Channel)> {
    for ch in channels {
        if let Some(v) = ch.all_videos().find(|v| v.id == id) {
            return Some((v, ch));
        }
    }
    None
}

/// Scan all platform directories rooted at (or sibling to) `youtube_root`
/// and return every channel found, tagged with its source platform.
///
/// The configured `youtube_root` is treated as `Platform::YouTube` for
/// backward compatibility with libraries created before the multi-platform
/// changes landed. Each other platform's folder lives as a sibling — see
/// [`crate::platform::platform_root`].
///
/// Per-channel info.json reads are parallelised across the available CPUs
/// because that's where ~all the time goes for large libraries (one fs read
/// + one JSON parse per video, multiplied by thousands).
/// Walk the channels directory and return the enriched library tree.
///
/// `cache` is an optional handle to the SQLite database holding the
/// `info_cache` table; when present, the scanner skips re-parsing
/// info.json sidecars whose `mtime` is unchanged since the last scan.
/// The cache is best-effort; any SQLite error falls through to a fresh
/// parse without complaint. Each parallel scan worker checks out its
/// own r2d2 connection so per-file lookups don't serialise.
///
/// Pass `None` from contexts that don't have a DB yet (early startup,
/// the in-memory fallback path).
pub fn scan_channels_with_cache(youtube_root: &Path, cache: Option<&Database>) -> Vec<Channel> {
    // Gather (platform, channel-folder-name, full-path) across every platform.
    let mut dirs: Vec<(Platform, String, PathBuf)> = Vec::new();
    for &platform in Platform::all() {
        let root = platform::platform_root(youtube_root, platform);
        let Ok(entries) = std::fs::read_dir(&root) else { continue };
        for entry in entries.flatten() {
            let path = entry.path();
            if !path.is_dir() { continue; }
            let name = entry.file_name().to_string_lossy().into_owned();
            if name.starts_with('.') { continue; }
            dirs.push((platform, name, path));
        }
    }

    // Leave one core free so a cold/large scan doesn't peg every core and
    // make the box unresponsive — this runs as a background task and the scan
    // is mostly disk-I/O-bound anyway, so giving up one thread costs little.
    let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
    let n_workers = cores.saturating_sub(1).max(1).min(dirs.len().max(1));
    // Hand each worker its own Database handle via .clone() (Arc inside,
    // cheap). Workers without a cache get None.
    let cache_handle: Option<Database> = cache.cloned();
    let mut channels = parallel_map(dirs, n_workers, move |(platform, name, path)| {
        let (videos, playlists) = scan_channel_dir_with_cache(&path, cache_handle.as_ref());
        if videos.is_empty() && playlists.is_empty() { return None; }
        let meta = load_channel_meta(&videos);
        let total_videos_cached =
            videos.len() + playlists.iter().map(|p| p.videos.len()).sum::<usize>();
        let total_size_cached = videos
            .iter()
            .chain(playlists.iter().flat_map(|p| p.videos.iter()))
            .filter_map(|v| v.file_size)
            .sum();
        let source_url = platform::read_source_url(&path);
        Some(Channel {
            name,
            path,
            platform,
            source_url,
            videos,
            playlists,
            meta,
            total_videos_cached,
            total_size_cached,
            download_options: DownloadOptions::default(),
            folder_id: None,
        })
    })
    .into_iter()
    .flatten()
    .collect::<Vec<_>>();
    // Stable order: platform first (so the sidebar groups cleanly), then name.
    channels.sort_by(|a, b| {
        let pa = a.platform as u8;
        let pb = b.platform as u8;
        pa.cmp(&pb).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
    });
    channels
}

/// Fan an `items` slice across `n_workers` threads, applying `f` to each item
/// and returning results in the original input order.
///
/// Stdlib-only mini work-stealer: an atomic index hands out the next slot to
/// any worker that's free. Used to parallelise channel-directory scans
/// without dragging in rayon.
fn parallel_map<I, O, F>(items: Vec<I>, n_workers: usize, f: F) -> Vec<O>
where
    I: Send + 'static,
    O: Send + 'static + Default,
    F: Fn(I) -> O + Send + Sync + 'static,
{
    let len = items.len();
    if len == 0 { return Vec::new(); }
    if n_workers <= 1 {
        return items.into_iter().map(f).collect();
    }
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::{Arc, Mutex};

    let items: Vec<Mutex<Option<I>>> = items.into_iter().map(|v| Mutex::new(Some(v))).collect();
    let items = Arc::new(items);
    let results: Vec<Mutex<O>> = (0..len).map(|_| Mutex::new(O::default())).collect();
    let results = Arc::new(results);
    let next = Arc::new(AtomicUsize::new(0));
    let f = Arc::new(f);

    let mut handles = Vec::with_capacity(n_workers);
    for _ in 0..n_workers {
        let items = items.clone();
        let results = results.clone();
        let next = next.clone();
        let f = f.clone();
        handles.push(std::thread::spawn(move || {
            loop {
                let i = next.fetch_add(1, Ordering::Relaxed);
                if i >= len { break; }
                let input = items[i].lock().unwrap().take().unwrap();
                let out = f(input);
                *results[i].lock().unwrap() = out;
            }
        }));
    }
    for h in handles { let _ = h.join(); }

    Arc::try_unwrap(results)
        .unwrap_or_else(|_| unreachable!("workers joined; refs released"))
        .into_iter()
        .map(|m| m.into_inner().unwrap())
        .collect()
}

fn load_channel_meta(videos: &[Video]) -> Option<ChannelMeta> {
    // Pull channel-level fields out of the first video's info.json
    let info_path = videos.iter().find_map(|v| {
        let p = v.video_path.as_ref()?.with_extension("info.json");
        p.exists().then_some(p)
    })?;
    let text = std::fs::read_to_string(&info_path).ok()?;
    let val: serde_json::Value = serde_json::from_str(&text).ok()?;
    Some(ChannelMeta {
        subscriber_count: val.get("channel_follower_count").and_then(|v| v.as_u64()),
        channel_url: val.get("channel_url").and_then(|v| v.as_str()).map(String::from),
        uploader: val
            .get("uploader")
            .or_else(|| val.get("channel"))
            .and_then(|v| v.as_str())
            .map(String::from),
    })
}

enum FileKind {
    Video,
    Thumb,
    Description,
    LiveChat,
    Info,
    Other,
}

fn classify(file_name: &str) -> Option<(&str, FileKind)> {
    if let Some(stem) = file_name.strip_suffix(".live_chat.json") {
        return Some((stem, FileKind::LiveChat));
    }
    if let Some(stem) = file_name.strip_suffix(".info.json") {
        return Some((stem, FileKind::Info));
    }
    let dot = file_name.rfind('.')?;
    let stem = &file_name[..dot];
    if stem.is_empty() { return None; }
    let ext = file_name[dot + 1..].to_lowercase();
    let kind = if VIDEO_EXTS.contains(&ext.as_str()) {
        FileKind::Video
    } else if THUMB_EXTS.contains(&ext.as_str()) {
        FileKind::Thumb
    } else if ext == "description" {
        FileKind::Description
    } else {
        FileKind::Other
    };
    Some((stem, kind))
}

fn parse_stem(stem: &str) -> Option<(String, String)> {
    let close = stem.rfind(']')?;
    let open = stem[..close].rfind('[')?;
    let id = stem[open + 1..close].trim();
    if id.is_empty() { return None; }
    let title = stem[..open].trim().trim_end_matches('-').trim();
    let title = if title.is_empty() { stem } else { title };
    Some((title.to_string(), id.to_string()))
}

struct RawVideo {
    id: String,
    title: String,
    stem: String,
    video_path: Option<PathBuf>,
    thumb_path: Option<PathBuf>,
    description_path: Option<PathBuf>,
    info_path: Option<PathBuf>,
    subtitles: Vec<Subtitle>,
    has_live_chat: bool,
}

fn collect_raw_videos(entries: impl Iterator<Item = std::fs::DirEntry>) -> Vec<RawVideo> {
    let mut by_stem: BTreeMap<String, RawVideo> = BTreeMap::new();
    let mut pending_subs: Vec<(String, String, PathBuf)> = Vec::new();
    for entry in entries {
        let path = entry.path();
        if !path.is_file() { continue; }
        let file_name = entry.file_name().to_string_lossy().into_owned();

        // Subtitles: "Title [id].en.vtt" or "Title [id].en.srt" — strip ext then .lang
        let sub_stem = file_name.strip_suffix(".vtt")
            .or_else(|| file_name.strip_suffix(".srt"));
        if let Some(sub_stem) = sub_stem {
            if let Some(dot) = sub_stem.rfind('.') {
                let lang = sub_stem[dot + 1..].to_string();
                let video_stem = sub_stem[..dot].to_string();
                pending_subs.push((video_stem, lang, path));
                continue;
            }
        }

        let Some((stem, kind)) = classify(&file_name) else { continue };
        let Some((title, id)) = parse_stem(stem) else { continue };
        let raw = by_stem.entry(stem.to_string()).or_insert_with(|| RawVideo {
            id,
            title,
            stem: stem.to_string(),
            video_path: None,
            thumb_path: None,
            description_path: None,
            info_path: None,
            subtitles: Vec::new(),
            has_live_chat: false,
        });
        match kind {
            FileKind::Video => { if raw.video_path.is_none() { raw.video_path = Some(path); } }
            FileKind::Thumb => { if raw.thumb_path.is_none() { raw.thumb_path = Some(path); } }
            FileKind::Description => raw.description_path = Some(path),
            FileKind::Info => raw.info_path = Some(path),
            FileKind::LiveChat => raw.has_live_chat = true,
            FileKind::Other => {}
        }
    }
    for (video_stem, lang, path) in pending_subs {
        if let Some(raw) = by_stem.get_mut(&video_stem) {
            raw.subtitles.push(Subtitle { lang, path });
        }
    }
    for raw in by_stem.values_mut() {
        raw.subtitles.sort_by(|a, b| a.lang.cmp(&b.lang));
    }
    by_stem.into_values().collect()
}

fn enrich_with_cache(raws: Vec<RawVideo>, cache: Option<&Database>) -> Vec<Video> {
    // Cache misses are collected here and flushed in ONE transaction after
    // the loop (`info_cache_put_many`). On a cold cache every video is a
    // miss, so per-row autocommitted upserts would pay one commit per video.
    let mut cache_misses: Vec<(String, u64, Option<f64>, bool, Option<String>)> = Vec::new();
    let mut videos: Vec<Video> = raws.into_iter().map(|raw| {
        // Parse info.json once for both duration and chapter presence.
        // The cache (if provided) lets us skip the read+JSON parse when
        // the file's mtime is unchanged since last scan — the dominant
        // cost on a warm-filesystem rescan.
        let (duration_secs, has_chapters, upload_date) = raw.info_path.as_ref()
            .map(|p| {
                let mtime = std::fs::metadata(p)
                    .and_then(|m| m.modified())
                    .ok()
                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                    .map(|d| d.as_secs());
                let path_str = p.to_string_lossy();
                // Cache hit: return early without reading or parsing.
                if let (Some(mt), Some(db)) = (mtime, cache) {
                    if let Some(hit) = db.info_cache_get(&path_str, mt) {
                        return hit;
                    }
                }
                // Cache miss: parse the file, then upsert the result.
                let parsed = std::fs::read_to_string(p.as_path()).ok()
                    .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
                    .map(|val| {
                        let dur = val.get("duration").and_then(|v| v.as_f64());
                        let chap = val.get("chapters")
                            .and_then(|c| c.as_array())
                            .map(|a| !a.is_empty())
                            .unwrap_or(false);
                        // Prefer `upload_date`; fall back to `release_date` for premiere/live.
                        let date = val.get("upload_date")
                            .and_then(|v| v.as_str())
                            .or_else(|| val.get("release_date").and_then(|v| v.as_str()))
                            .map(|s| s.to_string());
                        (dur, chap, date)
                    })
                    .unwrap_or((None, false, None));
                if let (Some(mt), Some(_)) = (mtime, cache) {
                    cache_misses.push((path_str.into_owned(), mt, parsed.0, parsed.1, parsed.2.clone()));
                }
                parsed
            })
            .unwrap_or((None, false, None));
        let metadata = raw.video_path.as_ref()
            .and_then(|p| std::fs::metadata(p).ok());
        let file_size = metadata.as_ref().map(|m| m.len());
        let mtime_unix = metadata
            .as_ref()
            .and_then(|m| m.modified().ok())
            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
            .map(|d| d.as_secs());
        Video {
            id: raw.id,
            title: raw.title,
            stem: raw.stem,
            video_path: raw.video_path,
            thumb_path: raw.thumb_path,
            description_path: raw.description_path,
            info_path: raw.info_path,
            subtitles: raw.subtitles,
            has_live_chat: raw.has_live_chat,
            duration_secs,
            has_chapters,
            file_size,
            mtime_unix,
            upload_date,
        }
    }).collect();
    if let Some(db) = cache {
        db.info_cache_put_many(&cache_misses);
    }
    // sort_by_cached_key: lowercase each title once, not once per comparison.
    videos.sort_by_cached_key(|v| v.title.to_lowercase());
    videos
}

/// Scan a single flat directory for video files and return enriched
/// `Video` entries. Used when rescanning a playlist directory without
/// a full library reload.
pub fn scan_video_files_with_cache(dir: &Path, cache: Option<&Database>) -> Vec<Video> {
    let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
    let raws = collect_raw_videos(entries.flatten().filter(|e| e.path().is_file()));
    enrich_with_cache(raws, cache)
}

fn scan_channel_dir_with_cache(
    dir: &Path,
    cache: Option<&Database>,
) -> (Vec<Video>, Vec<Playlist>) {
    let Ok(entries) = std::fs::read_dir(dir) else { return (Vec::new(), Vec::new()) };

    let mut file_entries = Vec::new();
    let mut playlists = Vec::new();

    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            let name = entry.file_name().to_string_lossy().into_owned();
            let videos = scan_video_files_with_cache(&path, cache);
            if !videos.is_empty() {
                playlists.push(Playlist { name, path, videos });
            }
        } else {
            file_entries.push(entry);
        }
    }

    let raws = collect_raw_videos(file_entries.into_iter());
    let videos = enrich_with_cache(raws, cache);
    playlists.sort_by_key(|p| p.name.to_lowercase());
    (videos, playlists)
}

// ── Music library ─────────────────────────────────────────────────────────────

/// Scan `root` (the `music/` directory) for audio tracks, recursively.
///
/// The top-level subdirectory name is used as the default artist (overridden
/// by info.json's `artist`/`creator`/`uploader` when present). Deeper levels
/// — e.g. `music/Artist/Album/song.opus` — are walked but the top-level name
/// remains the fallback artist so albums don't reset the attribution.
pub fn scan_music(root: &Path) -> Vec<Track> {
    let mut tracks = Vec::new();
    let Ok(entries) = std::fs::read_dir(root) else { return tracks };
    for entry in entries.flatten() {
        let path = entry.path();
        let name = entry.file_name().to_string_lossy().into_owned();
        if name.starts_with('.') { continue; }
        if path.is_dir() {
            scan_music_dir(&path, &name, &mut tracks);
        } else if let Some(track) = track_from_path(&path, "") {
            tracks.push(track);
        }
    }
    tracks.sort_by_key(|t| (t.artist.to_lowercase(), t.title.to_lowercase()));
    tracks
}

fn scan_music_dir(dir: &Path, folder_artist: &str, tracks: &mut Vec<Track>) {
    let Ok(entries) = std::fs::read_dir(dir) else { return };
    for entry in entries.flatten() {
        let path = entry.path();
        let name = entry.file_name().to_string_lossy().into_owned();
        if name.starts_with('.') { continue; }
        if path.is_file() {
            if let Some(track) = track_from_path(&path, folder_artist) {
                tracks.push(track);
            }
        } else if path.is_dir() {
            // Recurse into albums/subfolders while preserving the top-level
            // artist label.
            scan_music_dir(&path, folder_artist, tracks);
        }
    }
}

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

    #[test]
    fn parse_stem_extracts_id_and_title() {
        let (title, id) = parse_stem("My great video [abc123]").unwrap();
        assert_eq!(title, "My great video");
        assert_eq!(id, "abc123");
    }

    #[test]
    fn parse_stem_trims_trailing_dash() {
        let (title, _id) = parse_stem("Some video - [xyz]").unwrap();
        assert_eq!(title, "Some video");
    }

    #[test]
    fn parse_stem_rejects_missing_brackets() {
        assert!(parse_stem("no brackets here").is_none());
    }

    #[test]
    fn parse_stem_rejects_empty_id() {
        assert!(parse_stem("foo []").is_none());
    }

    #[test]
    fn parse_stem_handles_brackets_in_title() {
        // The last [..] is the id; earlier ones are part of the title.
        let (title, id) = parse_stem("[NSFW] Some title [vidid]").unwrap();
        assert_eq!(id, "vidid");
        assert!(title.contains("[NSFW]"));
    }
}

fn track_from_path(path: &Path, folder_artist: &str) -> Option<Track> {
    let ext = path.extension()?.to_str()?.to_lowercase();
    if !AUDIO_EXTS.contains(&ext.as_str()) { return None; }

    let stem = path.file_stem()?.to_string_lossy().into_owned();
    let (title, id) = parse_stem(&stem)?;

    let dir = path.parent()?;
    let thumb_path = THUMB_EXTS.iter().find_map(|e| {
        let p = dir.join(format!("{stem}.{e}"));
        p.exists().then_some(p)
    });
    let info_path = {
        let p = dir.join(format!("{stem}.info.json"));
        p.exists().then_some(p)
    };

    let (duration_secs, resolved_artist) = info_path.as_ref()
        .and_then(|p| std::fs::read_to_string(p).ok())
        .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
        .map(|val| {
            let dur = val.get("duration").and_then(|v| v.as_f64());
            let art = val.get("artist")
                .or_else(|| val.get("creator"))
                .or_else(|| val.get("uploader"))
                .and_then(|v| v.as_str())
                .map(String::from)
                .unwrap_or_else(|| folder_artist.to_string());
            (dur, art)
        })
        .unwrap_or_else(|| (None, folder_artist.to_string()));

    let artist = if resolved_artist.is_empty() {
        "Unknown".to_string()
    } else {
        resolved_artist
    };

    Some(Track {
        id,
        title,
        artist,
        path: path.to_path_buf(),
        thumb_path,
        info_path,
        duration_secs,
        file_size: std::fs::metadata(path).ok().map(|m| m.len()),
    })
}

/// Flatten a scanned library into [`crate::database::SearchEntry`] rows for
/// the full-text index — every video in every channel and playlist, tagged
/// with its platform + channel name and a pointer to its description sidecar.
/// Shared by both front-ends so the index is built identically.
pub fn build_search_entries(lib: &[Channel]) -> Vec<crate::database::SearchEntry> {
    let mut out = Vec::new();
    for ch in lib {
        let platform = ch.platform.dir_name();
        let playlist_videos = ch.playlists.iter().flat_map(|p| p.videos.iter());
        for v in ch.videos.iter().chain(playlist_videos) {
            out.push(crate::database::SearchEntry {
                video_id: v.id.clone(),
                mtime_unix: v.mtime_unix.map(|m| m as i64).unwrap_or(0),
                platform: platform.to_string(),
                channel: ch.name.clone(),
                title: v.title.clone(),
                description_path: v.description_path.clone(),
                subtitle_path: v.subtitles.first().map(|s| s.path.clone()),
            });
        }
    }
    out
}
