//! Persistent storage for watched status, playback positions, and settings.
//!
//! Backed by a bundled SQLite database (`yt-offline.db`). Access goes through
//! a small `r2d2`-managed pool of connections rather than a single shared
//! `Connection` — that way concurrent read queries from different axum
//! handlers don't serialize on a mutex, and write queries still take their
//! turn via SQLite's own per-connection locking.
//!
//! # Schema
//!
//! | Table | Columns | Purpose |
//! |---|---|---|
//! | `watched` | `video_id` (PK), `watched_at` | Records videos the user has marked watched |
//! | `positions` | `video_id` (PK), `position_secs`, `updated_at` | Stores resume positions |
//! | `settings` | `key` (PK), `value` | Persistent app settings (password hash, etc.) |

use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{Connection, Result};
use std::collections::{HashMap, HashSet};
use std::path::Path;

type Pool = r2d2::Pool<SqliteConnectionManager>;
type PooledConn = r2d2::PooledConnection<SqliteConnectionManager>;

/// Persisted folder row from the `folders` table. Used by the sidebar +
/// folder-management UI; channels reference folders via the separate
/// `channel_assignments` table.
#[derive(Clone, Debug, serde::Serialize)]
pub struct FolderRecord {
    pub id: i64,
    pub name: String,
    pub position: i64,
}

/// In-memory representation of the `video_flags` table. Each set holds the
/// video IDs that have the named flag enabled — kept small (a few hundred
/// to a few thousand entries in practice).
#[derive(Default, Clone, Debug)]
pub struct VideoFlagsBundle {
    pub bookmark: HashSet<String>,
    pub favourite: HashSet<String>,
    pub waiting: HashSet<String>,
    pub archive: HashSet<String>,
}

/// Default pool size for a file-backed database. Small intentionally — the
/// app is single-user and our queries are short. A handful is plenty.
const FILE_POOL_SIZE: u32 = 4;

/// Thin wrapper around an `r2d2` SQLite pool with schema management.
///
/// Construction always returns a pool with at least one usable connection
/// and the schema initialised. Subsequent method calls borrow a connection,
/// run their query, and return it — no external `Mutex` is needed.
pub struct Database {
    pool: Pool,
}

impl Database {
    /// Open or create the database at `path`, running schema migrations.
    ///
    /// On Unix the file mode is tightened to `0600` so the Argon2 password
    /// hash and resume positions aren't readable by other local users. A
    /// best-effort: failure is logged but doesn't abort startup.
    pub fn open(path: &Path) -> Result<Self> {
        let manager = SqliteConnectionManager::file(path);
        let pool = Pool::builder()
            .max_size(FILE_POOL_SIZE)
            .build(manager)
            .map_err(pool_init_to_rusqlite)?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            if let Ok(meta) = std::fs::metadata(path) {
                let mut perms = meta.permissions();
                if perms.mode() & 0o777 != 0o600 {
                    perms.set_mode(0o600);
                    let _ = std::fs::set_permissions(path, perms);
                }
            }
        }

