//! SQLite storage. The only module that speaks SQL.

use crate::models::{
    Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Video,
};
use rusqlite::{params, Connection};
use std::path::Path;

pub struct Db {
    conn: Connection,
}

const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS channels (
    id        TEXT PRIMARY KEY,
    title     TEXT NOT NULL,
    url       TEXT NOT NULL,
    added_at  INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS videos (
    id          TEXT PRIMARY KEY,
    channel_id  TEXT NOT NULL,
    title       TEXT NOT NULL,
    description TEXT NOT NULL DEFAULT '',
    published   INTEGER NOT NULL,
    thumb_url   TEXT NOT NULL DEFAULT '',
    thumb_path  TEXT,
    views       INTEGER NOT NULL DEFAULT 0,
    is_short    INTEGER NOT NULL DEFAULT 0,
    fetched_at  INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_videos_published ON videos(published DESC);
CREATE INDEX IF NOT EXISTS idx_videos_channel   ON videos(channel_id);

CREATE TABLE IF NOT EXISTS playback (
    video_id   TEXT PRIMARY KEY,
    position   REAL NOT NULL,
    duration   REAL NOT NULL,
    updated_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS downloads (
    video_id     TEXT PRIMARY KEY,
    state        TEXT NOT NULL,
    path         TEXT,
    bytes_total  INTEGER,
    bytes_done   INTEGER,
    pct          REAL,
    speed        TEXT,
    eta          TEXT,
    error        TEXT,
    completed_at INTEGER
);
"#;

/// A channel row that is not a subscription and has no video on disk or on its
/// way there. Written once and used by both the count and the sweep, so the
/// confirmation can never disagree with what happens.
const ORPHAN_WHERE: &str = "c.subscribed = 0 AND NOT EXISTS (
        SELECT 1 FROM videos v JOIN downloads d ON d.video_id = v.id
         WHERE v.channel_id = c.id AND d.state IN ('done','queued','running'))";

const ORPHAN_COUNT_SQL: &str = "SELECT COUNT(*) FROM channels c WHERE c.subscribed = 0
     AND NOT EXISTS (SELECT 1 FROM videos v JOIN downloads d ON d.video_id = v.id
                      WHERE v.channel_id = c.id AND d.state IN ('done','queued','running'))";

const ORPHAN_VIDEO_COUNT_SQL: &str = "SELECT COUNT(*) FROM videos v WHERE v.channel_id IN (
        SELECT c.id FROM channels c WHERE c.subscribed = 0
         AND NOT EXISTS (SELECT 1 FROM videos v2 JOIN downloads d ON d.video_id = v2.id
                          WHERE v2.channel_id = c.id AND d.state IN ('done','queued','running')))";

fn now() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

impl Db {
    pub fn open(path: &Path) -> Result<Db, String> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| format!("Cannot create data dir: {e}"))?;
        }
        let conn = Connection::open(path).map_err(|e| format!("Cannot open database: {e}"))?;
        Self::init(conn)
    }

    /// In-memory database, for tests only.
    pub fn open_in_memory_pub() -> Result<Db, String> {
        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
        Self::init(conn)
    }

    #[cfg(test)]
    pub fn open_in_memory() -> Result<Db, String> {
        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
        Self::init(conn)
    }

    fn init(conn: Connection) -> Result<Db, String> {
        conn.execute_batch(SCHEMA)
            .map_err(|e| format!("Cannot create schema: {e}"))?;
        // CREATE TABLE IF NOT EXISTS leaves existing databases alone, so new
        // columns need adding explicitly. The error when it already exists is
        // the expected case, not a failure.
        let _ = conn.execute("ALTER TABLE videos ADD COLUMN duration INTEGER", []);
        let _ = conn.execute("ALTER TABLE channels ADD COLUMN last_error TEXT", []);
        let _ = conn.execute("ALTER TABLE channels ADD COLUMN last_checked INTEGER", []);
        // What a download was asked for, so it can be resumed as requested.
        let _ = conn.execute("ALTER TABLE downloads ADD COLUMN quality TEXT", []);
        let _ = conn.execute("ALTER TABLE downloads ADD COLUMN sub_lang TEXT", []);
        // A channel row that is not a subscription: the parent of a one-off
        // video saved from the menu bar. Existing rows are all subscriptions.
        let _ = conn.execute(
            "ALTER TABLE channels ADD COLUMN subscribed INTEGER NOT NULL DEFAULT 1",
            [],
        );
        Ok(Db { conn })
    }

    /// Notes how a channel's last refresh went. `error` of None clears it.
    pub fn set_channel_result(&self, id: &str, error: Option<&str>) -> Result<(), String> {
        self.conn
            .execute(
                "UPDATE channels SET last_error = ?2, last_checked = ?3 WHERE id = ?1",
                params![id, error, now()],
            )
            .map_err(|e| e.to_string())?;
        Ok(())
    }

    /// Records a video's length in seconds.
    pub fn set_duration(&self, video_id: &str, seconds: i64) -> Result<(), String> {
        if seconds <= 0 {
            return Ok(());
        }
        self.conn
            .execute(
                "UPDATE videos SET duration = ?2 WHERE id = ?1",
                params![video_id, seconds],
            )
            .map_err(|e| e.to_string())?;
        Ok(())
    }

    /// Of the given videos, those whose length is still unknown, in the order
    /// they were given so what is on screen first is filled first.
    pub fn filter_missing_duration(
        &self,
        ids: &[String],
        limit: i64,
    ) -> Result<Vec<String>, String> {
        let mut stmt = self
            .conn
            .prepare("SELECT duration FROM videos WHERE id = ?1")
            .map_err(|e| e.to_string())?;
        let mut out = Vec::new();
        for id in ids {
            if out.len() as i64 >= limit {
                break;
            }
            let known: Option<Option<i64>> = stmt
                .query_row(params![id], |r| r.get::<_, Option<i64>>(0))
                .ok();
            if matches!(known, Some(None)) {
                out.push(id.clone());
            }
        }
        Ok(out)
    }

    /// Videos whose length is still unknown, newest first.
    pub fn videos_missing_duration(&self, limit: i64) -> Result<Vec<String>, String> {
        let mut stmt = self
            .conn
            .prepare(
                "SELECT id FROM videos WHERE duration IS NULL
                  ORDER BY published DESC LIMIT ?1",
            )
            .map_err(|e| e.to_string())?;
        let rows = stmt
            .query_map(params![limit], |r| r.get::<_, String>(0))
            .map_err(|e| e.to_string())?;
        rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
    }

    /// What a replacing import would destroy. Callers show this before asking
    /// the user to confirm, so nothing is deleted without being named first.
    pub fn preview_replace(&self, incoming: &[Channel]) -> Result<ImportPreview, String> {
        let keep: std::collections::HashSet<&str> =
            incoming.iter().map(|c| c.id.as_str()).collect();

        let mut removed_channels = 0i64;
        for id in self.channel_ids()? {
            if !keep.contains(id.as_str()) {
                removed_channels += 1;
            }
        }

        let mut stmt = self
            .conn
            .prepare(
                "SELECT v.channel_id, COUNT(*),
                        SUM(CASE WHEN d.state = 'done' THEN 1 ELSE 0 END)
                   FROM videos v
                   JOIN channels c ON c.id = v.channel_id AND c.subscribed = 1
                   LEFT JOIN downloads d ON d.video_id = v.id
                  GROUP BY v.channel_id",
            )
            .map_err(|e| e.to_string())?;
        let rows = stmt
            .query_map([], |r| {
                Ok((
                    r.get::<_, String>(0)?,
                    r.get::<_, i64>(1)?,
                    r.get::<_, Option<i64>>(2)?.unwrap_or(0),
                ))
            })
            .map_err(|e| e.to_string())?;

        let mut removed_videos = 0i64;
        let mut removed_downloads = 0i64;
        for row in rows {
            let (cid, videos, downloads) = row.map_err(|e| e.to_string())?;
            if !keep.contains(cid.as_str()) {
                removed_videos += videos;
                removed_downloads += downloads;
            }
        }

        // Bare rows that the replace would sweep count as removals too: saying
        // "0 channels" while quietly taking one is worse than taking it.
        let (orphan_channels, orphan_videos) = self.orphan_counts()?;
        Ok(ImportPreview {
            incoming: incoming.len() as i64,
            removed_channels: removed_channels + orphan_channels,
            removed_videos: removed_videos + orphan_videos,
            removed_downloads,
        })
    }

    /// Files belonging to channels that a replacing import would drop, so the
    /// caller can delete them rather than orphaning them on disk.
    pub fn paths_dropped_by_replace(&self, incoming: &[Channel]) -> Result<Vec<String>, String> {
        let keep: std::collections::HashSet<&str> =
            incoming.iter().map(|c| c.id.as_str()).collect();
        let mut stmt = self
            .conn
            .prepare(
                "SELECT v.channel_id, d.path FROM downloads d
                   JOIN videos v ON v.id = d.video_id
                   JOIN channels c ON c.id = v.channel_id AND c.subscribed = 1
                  WHERE d.path IS NOT NULL",
            )
            .map_err(|e| e.to_string())?;
        let rows = stmt
            .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
            .map_err(|e| e.to_string())?;
        let mut out = Vec::new();
        for row in rows {
            let (cid, path) = row.map_err(|e| e.to_string())?;
            if !keep.contains(cid.as_str()) {
                out.push(path);
            }
        }
        Ok(out)
    }

    /// Replaces the subscription list outright: the imported CSV becomes the
    /// whole truth. Channels no longer present are dropped along with their
    /// videos and download records. Channels that survive keep their videos and
    /// download state untouched.
    pub fn replace_channels(&mut self, channels: &[Channel]) -> Result<usize, String> {
        let tx = self.conn.transaction().map_err(|e| e.to_string())?;
        {
            // A temp table keeps the delete set explicit and avoids building a
            // giant IN(...) clause for a few hundred channels.
            tx.execute_batch(
                "CREATE TEMP TABLE IF NOT EXISTS keep_ids (id TEXT PRIMARY KEY);
                 DELETE FROM keep_ids;",
            )
            .map_err(|e| e.to_string())?;
            {
                let mut ins = tx
                    .prepare("INSERT OR IGNORE INTO keep_ids (id) VALUES (?1)")
                    .map_err(|e| e.to_string())?;
                for c in channels {
                    ins.execute(params![c.id]).map_err(|e| e.to_string())?;
                }
            }

            tx.execute_batch(
                "CREATE TEMP VIEW IF NOT EXISTS dropped AS
                     SELECT id FROM channels
                      WHERE subscribed = 1 AND id NOT IN (SELECT id FROM keep_ids);
                 DELETE FROM downloads WHERE video_id IN (
                     SELECT id FROM videos WHERE channel_id IN (SELECT id FROM dropped));
                 DELETE FROM playback  WHERE video_id IN (
                     SELECT id FROM videos WHERE channel_id IN (SELECT id FROM dropped));
                 DELETE FROM videos   WHERE channel_id IN (SELECT id FROM dropped);
                 DELETE FROM channels WHERE id         IN (SELECT id FROM dropped);
                 DROP VIEW dropped;",
            )
            .map_err(|e| e.to_string())?;

            let mut stmt = tx
                .prepare(
                    "INSERT INTO channels (id, title, url, added_at, subscribed)
                     VALUES (?1, ?2, ?3, ?4, 1)
                     ON CONFLICT(id) DO UPDATE SET
                        title = excluded.title,
                        url = excluded.url,
                        -- A channel saved from the menu bar exists as a bare
                        -- row; subscribing to it now is a promotion, not a
                        -- duplicate.
                        subscribed = 1",
                )
                .map_err(|e| e.to_string())?;
            let ts = now();
            for c in channels {
                stmt.execute(params![c.id, c.title, c.url, ts])
                    .map_err(|e| e.to_string())?;
            }
        }
        tx.commit().map_err(|e| e.to_string())?;
        // A replace is the moment to notice that a bare row has outlived its
        // video.
        self.prune_orphan_channels()?;
        Ok(channels.len())
    }

    /// What removing one subscription would take with it: its videos, and
    /// which of those are downloaded (with the files to delete).
    pub fn channel_removal(&self, id: &str) -> Result<(i64, Vec<String>), String> {
        let videos: i64 = self
            .conn
            .query_row(
                "SELECT COUNT(*) FROM videos WHERE channel_id = ?1",
                params![id],
                |r| r.get(0),
            )
            .map_err(|e| e.to_string())?;
        let mut stmt = self
            .conn
            .prepare(
                "SELECT d.path FROM downloads d
                 JOIN videos v ON v.id = d.video_id
                 WHERE v.channel_id = ?1 AND d.path IS NOT NULL",
            )
            .map_err(|e| e.to_string())?;
        let paths = stmt
            .query_map(params![id], |r| r.get::<_, String>(0))
            .map_err(|e| e.to_string())?
            .collect::<Result<Vec<_>, _>>()
            .map_err(|e| e.to_string())?;
        Ok((videos, paths))
    }

    /// Removes one subscription and everything hanging off it.
    pub fn delete_channel(&mut self, id: &str) -> Result<(), String> {
        let tx = self.conn.transaction().map_err(|e| e.to_string())?;
        tx.execute(
            "DELETE FROM downloads WHERE video_id IN (SELECT id FROM videos WHERE channel_id = ?1)",
            params![id],
        )
        .map_err(|e| e.to_string())?;
        tx.execute(
            "DELETE FROM playback WHERE video_id IN (SELECT id FROM videos WHERE channel_id = ?1)",
            params![id],
        )
        .map_err(|e| e.to_string())?;
        tx.execute("DELETE FROM videos WHERE channel_id = ?1", params![id])
            .map_err(|e| e.to_string())?;
        tx.execute("DELETE FROM channels WHERE id = ?1", params![id])
            .map_err(|e| e.to_string())?;
        tx.commit().map_err(|e| e.to_string())
    }

    /// True when a channel row exists at all, subscription or not.
    pub fn has_any_channel(&self, id: &str) -> Result<bool, String> {
        self.conn
            .query_row("SELECT 1 FROM channels WHERE id = ?1", params![id], |_| Ok(()))
            .map(|_| true)
            .or_else(|e| match e {
                rusqlite::Error::QueryReturnedNoRows => Ok(false),
                other => Err(other),
            })
            .map_err(|e| e.to_string())
    }

    /// True when a channel is already a subscription. A bare row saved for a
    /// one-off video does not count: subscribing to it is a real change.
    pub fn is_subscribed(&self, id: &str) -> Result<bool, String> {
        self.conn
            .query_row(
                "SELECT 1 FROM channels WHERE id = ?1 AND subscribed = 1",
                params![id],
                |_| Ok(()),
            )
            .map(|_| true)
            .or_else(|e| match e {
                rusqlite::Error::QueryReturnedNoRows => Ok(false),
                other => Err(other),
            })
            .map_err(|e| e.to_string())
    }

    pub fn upsert_channels(&mut self, channels: &[Channel]) -> Result<usize, String> {
        let tx = self.conn.transaction().map_err(|e| e.to_string())?;
        {
            let mut stmt = tx
                .prepare(
                    "INSERT INTO channels (id, title, url, added_at, subscribed)
                     VALUES (?1, ?2, ?3, ?4, 1)
                     ON CONFLICT(id) DO UPDATE SET
                        title = excluded.title,
                        url = excluded.url,
                        -- A channel saved from the menu bar exists as a bare
                        -- row; subscribing to it now is a promotion, not a
                        -- duplicate.
                        subscribed = 1",
                )
                .map_err(|e| e.to_string())?;
            let ts = now();
            for c in channels {
                stmt.execute(params![c.id, c.title, c.url, ts])
                    .map_err(|e| e.to_string())?;
            }
        }
        tx.commit().map_err(|e| e.to_string())?;
        Ok(channels.len())
    }

    pub fn list_channels(&self) -> Result<Vec<ChannelWithCount>, String> {
        let mut stmt = self
            .conn
            .prepare(
                "SELECT c.id, c.title, c.url,
                        (SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id),
                        (SELECT COUNT(*) FROM videos v
                           JOIN downloads d ON d.video_id = v.id
                          WHERE v.channel_id = c.id AND d.state = 'done'),
                        c.last_error
                   FROM channels c
                  WHERE c.subscribed = 1
                  ORDER BY c.title COLLATE NOCASE ASC",
            )
            .map_err(|e| e.to_string())?;

        let rows = stmt
            .query_map([], |r| {
                Ok(ChannelWithCount {
                    id: r.get(0)?,
                    title: r.get(1)?,
                    url: r.get(2)?,
                    video_count: r.get(3)?,
                    downloaded_count: r.get(4)?,
                    last_error: r.get(5)?,
                })
            })
            .map_err(|e| e.to_string())?;

        rows.collect::<Result<Vec<_>, _>>()
            .map_err(|e| e.to_string())
    }

    pub fn channel_ids(&self) -> Result<Vec<String>, String> {
        let mut stmt = self
            .conn
            .prepare("SELECT id FROM channels WHERE subscribed = 1")
            .map_err(|e| e.to_string())?;
        let rows = stmt
            .query_map([], |r| r.get::<_, String>(0))
            .map_err(|e| e.to_string())?;
        rows.collect::<Result<Vec<_>, _>>()
            .map_err(|e| e.to_string())
    }

    /// Upserts video metadata only. Deliberately never touches the `downloads`
    /// table, so a refresh cannot lose the record of what has been downloaded.
    /// `thumb_path` is preserved via COALESCE for the same reason.
    /// How many bare rows, and their videos, a replace would sweep.
    fn orphan_counts(&self) -> Result<(i64, i64), String> {
        let channels: i64 = self
            .conn
            .query_row(ORPHAN_COUNT_SQL, [], |r| r.get(0))
            .map_err(|e| e.to_string())?;
        let videos: i64 = self
            .conn
            .query_row(ORPHAN_VIDEO_COUNT_SQL, [], |r| r.get(0))
            .map_err(|e| e.to_string())?;
        Ok((channels, videos))
    }

    /// Drops channel rows that are not subscriptions and have nothing left to
    /// hold on to.
    ///
    /// A bare row exists only to parent a video saved on its own from the menu
    /// bar. Once that video is no longer on disk — or on its way there — the
    /// row is residue, and it is confusing residue: the channel is not in the
    /// sidebar, yet its video still sits in the feed after everything else has
    /// been removed.
    pub fn prune_orphan_channels(&mut self) -> Result<usize, String> {
        let tx = self.conn.transaction().map_err(|e| e.to_string())?;
        tx.execute_batch(
            &format!(
                "CREATE TEMP VIEW IF NOT EXISTS orphans AS
                     SELECT c.id FROM channels c WHERE {ORPHAN_WHERE};
             DELETE FROM downloads WHERE video_id IN (
                 SELECT id FROM videos WHERE channel_id IN (SELECT id FROM orphans));
             DELETE FROM playback WHERE video_id IN (
                 SELECT id FROM videos WHERE channel_id IN (SELECT id FROM orphans));
             DELETE FROM videos WHERE channel_id IN (SELECT id FROM orphans);"
            ),
        )
        .map_err(|e| e.to_string())?;
        let n = tx
            .execute("DELETE FROM channels WHERE id IN (SELECT id FROM orphans)", [])
            .map_err(|e| e.to_string())?;
        tx.execute_batch("DROP VIEW orphans;").map_err(|e| e.to_string())?;
        tx.commit().map_err(|e| e.to_string())?;
        Ok(n)
    }

    /// Records a channel without subscribing to it, for a video saved on its
    /// own. An existing subscription is left exactly as it is.
    pub fn ensure_channel(&self, c: &Channel) -> Result<(), String> {
        self.conn
            .execute(
                "INSERT INTO channels (id, title, url, added_at, subscribed)
                 VALUES (?1, ?2, ?3, ?4, 0)
                 ON CONFLICT(id) DO UPDATE SET title = excluded.title",
                params![c.id, c.title, c.url, now()],
            )
            .map_err(|e| e.to_string())?;
        Ok(())
    }

    pub fn upsert_videos(&mut self, videos: &[Video]) -> Result<usize, String> {
        let tx = self.conn.transaction().map_err(|e| e.to_string())?;
        {
            let mut stmt = tx
                .prepare(
                    "INSERT INTO videos
                        (id, channel_id, title, description, published, thumb_url, views, is_short, fetched_at)
                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
                     ON CONFLICT(id) DO UPDATE SET
                        channel_id  = excluded.channel_id,
                        title       = excluded.title,
                        description = excluded.description,
                        published   = excluded.published,
                        thumb_url   = excluded.thumb_url,
                        views       = excluded.views,
                        is_short    = excluded.is_short,
                        fetched_at  = excluded.fetched_at",
                )
                .map_err(|e| e.to_string())?;
            let ts = now();
            for v in videos {
                stmt.execute(params![
                    v.id,
                    v.channel_id,
                    v.title,
                    v.description,
                    v.published,
                    v.thumb_url,
                    v.views,
                    v.is_short as i64,
                    ts
                ])
                .map_err(|e| e.to_string())?;
            }
        }
        tx.commit().map_err(|e| e.to_string())?;
        Ok(videos.len())
    }

    pub fn set_thumb_path(&self, video_id: &str, path: &str) -> Result<(), String> {
        self.conn
            .execute(
                "UPDATE videos SET thumb_path = ?2 WHERE id = ?1",
                params![video_id, path],
            )
            .map_err(|e| e.to_string())?;
        Ok(())
    }

    /// Videos that still need a thumbnail cached locally.
    pub fn videos_missing_thumbs(&self, limit: i64) -> Result<Vec<(String, String)>, String> {
        let mut stmt = self
            .conn
            .prepare(
                "SELECT id, thumb_url FROM videos
                  WHERE thumb_path IS NULL AND thumb_url <> ''
                  ORDER BY published DESC LIMIT ?1",
            )
            .map_err(|e| e.to_string())?;
        let rows = stmt
            .query_map(params![limit], |r| Ok((r.get(0)?, r.get(1)?)))
            .map_err(|e| e.to_string())?;
        rows.collect::<Result<Vec<_>, _>>()
            .map_err(|e| e.to_string())
    }

    pub fn list_feed(&self, f: &FeedFilter) -> Result<Vec<FeedItem>, String> {
        let mut sql = String::from(
            "SELECT v.id, v.channel_id, COALESCE(c.title, ''), v.title, v.description,
                    v.published, v.thumb_url, v.thumb_path, v.views, v.is_short,
                    d.state, d.path, d.pct, d.error, p.position,
                    COALESCE(v.duration, p.duration)
               FROM videos v
               LEFT JOIN channels c  ON c.id = v.channel_id
               LEFT JOIN downloads d ON d.video_id = v.id
               LEFT JOIN playback  p ON p.video_id = v.id
              WHERE 1=1",
        );
        let mut args: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();

        if let Some(cid) = f.channel_id.as_ref().filter(|s| !s.is_empty()) {
            sql.push_str(" AND v.channel_id = ?");
            args.push(Box::new(cid.clone()));
        }
        if let Some(q) = f.search.as_ref().filter(|s| !s.trim().is_empty()) {
            sql.push_str(" AND (v.title LIKE ? COLLATE NOCASE OR c.title LIKE ? COLLATE NOCASE)");
            let pat = format!("%{}%", q.trim());
            args.push(Box::new(pat.clone()));
            args.push(Box::new(pat));
        }
        if f.downloaded_only {
            // Queued and running count: a download you started should not
            // vanish from the very list you are watching it in.
            sql.push_str(" AND d.state IN ('done','queued','running')");
        }
        if f.hide_shorts {
            sql.push_str(" AND v.is_short = 0");
        }
        sql.push_str(" ORDER BY v.published DESC");
        if let Some(l) = f.limit {
            sql.push_str(" LIMIT ?");
            args.push(Box::new(l));
        }

        let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
        let refs: Vec<&dyn rusqlite::ToSql> = args.iter().map(|b| b.as_ref()).collect();

        let rows = stmt
            .query_map(refs.as_slice(), |r| {
                let state: Option<String> = r.get(10)?;
                Ok(FeedItem {
                    id: r.get(0)?,
                    channel_id: r.get(1)?,
                    channel_title: r.get(2)?,
                    title: r.get(3)?,
                    description: r.get(4)?,
                    published: r.get(5)?,
                    thumb_url: r.get(6)?,
                    thumb_path: r.get(7)?,
                    views: r.get(8)?,
                    is_short: r.get::<_, i64>(9)? != 0,
                    state: state.as_deref().and_then(DownloadState::from_str),
                    path: r.get(11)?,
                    pct: r.get(12)?,
                    error: r.get(13)?,
                    position: r.get(14)?,
                    duration: r.get(15)?,
                })
            })
            .map_err(|e| e.to_string())?;

        rows.collect::<Result<Vec<_>, _>>()
            .map_err(|e| e.to_string())
    }

    /// Remembers where playback got to, so the feed can show a progress bar and
    /// reopening a video can resume it.
    pub fn save_playback(&self, video_id: &str, position: f64, duration: f64) -> Result<(), String> {
        if !(position.is_finite() && duration.is_finite()) || duration <= 0.0 {
            return Ok(());
        }
        self.conn
            .execute(
                "INSERT INTO playback (video_id, position, duration, updated_at)
                 VALUES (?1, ?2, ?3, ?4)
                 ON CONFLICT(video_id) DO UPDATE SET
                    position = excluded.position,
                    duration = excluded.duration,
                    updated_at = excluded.updated_at",
                params![video_id, position.max(0.0), duration, now()],
            )
            .map_err(|e| e.to_string())?;
        Ok(())
    }

    /// The quality and subtitle language a download was started with, so an
    /// interrupted one resumes as it was asked for rather than as the settings
    /// happen to read now.
    pub fn set_download_request(
        &self,
        video_id: &str,
        quality: &str,
        sub_lang: &str,
    ) -> Result<(), String> {
        self.conn
            .execute(
                "UPDATE downloads SET quality = ?2, sub_lang = ?3 WHERE video_id = ?1",
                params![video_id, quality, sub_lang],
            )
            .map_err(|e| e.to_string())?;
        Ok(())
    }

    /// Downloads left unfinished, as (video_id, quality, sub_lang). Killing the
    /// app leaves rows queued or running with no process behind them.
    pub fn interrupted_downloads(&self) -> Result<Vec<(String, String, String)>, String> {
        let mut stmt = self
            .conn
            .prepare(
                "SELECT video_id, COALESCE(quality, ''), COALESCE(sub_lang, '')
                 FROM downloads WHERE state IN ('queued','running')",
            )
            .map_err(|e| e.to_string())?;
        let rows = stmt
            .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
            .map_err(|e| e.to_string())?;
        rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
    }

    pub fn set_download_state(
        &self,
        video_id: &str,
        state: DownloadState,
        error: Option<&str>,
    ) -> Result<(), String> {
        let completed = matches!(state, DownloadState::Done).then(now);
        self.conn
            .execute(
                "INSERT INTO downloads (video_id, state, error, completed_at)
                 VALUES (?1, ?2, ?3, ?4)
                 ON CONFLICT(video_id) DO UPDATE SET
                    state = excluded.state,
                    error = excluded.error,
                    completed_at = COALESCE(excluded.completed_at, downloads.completed_at)",
                params![video_id, state.as_str(), error, completed],
            )
            .map_err(|e| e.to_string())?;
        Ok(())
    }

    pub fn set_download_progress(
        &self,
        video_id: &str,
        done: u64,
        total: Option<u64>,
        pct: Option<f64>,
    ) -> Result<(), String> {
        self.conn
            .execute(
                "UPDATE downloads SET bytes_done = ?2, bytes_total = ?3, pct = ?4
                  WHERE video_id = ?1",
                params![
                    video_id,
                    done as i64,
                    total.map(|t| t as i64),
                    pct
                ],
            )
            .map_err(|e| e.to_string())?;
        Ok(())
    }

    pub fn set_download_path(&self, video_id: &str, path: &str) -> Result<(), String> {
        self.conn
            .execute(
                "UPDATE downloads SET path = ?2 WHERE video_id = ?1",
                params![video_id, path],
            )
            .map_err(|e| e.to_string())?;
        Ok(())
    }

    /// The recorded state, or None if the video was never queued.
    pub fn download_state(&self, video_id: &str) -> Result<Option<String>, String> {
        self.conn
            .query_row(
                "SELECT state FROM downloads WHERE video_id = ?1",
                params![video_id],
                |r| r.get::<_, String>(0),
            )
            .map(Some)
            .or_else(|e| match e {
                rusqlite::Error::QueryReturnedNoRows => Ok(None),
                other => Err(other),
            })
            .map_err(|e| e.to_string())
    }

    pub fn get_download_path(&self, video_id: &str) -> Result<Option<String>, String> {
        self.conn
            .query_row(
                "SELECT path FROM downloads WHERE video_id = ?1",
                params![video_id],
                |r| r.get::<_, Option<String>>(0),
            )
            .or_else(|e| match e {
                rusqlite::Error::QueryReturnedNoRows => Ok(None),
                other => Err(other),
            })
            .map_err(|e| e.to_string())
    }

    /// Paths of every completed download, for deleting them all at once.
    pub fn all_download_paths(&self) -> Result<Vec<String>, String> {
        let mut stmt = self
            .conn
            .prepare("SELECT path FROM downloads WHERE path IS NOT NULL")
            .map_err(|e| e.to_string())?;
        let rows = stmt
            .query_map([], |r| r.get::<_, String>(0))
            .map_err(|e| e.to_string())?;
        rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
    }

    /// Videos the database still considers in flight. Includes any left
    /// "running" by a crash, which no live process backs any more.
    pub fn active_downloads(&self) -> Result<Vec<String>, String> {
        let mut stmt = self
            .conn
            .prepare("SELECT video_id FROM downloads WHERE state IN ('queued','running')")
            .map_err(|e| e.to_string())?;
        let rows = stmt
            .query_map([], |r| r.get::<_, String>(0))
            .map_err(|e| e.to_string())?;
        rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
    }

    pub fn clear_all_downloads(&self) -> Result<usize, String> {
        let n = self
            .conn
            .execute("DELETE FROM downloads", [])
            .map_err(|e| e.to_string())?;
        Ok(n)
    }

    pub fn clear_download(&self, video_id: &str) -> Result<(), String> {
        self.conn
            .execute("DELETE FROM downloads WHERE video_id = ?1", params![video_id])
            .map_err(|e| e.to_string())?;
        Ok(())
    }

    pub fn video_title(&self, video_id: &str) -> Result<String, String> {
        self.conn
            .query_row(
                "SELECT title FROM videos WHERE id = ?1",
                params![video_id],
                |r| r.get(0),
            )
            .map_err(|e| e.to_string())
    }
}

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

    fn vid(id: &str, chan: &str, published: i64, short: bool) -> Video {
        Video {
            id: id.into(),
            channel_id: chan.into(),
            title: format!("Title {id}"),
            description: "d".into(),
            published,
            thumb_url: format!("https://i.ytimg.com/vi/{id}/hq.jpg"),
            views: 10,
            is_short: short,
        }
    }

    fn seeded() -> Db {
        let mut db = Db::open_in_memory().unwrap();
        db.upsert_channels(&[
            Channel { id: "UC1".into(), title: "Alpha".into(), url: "u1".into() },
            Channel { id: "UC2".into(), title: "Beta".into(), url: "u2".into() },
        ])
        .unwrap();
        db.upsert_videos(&[
            vid("a", "UC1", 100, false),
            vid("b", "UC2", 300, false),
            vid("c", "UC1", 200, true),
        ])
        .unwrap();
        db
    }

    #[test]
    fn playback_position_surfaces_on_the_feed() {
        let db = seeded();
        db.save_playback("a", 30.0, 120.0).unwrap();
        let feed = db.list_feed(&FeedFilter::default()).unwrap();
        let a = feed.iter().find(|f| f.id == "a").unwrap();
        assert_eq!(a.position, Some(30.0));
        assert_eq!(a.duration, Some(120.0));
        // Videos never opened have no bar to draw.
        let b = feed.iter().find(|f| f.id == "b").unwrap();
        assert_eq!(b.position, None);
    }

    #[test]
    fn saving_playback_twice_updates_in_place() {
        let db = seeded();
        db.save_playback("a", 10.0, 120.0).unwrap();
        db.save_playback("a", 90.0, 120.0).unwrap();
        let feed = db.list_feed(&FeedFilter::default()).unwrap();
        assert_eq!(feed.iter().find(|f| f.id == "a").unwrap().position, Some(90.0));
    }

    #[test]
    fn nonsense_playback_values_are_ignored() {
        let db = seeded();
        db.save_playback("a", f64::NAN, 120.0).unwrap();
        db.save_playback("a", 5.0, 0.0).unwrap();
        db.save_playback("a", 5.0, f64::INFINITY).unwrap();
        let feed = db.list_feed(&FeedFilter::default()).unwrap();
        assert_eq!(feed.iter().find(|f| f.id == "a").unwrap().position, None);
    }

    #[test]
    fn replacing_drops_playback_of_removed_channels() {
        let mut db = seeded();
        db.save_playback("a", 10.0, 120.0).unwrap();
        db.save_playback("b", 10.0, 120.0).unwrap();
        db.replace_channels(&[Channel { id: "UC1".into(), title: "Alpha".into(), url: "u1".into() }])
            .unwrap();
        let feed = db.list_feed(&FeedFilter::default()).unwrap();
        assert!(feed.iter().all(|f| f.id != "b"));
        assert_eq!(feed.iter().find(|f| f.id == "a").unwrap().position, Some(10.0));
    }

    #[test]
    fn replacing_drops_channels_absent_from_the_new_csv() {
        let mut db = seeded();
        db.set_download_state("b", DownloadState::Done, None).unwrap();

        // New CSV contains only UC1; UC2 (and its downloaded video "b") must go.
        db.replace_channels(&[Channel {
            id: "UC1".into(),
            title: "Alpha".into(),
            url: "u1".into(),
        }])
        .unwrap();

        let chans = db.list_channels().unwrap();
        assert_eq!(chans.len(), 1);
        assert_eq!(chans[0].id, "UC1");

        let feed = db.list_feed(&FeedFilter::default()).unwrap();
        assert!(feed.iter().all(|f| f.channel_id == "UC1"));
        assert!(feed.iter().all(|f| f.id != "b"), "video of dropped channel must go");
    }

    #[test]
    fn replacing_keeps_download_state_for_surviving_channels() {
        let mut db = seeded();
        db.set_download_state("a", DownloadState::Done, None).unwrap();
        db.set_download_path("a", "/movies/a.mp4").unwrap();

        db.replace_channels(&[Channel {
            id: "UC1".into(),
            title: "Alpha renamed".into(),
            url: "u1".into(),
        }])
        .unwrap();

        let feed = db.list_feed(&FeedFilter::default()).unwrap();
        let a = feed.iter().find(|f| f.id == "a").expect("surviving video kept");
        assert_eq!(a.state, Some(DownloadState::Done));
        assert_eq!(a.path.as_deref(), Some("/movies/a.mp4"));
        assert_eq!(a.channel_title, "Alpha renamed");
    }

    #[test]
    fn preview_counts_what_replacing_would_remove() {
        let mut db = seeded();
        db.set_download_state("b", DownloadState::Done, None).unwrap();

        let incoming = vec![Channel { id: "UC1".into(), title: "Alpha".into(), url: "u".into() }];
        let p = db.preview_replace(&incoming).unwrap();
        assert_eq!(p.incoming, 1);
        assert_eq!(p.removed_channels, 1);
        assert_eq!(p.removed_videos, 1);
        assert_eq!(p.removed_downloads, 1);
    }

    #[test]
    fn preview_removes_nothing_when_csv_is_a_superset() {
        let mut db = seeded();
        let incoming = vec![
            Channel { id: "UC1".into(), title: "Alpha".into(), url: "u".into() },
            Channel { id: "UC2".into(), title: "Beta".into(), url: "u".into() },
            Channel { id: "UC3".into(), title: "Gamma".into(), url: "u".into() },
        ];
        let p = db.preview_replace(&incoming).unwrap();
        assert_eq!(p.removed_channels, 0);
        assert_eq!(p.removed_videos, 0);
        assert_eq!(p.removed_downloads, 0);
    }

    #[test]
    fn dropped_paths_lists_only_files_of_removed_channels() {
        let mut db = seeded();
        db.set_download_state("a", DownloadState::Done, None).unwrap();
        db.set_download_path("a", "/movies/a.mp4").unwrap();
        db.set_download_state("b", DownloadState::Done, None).unwrap();
        db.set_download_path("b", "/movies/b.mp4").unwrap();

        let incoming = vec![Channel { id: "UC1".into(), title: "Alpha".into(), url: "u".into() }];
        let paths = db.paths_dropped_by_replace(&incoming).unwrap();
        assert_eq!(paths, vec!["/movies/b.mp4".to_string()]);
    }

    #[test]
    fn upserting_same_channel_twice_yields_one_row() {
        let mut db = Db::open_in_memory().unwrap();
        let c = Channel { id: "UC1".into(), title: "First".into(), url: "u".into() };
        db.upsert_channels(&[c.clone()]).unwrap();
        db.upsert_channels(&[Channel { title: "Renamed".into(), ..c }]).unwrap();

        let all = db.list_channels().unwrap();
        assert_eq!(all.len(), 1);
        assert_eq!(all[0].title, "Renamed");
    }

    #[test]
    fn refresh_preserves_download_state() {
        let mut db = seeded();
        db.set_download_state("a", DownloadState::Done, None).unwrap();
        db.set_download_path("a", "/movies/a.mp4").unwrap();

        // Simulate a later refresh returning updated metadata for the same video.
        let mut updated = vid("a", "UC1", 100, false);
        updated.title = "Retitled".into();
        updated.views = 999;
        db.upsert_videos(&[updated]).unwrap();

        let feed = db.list_feed(&FeedFilter::default()).unwrap();
        let a = feed.iter().find(|f| f.id == "a").unwrap();
        assert_eq!(a.title, "Retitled", "metadata should update");
        assert_eq!(a.views, 999);
        assert_eq!(a.state, Some(DownloadState::Done), "download state must survive refresh");
        assert_eq!(a.path.as_deref(), Some("/movies/a.mp4"));
    }

    #[test]
    fn feed_is_sorted_newest_first() {
        let db = seeded();
        let feed = db.list_feed(&FeedFilter::default()).unwrap();
        let order: Vec<&str> = feed.iter().map(|f| f.id.as_str()).collect();
        assert_eq!(order, vec!["b", "c", "a"]);
    }

    #[test]
    fn feed_joins_channel_title() {
        let db = seeded();
        let feed = db.list_feed(&FeedFilter::default()).unwrap();
        let b = feed.iter().find(|f| f.id == "b").unwrap();
        assert_eq!(b.channel_title, "Beta");
    }

    #[test]
    fn downloaded_only_filter_excludes_undownloaded() {
        let db = seeded();
        db.set_download_state("b", DownloadState::Done, None).unwrap();
        // A failed download must not count as available offline.
        db.set_download_state("a", DownloadState::Failed, Some("boom")).unwrap();

        let feed = db
            .list_feed(&FeedFilter { downloaded_only: true, ..Default::default() })
            .unwrap();
        assert_eq!(feed.len(), 1);
        assert_eq!(feed[0].id, "b");
    }

    #[test]
    fn only_the_visible_videos_without_a_length_are_queued() {
        let db = seeded();
        db.set_duration("b", 120).unwrap();
        // Order follows what was asked for, so the top of the screen fills first.
        let want = vec!["c".to_string(), "b".to_string(), "a".to_string()];
        assert_eq!(
            db.filter_missing_duration(&want, 10).unwrap(),
            vec!["c".to_string(), "a".to_string()]
        );
        // Unknown ids are simply skipped rather than queued forever.
        assert!(db
            .filter_missing_duration(&["nope".to_string()], 10)
            .unwrap()
            .is_empty());
        assert_eq!(db.filter_missing_duration(&want, 1).unwrap().len(), 1);
    }

    #[test]
    fn a_channel_failure_is_remembered_and_can_be_cleared() {
        let db = seeded();
        assert!(db.list_channels().unwrap().iter().all(|c| c.last_error.is_none()));

        db.set_channel_result("UC1", Some("Feed returned HTTP 404")).unwrap();
        let failed = db.list_channels().unwrap();
        let alpha = failed.iter().find(|c| c.id == "UC1").unwrap();
        assert_eq!(alpha.last_error.as_deref(), Some("Feed returned HTTP 404"));
        // Other channels are untouched.
        assert!(failed.iter().find(|c| c.id == "UC2").unwrap().last_error.is_none());

        db.set_channel_result("UC1", None).unwrap();
        assert!(db
            .list_channels()
            .unwrap()
            .iter()
            .all(|c| c.last_error.is_none()));
    }

    #[test]
    fn a_recorded_duration_reaches_the_feed() {
        let db = seeded();
        db.set_duration("a", 754).unwrap();
        let feed = db.list_feed(&FeedFilter::default()).unwrap();
        assert_eq!(feed.iter().find(|f| f.id == "a").unwrap().duration, Some(754.0));
    }

    #[test]
    fn playback_duration_fills_in_when_the_video_length_is_unknown() {
        let db = seeded();
        db.save_playback("b", 10.0, 300.0).unwrap();
        let feed = db.list_feed(&FeedFilter::default()).unwrap();
        assert_eq!(feed.iter().find(|f| f.id == "b").unwrap().duration, Some(300.0));
    }

    #[test]
    fn videos_without_a_duration_are_reported_for_lookup() {
        let db = seeded();
        assert_eq!(db.videos_missing_duration(10).unwrap().len(), 3);
        db.set_duration("a", 100).unwrap();
        assert_eq!(db.videos_missing_duration(10).unwrap().len(), 2);
        // A nonsense length is ignored rather than stored.
        db.set_duration("b", 0).unwrap();
        assert_eq!(db.videos_missing_duration(10).unwrap().len(), 2);
    }

    #[test]
    fn downloads_in_progress_still_show_in_the_downloaded_filter() {
        let db = seeded();
        db.set_download_state("a", DownloadState::Running, None).unwrap();
        db.set_download_state("c", DownloadState::Queued, None).unwrap();
        db.set_download_state("b", DownloadState::Failed, Some("x")).unwrap();

        let feed = db
            .list_feed(&FeedFilter { downloaded_only: true, ..Default::default() })
            .unwrap();
        let ids: Vec<&str> = feed.iter().map(|f| f.id.as_str()).collect();
        assert!(ids.contains(&"a"), "running should be listed");
        assert!(ids.contains(&"c"), "queued should be listed");
        assert!(!ids.contains(&"b"), "failed should not be");
    }

    #[test]
    fn clearing_all_downloads_empties_the_filter() {
        let db = seeded();
        db.set_download_state("a", DownloadState::Done, None).unwrap();
        db.set_download_path("a", "/movies/a.mp4").unwrap();
        db.set_download_state("b", DownloadState::Done, None).unwrap();
        assert_eq!(db.all_download_paths().unwrap(), vec!["/movies/a.mp4".to_string()]);
        db.clear_all_downloads().unwrap();
        assert!(db
            .list_feed(&FeedFilter { downloaded_only: true, ..Default::default() })
            .unwrap()
            .is_empty());
    }

    #[test]
    fn hide_shorts_filter_excludes_shorts() {
        let db = seeded();
        let feed = db
            .list_feed(&FeedFilter { hide_shorts: true, ..Default::default() })
            .unwrap();
        assert_eq!(feed.len(), 2);
        assert!(feed.iter().all(|f| !f.is_short));
    }

    #[test]
    fn channel_filter_scopes_to_one_channel() {
        let db = seeded();
        let feed = db
            .list_feed(&FeedFilter { channel_id: Some("UC1".into()), ..Default::default() })
            .unwrap();
        assert_eq!(feed.len(), 2);
        assert!(feed.iter().all(|f| f.channel_id == "UC1"));
    }

    #[test]
    fn search_matches_title_case_insensitively() {
        let db = seeded();
        let feed = db
            .list_feed(&FeedFilter { search: Some("title B".into()), ..Default::default() })
            .unwrap();
        assert_eq!(feed.len(), 1);
        assert_eq!(feed[0].id, "b");
    }

    #[test]
    fn search_also_matches_channel_name() {
        let db = seeded();
        let feed = db
            .list_feed(&FeedFilter { search: Some("beta".into()), ..Default::default() })
            .unwrap();
        assert_eq!(feed.len(), 1);
        assert_eq!(feed[0].id, "b");
    }

    #[test]
    fn channel_list_reports_video_and_download_counts() {
        let mut db = seeded();
        db.set_download_state("a", DownloadState::Done, None).unwrap();
        let chans = db.list_channels().unwrap();
        let alpha = chans.iter().find(|c| c.id == "UC1").unwrap();
        assert_eq!(alpha.video_count, 2);
        assert_eq!(alpha.downloaded_count, 1);
    }

    #[test]
    fn progress_updates_are_recorded() {
        let db = seeded();
        db.set_download_state("a", DownloadState::Running, None).unwrap();
        db.set_download_progress("a", 50, Some(100), Some(50.0)).unwrap();
        let feed = db.list_feed(&FeedFilter::default()).unwrap();
        let a = feed.iter().find(|f| f.id == "a").unwrap();
        assert_eq!(a.pct, Some(50.0));
        assert_eq!(a.state, Some(DownloadState::Running));
    }

    #[test]
    fn download_state_is_readable_and_absent_until_queued() {
        let db = seeded();
        assert_eq!(db.download_state("a").unwrap(), None);
        db.set_download_state("a", DownloadState::Queued, None).unwrap();
        assert_eq!(db.download_state("a").unwrap().as_deref(), Some("queued"));
        // What a waiting download checks before it claims a slot.
        db.set_download_state("a", DownloadState::Cancelled, None).unwrap();
        assert_ne!(
            db.download_state("a").unwrap().as_deref(),
            Some(DownloadState::Queued.as_str())
        );
    }

    #[test]
    fn a_bare_channel_row_is_not_a_subscription() {
        let mut db = seeded();
        let one_off = Channel {
            id: "UCX".into(),
            title: "Someone Else".into(),
            url: "https://youtube.com/channel/UCX".into(),
        };
        db.ensure_channel(&one_off).unwrap();
        // It exists as a parent for the video, but it is not in the sidebar and
        // refreshing does not go looking for it.
        assert!(!db.is_subscribed("UCX").unwrap());
        assert!(!db.list_channels().unwrap().iter().any(|c| c.id == "UCX"));
        assert!(!db.channel_ids().unwrap().contains(&"UCX".to_string()));

        // Importing a CSV, which replaces subscriptions, leaves it alone while
        // it still has a downloaded video to hold on to.
        db.upsert_videos(&[Video {
            id: "x1".into(),
            channel_id: "UCX".into(),
            title: "A one-off".into(),
            description: String::new(),
            published: 10,
            thumb_url: String::new(),
            views: 0,
            is_short: false,
        }])
        .unwrap();
        db.set_download_state("x1", DownloadState::Done, None).unwrap();
        let incoming = vec![Channel {
            id: "UC1".into(),
            title: "Alpha".into(),
            url: "https://youtube.com/channel/UC1".into(),
        }];
        db.replace_channels(&incoming).unwrap();
        assert!(db.has_any_channel("UCX").unwrap());

        // Subscribing to it later promotes the row rather than being refused
        // as a duplicate — saving a video is not subscribing.
        db.upsert_channels(&[one_off.clone()]).unwrap();
        assert!(db.is_subscribed("UCX").unwrap());
        assert!(db.list_channels().unwrap().iter().any(|c| c.id == "UCX"));
    }

    #[test]
    fn a_bare_row_with_nothing_downloaded_is_swept_by_a_replace() {
        let mut db = seeded();
        let one_off = Channel {
            id: "UCX".into(),
            title: "Saved Once".into(),
            url: "https://youtube.com/channel/UCX".into(),
        };
        db.ensure_channel(&one_off).unwrap();
        db.upsert_videos(&[Video {
            id: "x1".into(),
            channel_id: "UCX".into(),
            title: "A one-off".into(),
            description: String::new(),
            published: 10,
            thumb_url: String::new(),
            views: 0,
            is_short: false,
        }])
        .unwrap();

        // Nothing downloaded: emptying the list takes it too, or its video
        // would sit in the feed with no channel behind it.
        db.replace_channels(&[]).unwrap();
        assert!(!db.has_any_channel("UCX").unwrap());
        assert!(db.list_feed(&FeedFilter::default()).unwrap().is_empty());
    }

    #[test]
    fn the_preview_counts_what_the_sweep_will_take() {
        let mut db = seeded();
        db.ensure_channel(&Channel {
            id: "UCX".into(),
            title: "Saved Once".into(),
            url: "https://youtube.com/channel/UCX".into(),
        })
        .unwrap();
        db.upsert_videos(&[Video {
            id: "x1".into(),
            channel_id: "UCX".into(),
            title: "A one-off".into(),
            description: String::new(),
            published: 10,
            thumb_url: String::new(),
            views: 0,
            is_short: false,
        }])
        .unwrap();

        // Emptying the list takes both subscriptions and this bare row, and
        // the confirmation has to say so before it happens.
        let p = db.preview_replace(&[]).unwrap();
        assert_eq!(p.removed_channels, 3, "two subscriptions and the bare row");
        assert_eq!(p.removed_videos, 4, "their videos and the one-off");

        db.replace_channels(&[]).unwrap();
        assert!(!db.has_any_channel("UCX").unwrap());
    }

    #[test]
    fn a_bare_row_whose_video_is_downloaded_survives() {
        let mut db = seeded();
        let one_off = Channel {
            id: "UCX".into(),
            title: "Saved Once".into(),
            url: "https://youtube.com/channel/UCX".into(),
        };
        db.ensure_channel(&one_off).unwrap();
        db.upsert_videos(&[Video {
            id: "x1".into(),
            channel_id: "UCX".into(),
            title: "A one-off".into(),
            description: String::new(),
            published: 10,
            thumb_url: String::new(),
            views: 0,
            is_short: false,
        }])
        .unwrap();
        db.set_download_state("x1", DownloadState::Done, None).unwrap();

        // Deliberately saved and still on disk: not this function's to delete.
        db.replace_channels(&[]).unwrap();
        assert!(db.has_any_channel("UCX").unwrap());
    }

    #[test]
    fn deleting_a_subscription_takes_its_videos_with_it() {
        let mut db = seeded();
        db.set_download_state("a", DownloadState::Done, None).unwrap();
        db.set_download_path("a", "/tmp/a.mp4").unwrap();
        let (videos, paths) = db.channel_removal("UC1").unwrap();
        assert_eq!(videos, 2);
        assert_eq!(paths, vec!["/tmp/a.mp4".to_string()]);

        db.delete_channel("UC1").unwrap();
        assert!(!db.is_subscribed("UC1").unwrap());
        assert!(db.list_feed(&FeedFilter::default()).unwrap().iter().all(|f| f.id != "a"));
    }

    #[test]
    fn active_downloads_are_the_queued_and_running_ones() {
        let db = seeded();
        db.set_download_state("a", DownloadState::Running, None).unwrap();
        db.set_download_state("b", DownloadState::Queued, None).unwrap();
        db.set_download_state("c", DownloadState::Done, None).unwrap();
        let mut active = db.active_downloads().unwrap();
        active.sort();
        assert_eq!(active, vec!["a".to_string(), "b".to_string()]);
        // Stopping them takes them out of flight without deleting anything.
        db.set_download_state("a", DownloadState::Cancelled, None).unwrap();
        db.set_download_state("b", DownloadState::Cancelled, None).unwrap();
        assert!(db.active_downloads().unwrap().is_empty());
    }

    #[test]
    fn clearing_a_download_makes_it_undownloaded_again() {
        let db = seeded();
        db.set_download_state("a", DownloadState::Done, None).unwrap();
        db.clear_download("a").unwrap();
        let feed = db.list_feed(&FeedFilter::default()).unwrap();
        assert!(feed.iter().find(|f| f.id == "a").unwrap().state.is_none());
    }

    #[test]
    fn thumbs_needing_cache_are_reported_then_cleared() {
        let db = seeded();
        assert_eq!(db.videos_missing_thumbs(10).unwrap().len(), 3);
        db.set_thumb_path("a", "/cache/a.jpg").unwrap();
        assert_eq!(db.videos_missing_thumbs(10).unwrap().len(), 2);
    }
}