        let db = Database { pool };
        db.init_schema()?;
        Ok(db)
    }

    /// Open an in-memory database — used in tests and as a fallback when the
    /// real file can't be opened.
    ///
    /// In-memory SQLite databases are per-connection by default, so the pool
    /// is capped at 1 connection here. Otherwise each `get()` would hand back
    /// a fresh, empty database and our schema/data would vanish between calls.
    pub fn open_in_memory() -> Result<Self> {
        let manager = SqliteConnectionManager::memory();
        let pool = Pool::builder()
            .max_size(1)
            .build(manager)
            .map_err(pool_init_to_rusqlite)?;
        let db = Database { pool };
        db.init_schema()?;
        Ok(db)
    }

    /// Acquire a connection from the pool. Panics on pool failure — these
    /// are effectively unrecoverable (the SQLite file vanished, the disk is
    /// full / read-only, or the pool is exhausted under runaway load).
    fn conn(&self) -> PooledConn {
        self.pool.get().expect("db pool checkout failed")
    }

    fn init_schema(&self) -> Result<()> {
        self.conn().execute_batch(
            "CREATE TABLE IF NOT EXISTS watched (
                video_id TEXT PRIMARY KEY,
                watched_at DATETIME DEFAULT CURRENT_TIMESTAMP
            );
            CREATE TABLE IF NOT EXISTS positions (
                video_id TEXT PRIMARY KEY,
                position_secs REAL NOT NULL,
                updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
            );
            CREATE TABLE IF NOT EXISTS settings (
                key   TEXT PRIMARY KEY,
                value TEXT NOT NULL
            );
            CREATE TABLE IF NOT EXISTS channel_options (
                platform TEXT NOT NULL,
                handle   TEXT NOT NULL,
                options_json TEXT NOT NULL,
                updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
                PRIMARY KEY (platform, handle)
            );
            CREATE TABLE IF NOT EXISTS video_flags (
                video_id  TEXT PRIMARY KEY,
                bookmark  INTEGER NOT NULL DEFAULT 0,
                favourite INTEGER NOT NULL DEFAULT 0,
                waiting   INTEGER NOT NULL DEFAULT 0,
                archive   INTEGER NOT NULL DEFAULT 0,
                updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
            );
            CREATE TABLE IF NOT EXISTS folders (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL UNIQUE,
                position INTEGER NOT NULL DEFAULT 0,
                created_at DATETIME DEFAULT CURRENT_TIMESTAMP
            );
            CREATE TABLE IF NOT EXISTS channel_assignments (
                platform TEXT NOT NULL,
                handle   TEXT NOT NULL,
                folder_id INTEGER NOT NULL,
                PRIMARY KEY (platform, handle),
                FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE
            );",
        )?;
        Ok(())
    }

    /// Fetch the raw JSON-encoded download-options blob for a channel.
    /// `platform` is the [`crate::platform::Platform::dir_name`] string;
    /// `handle` is the on-disk folder name. Returns `None` when no options
    /// row exists.
    pub fn get_channel_options(&self, platform: &str, handle: &str) -> Result<Option<String>> {
        let conn = self.conn();
        let mut stmt = conn.prepare(
            "SELECT options_json FROM channel_options WHERE platform = ?1 AND handle = ?2",
        )?;
        let mut rows = stmt.query([platform, handle])?;
        Ok(rows.next()?.map(|r| r.get(0)).transpose()?)
    }

    /// Upsert the download-options JSON blob for a channel.
    pub fn set_channel_options(&self, platform: &str, handle: &str, options_json: &str) -> Result<()> {
        let conn = self.conn();
        conn.execute(
            "INSERT OR REPLACE INTO channel_options (platform, handle, options_json, updated_at)
             VALUES (?1, ?2, ?3, CURRENT_TIMESTAMP)",
            [platform, handle, options_json],
        )?;
        Ok(())
    }

    /// Delete a channel's options row, falling its behavior back to global defaults.
    pub fn delete_channel_options(&self, platform: &str, handle: &str) -> Result<()> {
        let conn = self.conn();
        conn.execute(
            "DELETE FROM channel_options WHERE platform = ?1 AND handle = ?2",
            [platform, handle],
        )?;
        Ok(())
    }

    /// Set or clear a single per-video flag. `flag` must be one of
    /// `"bookmark"`, `"favourite"`, `"waiting"`, `"archive"` — the only
    /// columns in `video_flags`. Returns an error if `flag` is unknown so a
    /// typo doesn't silently no-op.
    ///
    /// A row is upserted on first call; once any flag on a video is set,
    /// the row sticks around even after all flags are cleared. This keeps
    /// the schema simple at the cost of a few orphan rows.
    pub fn set_video_flag(&self, video_id: &str, flag: &str, value: bool) -> Result<()> {
        let col = match flag {
            "bookmark" | "favourite" | "waiting" | "archive" => flag,
            _ => return Err(rusqlite::Error::InvalidParameterName(flag.to_string())),
        };
        let conn = self.conn();
        // SQLite doesn't allow parameterised column names; we validated `col`
        // against an allow-list above so direct interpolation is safe.
        let sql = format!(
            "INSERT INTO video_flags (video_id, {col}) VALUES (?1, ?2)
             ON CONFLICT(video_id) DO UPDATE SET {col} = ?2, updated_at = CURRENT_TIMESTAMP"
        );
        conn.execute(&sql, rusqlite::params![video_id, value as i32])?;
        Ok(())
    }

    /// Create a new folder with the given name. Returns the new folder's id.
    /// Trying to insert a duplicate name surfaces the SQLite UNIQUE error.
    pub fn create_folder(&self, name: &str) -> Result<i64> {
        let conn = self.conn();
        conn.execute("INSERT INTO folders (name) VALUES (?1)", [name])?;
        Ok(conn.last_insert_rowid())
    }

    /// Rename an existing folder. No-op when the new name already matches.
    pub fn rename_folder(&self, id: i64, new_name: &str) -> Result<()> {
        let conn = self.conn();
        conn.execute("UPDATE folders SET name = ?1 WHERE id = ?2", rusqlite::params![new_name, id])?;
        Ok(())
    }

    /// Delete a folder. Associated channel_assignments rows cascade-delete
    /// via the foreign-key constraint, so each member channel reverts to
    /// "Unfiled".
    pub fn delete_folder(&self, id: i64) -> Result<()> {
        let conn = self.conn();
        // Enable FK cascade for this connection — SQLite has it off by default.
        conn.execute("PRAGMA foreign_keys = ON", [])?;
        conn.execute("DELETE FROM folders WHERE id = ?1", [id])?;
        Ok(())
    }

    /// List every folder, ordered by `position` then `id` so the sidebar
    /// has a deterministic order even before drag-to-reorder ships.
    pub fn list_folders(&self) -> Result<Vec<FolderRecord>> {
        let conn = self.conn();
        let mut stmt = conn.prepare("SELECT id, name, position FROM folders ORDER BY position, id")?;
        let rows = stmt.query_map([], |row| {
            Ok(FolderRecord {
                id: row.get(0)?,
                name: row.get(1)?,
                position: row.get(2)?,
            })
        })?;
        Ok(rows.filter_map(std::result::Result::ok).collect())
    }

    /// Set or clear a channel's folder assignment. `folder_id = None`
    /// deletes the row so the channel reverts to "Unfiled".
    pub fn set_channel_folder(
        &self,
        platform: &str,
        handle: &str,
        folder_id: Option<i64>,
    ) -> Result<()> {
        let conn = self.conn();
        match folder_id {
            Some(fid) => {
                conn.execute(
                    "INSERT OR REPLACE INTO channel_assignments (platform, handle, folder_id)
                     VALUES (?1, ?2, ?3)",
                    rusqlite::params![platform, handle, fid],
                )?;
            }
            None => {
                conn.execute(
                    "DELETE FROM channel_assignments WHERE platform = ?1 AND handle = ?2",
                    [platform, handle],
                )?;
            }
        }
        Ok(())
    }

    /// Bulk fetch of every channel's folder assignment as a
    /// `((platform, handle) → folder_id)` map. Used by the library scanner
    /// to populate `Channel.folder_id` after a rescan.
    pub fn get_all_channel_assignments(&self) -> Result<HashMap<(String, String), i64>> {
        let conn = self.conn();
        let mut stmt = conn.prepare(
            "SELECT platform, handle, folder_id FROM channel_assignments",
        )?;
        let map = stmt
            .query_map([], |row| {
                Ok((
                    (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
                    row.get::<_, i64>(2)?,
                ))
            })?
            .filter_map(std::result::Result::ok)
            .map(|(k, v)| (k, v))
            .collect();
        Ok(map)
    }

    /// Bulk fetch every video's flag set, grouped by flag. Used at startup +
    /// after rescan to hydrate the in-memory caches. The four returned sets
    /// hold the IDs of videos with each flag set to true.
    pub fn get_video_flags(&self) -> Result<VideoFlagsBundle> {
        let conn = self.conn();
        let mut stmt = conn.prepare(
            "SELECT video_id, bookmark, favourite, waiting, archive FROM video_flags",
        )?;
        let mut bundle = VideoFlagsBundle::default();
        let rows = stmt.query_map([], |row| {
            Ok((
                row.get::<_, String>(0)?,
                row.get::<_, i32>(1)? != 0,
                row.get::<_, i32>(2)? != 0,
                row.get::<_, i32>(3)? != 0,
                row.get::<_, i32>(4)? != 0,
            ))
        })?;
        for row in rows.flatten() {
            let (id, b, f, w, a) = row;
            if b { bundle.bookmark.insert(id.clone()); }
            if f { bundle.favourite.insert(id.clone()); }
            if w { bundle.waiting.insert(id.clone()); }
            if a { bundle.archive.insert(id.clone()); }
        }
        Ok(bundle)
    }

    /// Bulk fetch of every channel's options, returned as
    /// `((platform, handle) → options_json)`. Used by the library scanner to
    /// attach options to each scanned [`crate::library::Channel`] without
    /// per-channel SQL round trips.
    pub fn get_all_channel_options(&self) -> Result<HashMap<(String, String), String>> {
        let conn = self.conn();
        let mut stmt = conn.prepare("SELECT platform, handle, options_json FROM channel_options")?;
        let map = stmt
            .query_map([], |row| {
                Ok((
                    (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
                    row.get::<_, String>(2)?,
                ))
            })?
            .filter_map(std::result::Result::ok)
            .map(|(k, v)| (k, v))
            .collect();
        Ok(map)
    }

    pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
        let conn = self.conn();
        let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
        let mut rows = stmt.query([key])?;
        Ok(rows.next()?.map(|r| r.get(0)).transpose()?)
    }

    pub fn set_setting(&self, key: &str, value: Option<&str>) -> Result<()> {
        let conn = self.conn();
        match value {
            Some(v) => {
                conn.execute(
                    "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
                    [key, v],
                )?;
            }
            None => {
                conn.execute("DELETE FROM settings WHERE key = ?1", [key])?;
            }
        }
        Ok(())
    }

    pub fn set_watched(&self, video_id: &str, watched: bool) -> Result<()> {
        let conn = self.conn();
        if watched {
            conn.execute(
                "INSERT OR REPLACE INTO watched (video_id) VALUES (?1)",
                [video_id],
            )?;
        } else {
            conn.execute("DELETE FROM watched WHERE video_id = ?1", [video_id])?;
        }
        Ok(())
    }

    pub fn get_watched(&self) -> Result<HashSet<String>> {
        let conn = self.conn();
        let mut stmt = conn.prepare("SELECT video_id FROM watched")?;
        let ids = stmt
            .query_map([], |row| row.get(0))?
            .filter_map(std::result::Result::ok)
            .collect();
        Ok(ids)
    }

    pub fn set_position(&self, video_id: &str, position_secs: f64) -> Result<()> {
        let conn = self.conn();
        conn.execute(
            "INSERT OR REPLACE INTO positions (video_id, position_secs, updated_at)
             VALUES (?1, ?2, CURRENT_TIMESTAMP)",
            rusqlite::params![video_id, position_secs],
        )?;
        Ok(())
    }

    pub fn clear_position(&self, video_id: &str) -> Result<()> {
        let conn = self.conn();
        conn.execute("DELETE FROM positions WHERE video_id = ?1", [video_id])?;
        Ok(())
    }

    pub fn get_positions(&self) -> Result<HashMap<String, f64>> {
        let conn = self.conn();
        let mut stmt = conn.prepare("SELECT video_id, position_secs FROM positions")?;
        let map = stmt
            .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)))?
            .filter_map(std::result::Result::ok)
            .collect();
        Ok(map)
    }

    /// Idempotently merge another yt-offline database into this one.
    ///
    /// Designed for "Import library backup…" — the user uploads a snapshot
    /// produced by `GET /api/backup/db`, and we merge its rows in without
    /// disturbing the channel files on disk. Safe to re-run with the same
    /// backup (or a chain of overlapping backups): conflicting rows resolve
    /// deterministically by recency, flag-OR, or first-write-wins depending
    /// on the table's semantics.
    ///
    /// # Schema validation
    ///
    /// Before merging, we verify each expected table exists in the backup.
    /// A backup from an older schema is rejected outright — partial merges
    /// could leave the DB in a state where some features see stale data.
    /// The caller can offer a "do it anyway" path later if needed.
    ///
    /// # Per-table merge rules
    ///
    /// - `watched`: keep the later `watched_at`. INSERT-OR-IGNORE then
    ///   UPDATE-when-newer covers both directions.
    /// - `positions`: keep the later `updated_at` (same pattern).
    /// - `settings`: skip — `password_hash` and other settings are
    ///   *machine-local* per AGPL-deployment context. Importing them
    ///   would re-authorize an old password / overwrite the current
    ///   source_url with a stale value. The user can re-set them.
    /// - `channel_options`: keep the later `updated_at`.
    /// - `video_flags`: bitwise OR each flag column. If you favourited a
    ///   video on either side, it stays favourited.
    /// - `folders`: insert when the same name doesn't already exist.
    ///   Folder *contents* may differ between backups; we keep the
    ///   current side as authoritative and ignore the backup's
    ///   `channel_assignments` for any folder we already have.
    /// - `channel_assignments`: insert when the (platform, handle) pair
    ///   isn't already assigned. Doesn't change existing assignments.
    pub fn restore_from_backup(&self, backup_path: &Path) -> Result<RestoreSummary> {
        // Open the backup file via ATTACH so we can write `INSERT … SELECT
        // … FROM bk.<table>` in a single transaction against the live DB.
        // ATTACH paths are escaped by binding rather than interpolating to
        // avoid an injection if a caller ever passes a user-influenced
        // string (current callers pass a tmpfile path, but defense in
        // depth is cheap).
        let path_str = backup_path.to_string_lossy().to_string();
        let conn = self.conn();
        conn.execute("ATTACH DATABASE ?1 AS bk", [&path_str])?;
        // No matter what happens below, DETACH so the next caller's pool
        // checkout doesn't see a lingering attachment.
        let result = (|| -> Result<RestoreSummary> {
            // ── Schema validation ────────────────────────────────────
            let required = [
                "watched",
                "positions",
                "channel_options",
                "video_flags",
                "folders",
                "channel_assignments",
            ];
            for table in &required {
                let count: i64 = conn.query_row(
                    "SELECT count(*) FROM bk.sqlite_master \
                     WHERE type = 'table' AND name = ?1",
                    [table],
                    |r| r.get(0),
                )?;
                if count == 0 {
                    return Err(rusqlite::Error::SqliteFailure(
                        rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_MISMATCH),
                        Some(format!(
                            "backup is missing required table `{table}` — \
                             not a yt-offline snapshot, or from an incompatible version"
                        )),
                    ));
                }
            }

            // Wrap the whole merge in a transaction. If any step fails the
            // attached DB stays read-only on our side and the live DB
            // rolls back to pre-import state.
            conn.execute("BEGIN", [])?;
            let summary = (|| -> Result<RestoreSummary> {
                // watched: keep the later timestamp. Two-step approach
                // (insert missing, then update older) works in plain SQL
                // without an UPSERT WHERE clause.
                let watched_before: i64 = conn.query_row(
                    "SELECT count(*) FROM watched", [], |r| r.get(0))?;
                conn.execute(
                    "INSERT OR IGNORE INTO watched (video_id, watched_at) \
                     SELECT video_id, watched_at FROM bk.watched", [])?;
                conn.execute(
                    "UPDATE watched SET watched_at = (\
                         SELECT bk.watched.watched_at FROM bk.watched \
                         WHERE bk.watched.video_id = main.watched.video_id) \
                     WHERE EXISTS (\
                         SELECT 1 FROM bk.watched \
                         WHERE bk.watched.video_id = main.watched.video_id \
                         AND bk.watched.watched_at > main.watched.watched_at)", [])?;
                let watched_after: i64 = conn.query_row(
                    "SELECT count(*) FROM watched", [], |r| r.get(0))?;
                let watched_added = (watched_after - watched_before).max(0);

                // positions: same pattern as watched.
                let positions_before: i64 = conn.query_row(
                    "SELECT count(*) FROM positions", [], |r| r.get(0))?;
                conn.execute(
                    "INSERT OR IGNORE INTO positions (video_id, position_secs, updated_at) \
                     SELECT video_id, position_secs, updated_at FROM bk.positions", [])?;
                // Use main.positions to disambiguate the target — inside
                // the WHERE/SET subqueries SQLite would otherwise resolve
                // the bare `positions` to the innermost FROM (bk.positions
                // for the SELECT subquery), giving us a degenerate
                // `bk.positions.x = bk.positions.x` join.
                conn.execute(
                    "UPDATE positions SET \
                        position_secs = (SELECT bk.positions.position_secs FROM bk.positions \
                            WHERE bk.positions.video_id = main.positions.video_id), \
                        updated_at = (SELECT bk.positions.updated_at FROM bk.positions \
                            WHERE bk.positions.video_id = main.positions.video_id) \
                     WHERE EXISTS (\
                         SELECT 1 FROM bk.positions \
                         WHERE bk.positions.video_id = main.positions.video_id \
                         AND bk.positions.updated_at > main.positions.updated_at)", [])?;
                let positions_after: i64 = conn.query_row(
                    "SELECT count(*) FROM positions", [], |r| r.get(0))?;
                let positions_added = (positions_after - positions_before).max(0);

                // channel_options: keep the later updated_at.
                let options_before: i64 = conn.query_row(
                    "SELECT count(*) FROM channel_options", [], |r| r.get(0))?;
                conn.execute(
                    "INSERT OR IGNORE INTO channel_options \
                        (platform, handle, options_json, updated_at) \
                     SELECT platform, handle, options_json, updated_at \
                     FROM bk.channel_options", [])?;
                conn.execute(
                    "UPDATE channel_options SET \
                        options_json = (SELECT bk.channel_options.options_json \
                            FROM bk.channel_options \
                            WHERE bk.channel_options.platform = main.channel_options.platform \
                            AND bk.channel_options.handle   = main.channel_options.handle), \
                        updated_at = (SELECT bk.channel_options.updated_at \
                            FROM bk.channel_options \
                            WHERE bk.channel_options.platform = main.channel_options.platform \
                            AND bk.channel_options.handle   = main.channel_options.handle) \
                     WHERE EXISTS (\
                         SELECT 1 FROM bk.channel_options \
                         WHERE bk.channel_options.platform = main.channel_options.platform \
                         AND bk.channel_options.handle   = main.channel_options.handle \
                         AND bk.channel_options.updated_at > main.channel_options.updated_at)", [])?;
                let options_after: i64 = conn.query_row(
                    "SELECT count(*) FROM channel_options", [], |r| r.get(0))?;
                let options_added = (options_after - options_before).max(0);

                // video_flags: bitwise OR each flag column. Insert missing
                // first, then OR for collisions. (`MAX` works since each
                // flag is 0/1.)
                let flags_before: i64 = conn.query_row(
                    "SELECT count(*) FROM video_flags", [], |r| r.get(0))?;
                conn.execute(
                    "INSERT OR IGNORE INTO video_flags \
                        (video_id, bookmark, favourite, waiting, archive, updated_at) \
                     SELECT video_id, bookmark, favourite, waiting, archive, updated_at \
                     FROM bk.video_flags", [])?;
                conn.execute(
                    "UPDATE video_flags SET \
                        bookmark  = MAX(bookmark,  COALESCE((SELECT bookmark  FROM bk.video_flags \
                            WHERE bk.video_flags.video_id = main.video_flags.video_id), 0)), \
                        favourite = MAX(favourite, COALESCE((SELECT favourite FROM bk.video_flags \
                            WHERE bk.video_flags.video_id = main.video_flags.video_id), 0)), \
                        waiting   = MAX(waiting,   COALESCE((SELECT waiting   FROM bk.video_flags \
                            WHERE bk.video_flags.video_id = main.video_flags.video_id), 0)), \
                        archive   = MAX(archive,   COALESCE((SELECT archive   FROM bk.video_flags \
                            WHERE bk.video_flags.video_id = main.video_flags.video_id), 0))", [])?;
                let flags_after: i64 = conn.query_row(
                    "SELECT count(*) FROM video_flags", [], |r| r.get(0))?;
                let flags_added = (flags_after - flags_before).max(0);

                // folders: only insert names we don't already have.
                let folders_before: i64 = conn.query_row(
                    "SELECT count(*) FROM folders", [], |r| r.get(0))?;
                conn.execute(
                    "INSERT OR IGNORE INTO folders (name, position, created_at) \
                     SELECT name, position, created_at FROM bk.folders", [])?;
                let folders_after: i64 = conn.query_row(
                    "SELECT count(*) FROM folders", [], |r| r.get(0))?;
                let folders_added = (folders_after - folders_before).max(0);

                // channel_assignments: insert when (platform, handle) is
                // unassigned. We re-resolve folder_id by name to handle the
                // case where the backup and live DB have the same folder
                // name but different IDs.
                let assignments_before: i64 = conn.query_row(
                    "SELECT count(*) FROM channel_assignments", [], |r| r.get(0))?;
                conn.execute(
                    "INSERT OR IGNORE INTO channel_assignments (platform, handle, folder_id) \
                     SELECT b.platform, b.handle, f.id \
                     FROM bk.channel_assignments b \
                     JOIN bk.folders bf ON bf.id = b.folder_id \
                     JOIN folders f ON f.name = bf.name", [])?;
                let assignments_after: i64 = conn.query_row(
                    "SELECT count(*) FROM channel_assignments", [], |r| r.get(0))?;
                let assignments_added = (assignments_after - assignments_before).max(0);

                Ok(RestoreSummary {
                    watched_added: watched_added as u64,
                    positions_added: positions_added as u64,
                    options_added: options_added as u64,
                    flags_added: flags_added as u64,
                    folders_added: folders_added as u64,
                    assignments_added: assignments_added as u64,
                })
            })();
            match summary {
                Ok(s) => {
                    conn.execute("COMMIT", [])?;
                    Ok(s)
                }
                Err(e) => {
                    let _ = conn.execute("ROLLBACK", []);
                    Err(e)
                }
            }
        })();
        // ATTACH state lives on the connection. Detach even on error so
        // the pooled connection is clean when it goes back.
        let _ = conn.execute("DETACH DATABASE bk", []);
        result
    }
}

/// Per-table row counts that landed in the live DB during a restore.
/// Useful for the UI to show "imported N watched + M positions" so the
/// user sees evidence the merge actually did something.
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct RestoreSummary {
    pub watched_added: u64,
    pub positions_added: u64,
    pub options_added: u64,
    pub flags_added: u64,
    pub folders_added: u64,
    pub assignments_added: u64,
}

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

    /// Per-test scratch dir that auto-removes itself. Avoids pulling in
    /// the `tempfile` crate just for this test module.
    struct ScratchDir(std::path::PathBuf);
    impl ScratchDir {
        fn new(name: &str) -> Self {
            let mut p = std::env::temp_dir();
            // Disambiguate parallel-test runs of the same name with the
            // pid + a counter; collisions would otherwise leave one test
            // operating on another's DB.
            use std::sync::atomic::{AtomicU64, Ordering};
            static N: AtomicU64 = AtomicU64::new(0);
            let id = N.fetch_add(1, Ordering::Relaxed);
            p.push(format!("yt-offline-test-{}-{}-{}", std::process::id(), id, name));
            let _ = std::fs::remove_dir_all(&p);
            std::fs::create_dir_all(&p).unwrap();
            ScratchDir(p)
        }
        fn join(&self, name: &str) -> std::path::PathBuf { self.0.join(name) }
    }
    impl Drop for ScratchDir {
        fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); }
    }

    #[test]
    fn restores_watched_and_positions() {
        let dir = ScratchDir::new("watched-positions");
        let live = Database::open(&dir.join("live.db")).unwrap();
        let backup = Database::open(&dir.join("backup.db")).unwrap();

        backup.set_watched("v-only-in-backup", true).unwrap();
        // CURRENT_TIMESTAMP has 1-second resolution. Write the live row
        // first, sleep just over a second, then write the backup row so
        // the merge's `updated_at >` comparison actually picks the
        // backup's value. Real-world backups are taken minutes/days
        // apart, so this resolution is fine in production.
        live.set_position("v-shared", 10.0).unwrap();
        std::thread::sleep(std::time::Duration::from_millis(1100));
        backup.set_position("v-shared", 42.5).unwrap();

        let summary = live.restore_from_backup(&dir.join("backup.db")).unwrap();
        assert_eq!(summary.watched_added, 1);
        // v-shared existed on the live side already, so positions_added
        // counts only the *new* row (which there isn't one of).
        assert_eq!(summary.positions_added, 0);

        // The watched row from backup made it through.
        let w = live.get_watched().unwrap();
        assert!(w.contains("v-only-in-backup"));

        // v-shared got the *later* position (backup's, since the live one
        // was inserted earlier in this test).
        let p = live.get_positions().unwrap();
        assert!((p.get("v-shared").copied().unwrap_or(0.0) - 42.5).abs() < 0.001);
    }

    #[test]
    fn ors_video_flags() {
        let dir = ScratchDir::new("flags-or");
        let live = Database::open(&dir.join("live.db")).unwrap();
        let backup = Database::open(&dir.join("backup.db")).unwrap();

        // Live side: v1 favourite. Backup side: v1 bookmark. After merge
        // v1 should be both.
        live.set_video_flag("v1", "favourite", true).unwrap();
        backup.set_video_flag("v1", "bookmark", true).unwrap();
        backup.set_video_flag("v2", "waiting", true).unwrap();

        live.restore_from_backup(&dir.join("backup.db")).unwrap();
        let flags = live.get_video_flags().unwrap();
        assert!(flags.favourite.contains("v1"));
        assert!(flags.bookmark.contains("v1"));
        assert!(flags.waiting.contains("v2"));
    }

    #[test]
    fn idempotent_when_run_twice() {
        let dir = ScratchDir::new("idempotent");
        let live = Database::open(&dir.join("live.db")).unwrap();
        let backup = Database::open(&dir.join("backup.db")).unwrap();
        backup.set_watched("v1", true).unwrap();
        backup.set_position("v1", 7.5).unwrap();

        let s1 = live.restore_from_backup(&dir.join("backup.db")).unwrap();
        let s2 = live.restore_from_backup(&dir.join("backup.db")).unwrap();

        // First pass adds 1 of each, second adds none (same backup).
        assert_eq!(s1.watched_added, 1);
        assert_eq!(s1.positions_added, 1);
        assert_eq!(s2.watched_added, 0);
        assert_eq!(s2.positions_added, 0);
    }

    #[test]
    fn rejects_unrelated_sqlite_file() {
        let dir = ScratchDir::new("schema-mismatch");
        let live = Database::open(&dir.join("live.db")).unwrap();

        // Create a SQLite file with a completely different schema.
        let bad = dir.join("not-yt-offline.db");
        let conn = Connection::open(&bad).unwrap();
        conn.execute("CREATE TABLE foo (x INT)", []).unwrap();
        drop(conn);

        let err = live.restore_from_backup(&bad).unwrap_err();
        let msg = format!("{err}");
        assert!(msg.contains("missing required table"), "{msg}");
    }
}

/// Translate an `r2d2::Error` from `Pool::build()` into a `rusqlite::Error` so
/// callers don't have to juggle two error types. Pool-init failures are rare
/// (bad file path, OS-level problem) and the surfaced error message is what
/// matters; the variant is incidental.
fn pool_init_to_rusqlite(e: r2d2::Error) -> rusqlite::Error {
    rusqlite::Error::ToSqlConversionFailure(Box::new(std::io::Error::new(
        std::io::ErrorKind::Other,
        format!("sqlite pool init failed: {e}"),
    )))
}

// `Connection` is still imported for the type alias path; suppress the
// unused-import warning when no caller references it directly.
#[allow(dead_code)]
type _SilenceConnectionImport = Connection;
