//! SQLite-backed browsing history for buffr (Phase 5).
//!
//! Phase-5 scope: a pure data layer. No UI, no IPC, no FTS5 yet.
//! Visits land here via a CEF `LoadHandler` wired up in
//! `buffr-core::handlers`; later phases query `search` / `recent`
//! from the omnibar (Phase 3 chrome) and from a `buffr query history`
//! CLI (post-1.0).
//!
//! # Schema (v1)
//!
//! One `visits` table, one row per visit. Columns: `id`, `url`,
//! `title`, `visit_time` (unix epoch seconds), `transition`. Indexes
//! cover lookup-by-url, recent-first ordering, and the
//! `(url, visit_time)` pair the dedupe path needs. Migrations are
//! forward-only; see [`schema`].
//!
//! # Frecency
//!
//! [`History::search`] ranks by a simple `visit_count * 2 + recency_bonus`
//! formula — `recency_bonus = 10` if the most recent visit is within
//! the last 7 days, else `0`. Substring match (`LIKE %q%`) over both
//! `url` and `title`. Good enough for v1; Phase 5b lands FTS5 with a
//! migration. Documented in `crates/buffr-history/README.md`.
//!
//! # Concurrency
//!
//! [`History`] wraps `Mutex<rusqlite::Connection>`. All public methods
//! take `&self` and acquire the lock per call. CEF callbacks are
//! short, lock contention won't matter at human typing rates. If the
//! omnibar ever needs to query mid-keystroke we'll switch to a
//! connection pool (`r2d2_sqlite`) — not now.

use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use buffr_store::ts_to_dt;
use chrono::{DateTime, Utc};
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::{debug, trace};

pub mod schema;

/// Canonical default schemes never recorded. `about:` covers
/// `about:blank` / `about:srcdoc`; `cef:` and `chrome:` cover internal
/// browser UI; `data:` and `file:` are noisy and privacy-sensitive.
/// Surfaced as `config.privacy.skip_schemes` (Phase 4).
pub const DEFAULT_SKIP_SCHEMES: &[&str] = &["about", "cef", "chrome", "data", "file"];

/// Dedupe window. If a row exists for the same canonical URL whose
/// `visit_time` is within this many seconds of `now`, we update that
/// row in place rather than inserting. Prevents reload spam from
/// blowing up the table.
const DEDUPE_WINDOW_SECS: i64 = 60;

/// Recent-bonus window for the frecency formula. Visits inside this
/// window get `+10` added to their score so a yesterday-visited page
/// outranks a once-visited-six-months-ago page even with equal visit
/// counts.
const RECENCY_WINDOW_SECS: i64 = 7 * 24 * 60 * 60;

/// One row of the `visits` table, decoded into Rust types.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HistoryEntry {
    pub id: i64,
    pub url: String,
    pub title: Option<String>,
    pub visit_time: DateTime<Utc>,
    pub transition: Transition,
}

/// What kind of navigation produced a visit. Stored as a string so
/// adding variants doesn't require a schema migration.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Transition {
    /// User clicked a link or typed a URL into the omnibar.
    Link,
    /// Explicit page reload (`r`, `<C-r>`, etc.).
    Reload,
    /// Form submission landed on this URL.
    FormSubmit,
    /// Omnibar autocomplete pick.
    Generated,
    /// Anything else / unclassified.
    Other,
}

impl Transition {
    pub fn as_str(self) -> &'static str {
        match self {
            Transition::Link => "link",
            Transition::Reload => "reload",
            Transition::FormSubmit => "form_submit",
            Transition::Generated => "generated",
            Transition::Other => "other",
        }
    }

    fn parse(s: &str) -> Self {
        match s {
            "link" => Transition::Link,
            "reload" => Transition::Reload,
            "form_submit" => Transition::FormSubmit,
            "generated" => Transition::Generated,
            _ => Transition::Other,
        }
    }
}

/// Errors surfaced from [`History`]. `rusqlite::Error` is wrapped via
/// `#[from]` so the public surface stays small — callers don't need
/// to depend on `rusqlite` directly to handle errors.
#[derive(Debug, Error)]
pub enum HistoryError {
    #[error("opening sqlite database failed")]
    Open {
        #[source]
        source: rusqlite::Error,
    },
    #[error("applying migration v{version} failed")]
    Migrate {
        #[source]
        source: rusqlite::Error,
        version: i64,
    },
    /// The on-disk schema is newer than this binary understands —
    /// the profile was written by a newer buffr. Refusing beats
    /// silently running old code against a newer schema.
    #[error("database schema v{found} is newer than supported v{supported}")]
    SchemaTooNew { found: i64, supported: i64 },
    #[error("query failed")]
    Query {
        #[from]
        source: rusqlite::Error,
    },
    #[error("invalid url")]
    Url {
        #[from]
        source: url::ParseError,
    },
    #[error("history mutex poisoned")]
    Poisoned,
}

/// Trait used to inject "now" into [`History`]. The default impl reads
/// the system clock; tests substitute a [`MockClock`] so dedupe-window
/// boundary tests don't need to actually sleep.
pub trait Clock: Send + Sync + 'static {
    /// Current wall-clock instant in UTC. Resolution: seconds (we
    /// store unix-epoch seconds, not millis).
    fn now(&self) -> DateTime<Utc>;
}

/// System clock — `chrono::Utc::now()`. Default in production.
#[derive(Debug, Default, Clone, Copy)]
pub struct SystemClock;

impl Clock for SystemClock {
    fn now(&self) -> DateTime<Utc> {
        Utc::now()
    }
}

/// Manually-driven clock for tests. Construct via
/// [`MockClock::new`], advance with [`MockClock::advance`]. Implements
/// [`Clock`] through an internal `Arc<Mutex<DateTime<Utc>>>` so the
/// test harness can keep one handle for `advance` while a second
/// handle (sharing the same timeline) lives inside [`History`].
#[derive(Debug, Clone)]
pub struct MockClock {
    inner: Arc<Mutex<DateTime<Utc>>>,
}

impl MockClock {
    pub fn new(start: DateTime<Utc>) -> Self {
        Self {
            inner: Arc::new(Mutex::new(start)),
        }
    }

    pub fn advance(&self, secs: i64) {
        if let Ok(mut t) = self.inner.lock() {
            *t += chrono::Duration::seconds(secs);
        }
    }
}

impl Clock for MockClock {
    fn now(&self) -> DateTime<Utc> {
        self.inner.lock().map(|t| *t).unwrap_or_else(|_| Utc::now())
    }
}

/// SQLite-backed history store.
pub struct History {
    conn: Mutex<Connection>,
    clock: Box<dyn Clock>,
    skip_schemes: Vec<String>,
}

impl History {
    /// Open or create the SQLite database at `path` and run any
    /// pending schema migrations. Uses [`SystemClock`] for time and
    /// the canonical [`DEFAULT_SKIP_SCHEMES`] list.
    pub fn open(path: impl AsRef<Path>) -> Result<Self, HistoryError> {
        Self::builder().path(path).build()
    }

    /// In-memory database — for tests and short-lived ephemeral
    /// profiles (private windows, Phase 5 follow-up).
    /// Uses the canonical [`DEFAULT_SKIP_SCHEMES`] list.
    pub fn open_in_memory() -> Result<Self, HistoryError> {
        Self::builder().in_memory().build()
    }

    /// Configure a [`History`] with a custom [`Clock`] and/or
    /// skip-schemes list via [`HistoryBuilder`].
    pub fn builder() -> HistoryBuilder {
        HistoryBuilder::default()
    }

    /// Record a single visit. Performs URL canonicalisation, scheme
    /// filtering, and dedupe.
    ///
    /// - Skip-scheme URLs (`about:`, `chrome:`, `cef:`, `data:`,
    ///   `file:`) return `Ok(())` without inserting.
    /// - Unparseable URLs log at `debug` and return `Ok(())` — we'd
    ///   rather drop the row than poison the call site.
    /// - If a row exists for the canonical URL with `visit_time`
    ///   inside the last [`DEDUPE_WINDOW_SECS`], that row is updated
    ///   in place (new `visit_time`; non-empty `title` overrides).
    ///   Otherwise we insert.
    pub fn record_visit(
        &self,
        url: &str,
        title: Option<&str>,
        transition: Transition,
    ) -> Result<(), HistoryError> {
        let canon = match canonicalise(url) {
            Some(c) => c,
            None => {
                debug!(url, "history: unparseable url; skipping");
                return Ok(());
            }
        };
        if is_skip_scheme(&canon, &self.skip_schemes) {
            trace!(url = %canon, "history: skip-scheme; not recording");
            return Ok(());
        }

        let now = self.clock.now().timestamp();
        let cutoff = now - DEDUPE_WINDOW_SECS;
        let title = title.map(str::trim).filter(|s| !s.is_empty());

        let conn = self.conn.lock().map_err(|_| HistoryError::Poisoned)?;

        let recent: Option<(i64, Option<String>)> = conn
            .query_row(
                "SELECT id, title FROM visits \
                 WHERE url = ?1 AND visit_time >= ?2 \
                 ORDER BY visit_time DESC LIMIT 1",
                params![canon, cutoff],
                |row| Ok((row.get(0)?, row.get(1)?)),
            )
            .optional()?;

        if let Some((id, existing_title)) = recent {
            // Dedupe: bump visit_time. Keep existing title unless we
            // got a fresher non-empty one. Single UPDATE; rusqlite
            // does the right thing with `COALESCE`-via-Rust.
            let new_title: Option<String> = title.map(str::to_owned).or(existing_title);
            conn.execute(
                "UPDATE visits SET visit_time = ?1, title = ?2 WHERE id = ?3",
                params![now, new_title, id],
            )?;
        } else {
            conn.execute(
                "INSERT INTO visits (url, title, visit_time, transition) \
                 VALUES (?1, ?2, ?3, ?4)",
                params![canon, title, now, transition.as_str()],
            )?;
        }
        Ok(())
    }

    /// Update the title of the most recent visit for `url`. Used by
    /// the optional `DisplayHandler::on_title_change` integration —
    /// CEF emits the title slightly after `on_load_end`.
    pub fn update_latest_title(&self, url: &str, title: &str) -> Result<(), HistoryError> {
        let canon = match canonicalise(url) {
            Some(c) => c,
            None => return Ok(()),
        };
        let title = title.trim();
        if title.is_empty() {
            return Ok(());
        }
        let conn = self.conn.lock().map_err(|_| HistoryError::Poisoned)?;
        conn.execute(
            "UPDATE visits SET title = ?1 \
             WHERE id = (SELECT id FROM visits WHERE url = ?2 \
                         ORDER BY visit_time DESC LIMIT 1)",
            params![title, canon],
        )?;
        Ok(())
    }

    /// Return the `limit` most recent visits, newest first.
    pub fn recent(&self, limit: usize) -> Result<Vec<HistoryEntry>, HistoryError> {
        let conn = self.conn.lock().map_err(|_| HistoryError::Poisoned)?;
        let mut stmt = conn.prepare(
            "SELECT id, url, title, visit_time, transition \
             FROM visits ORDER BY visit_time DESC LIMIT ?1",
        )?;
        let rows = stmt
            .query_map(params![limit as i64], row_to_entry)?
            .collect::<Result<Vec<_>, _>>()?;
        Ok(rows)
    }

    /// Search history using FTS5 token matching, ranked by frecency.
    ///
    /// **Matching**: the query is run through `visits_fts MATCH` with
    /// `unicode61` tokenization. Multi-word queries (`"rust learn"`) use
    /// FTS5 implicit AND across both `url` and `title` columns. User
    /// query text is wrapped in double-quotes before passing to SQLite
    /// so that FTS5 special characters (`*`, `:`, `^`, `OR`, `AND`,
    /// etc.) are treated as literals rather than operators — the same
    /// substring-style experience as the old `LIKE %q%` path.
    ///
    /// **Empty query**: if `query` is empty (or only whitespace) the
    /// function returns `Ok(vec![])`. Callers that want "show recent"
    /// semantics on an empty bar should call [`History::recent`]
    /// directly.
    ///
    /// **Frecency formula** (unchanged from v1):
    /// `score = visit_count * 2 + recency_bonus`, where
    /// `recency_bonus = 10` if the latest visit was within
    /// [`RECENCY_WINDOW_SECS`] (7 days) else `0`.
    ///
    /// Result rows are deduplicated by `url`; `visit_time` reflects
    /// the most recent visit for that URL. Tie-break by `visit_time
    /// DESC` so two equally-scored entries surface the more recent
    /// one first.
    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<HistoryEntry>, HistoryError> {
        if query.trim().is_empty() {
            return Ok(vec![]);
        }
        // Multi-word queries AND their tokens together so order and column
        // placement don't matter (A5): FTS5 treats a quoted string as a phrase
        // (adjacent, in-order tokens in one column), but the documented contract
        // is implicit AND across both url and title. Quote each whitespace token
        // individually and join with AND; a single token behaves exactly as the
        // old single-phrase form.
        let needle = query
            .split_whitespace()
            .map(|tok| format!("\"{}\"", tok.replace('"', "\"\"")))
            .collect::<Vec<_>>()
            .join(" AND ");
        let conn = self.conn.lock().map_err(|_| HistoryError::Poisoned)?;
        let now = self.clock.now().timestamp();
        let recent_cutoff = now - RECENCY_WINDOW_SECS;
        let mut stmt = conn.prepare_cached(
            "SELECT id, url, title, visit_time, transition, score FROM ( \
               SELECT MAX(v.id) AS id, v.url, \
                      (SELECT title FROM visits v2 WHERE v2.url = v.url \
                       ORDER BY visit_time DESC LIMIT 1) AS title, \
                      MAX(v.visit_time) AS visit_time, \
                      (SELECT transition FROM visits v2 WHERE v2.url = v.url \
                       ORDER BY visit_time DESC LIMIT 1) AS transition, \
                      (COUNT(*) * 2 \
                       + CASE WHEN MAX(v.visit_time) >= ?1 THEN 10 ELSE 0 END) AS score \
               FROM visits v \
               JOIN visits_fts f ON f.rowid = v.id \
               WHERE visits_fts MATCH ?2 \
               GROUP BY v.url \
             ) ORDER BY score DESC, visit_time DESC LIMIT ?3",
        )?;
        let rows = stmt
            .query_map(params![recent_cutoff, needle, limit as i64], |row| {
                Ok(HistoryEntry {
                    id: row.get(0)?,
                    url: row.get(1)?,
                    title: row.get(2)?,
                    visit_time: ts_to_dt(row.get(3)?),
                    transition: Transition::parse(&row.get::<_, String>(4)?),
                })
            })?
            .collect::<Result<Vec<_>, _>>()?;
        Ok(rows)
    }

    /// Delete every visit with `from <= visit_time < to`. Returns the
    /// number of rows deleted. Used by Phase 5 "clear browsing data".
    pub fn clear_range(
        &self,
        from: DateTime<Utc>,
        to: DateTime<Utc>,
    ) -> Result<usize, HistoryError> {
        let conn = self.conn.lock().map_err(|_| HistoryError::Poisoned)?;
        let n = conn.execute(
            "DELETE FROM visits WHERE visit_time >= ?1 AND visit_time < ?2",
            params![from.timestamp(), to.timestamp()],
        )?;
        Ok(n)
    }

    /// Total row count. O(1) with the index but we don't care — this
    /// is for tests / startup logging only.
    pub fn count(&self) -> Result<usize, HistoryError> {
        let conn = self.conn.lock().map_err(|_| HistoryError::Poisoned)?;
        let n: i64 = conn.query_row("SELECT COUNT(*) FROM visits", [], |row| row.get(0))?;
        Ok(n as usize)
    }

    /// Delete every visit row. Returns the number deleted. Used by the
    /// `[privacy] clear_on_exit = ["history"]` shutdown hook in
    /// `apps/buffr`. Runs `VACUUM` afterward to actually shrink the DB
    /// file rather than just marking pages free.
    pub fn clear_all(&self) -> Result<usize, HistoryError> {
        let conn = self.conn.lock().map_err(|_| HistoryError::Poisoned)?;
        let n = conn.execute("DELETE FROM visits", [])?;
        // VACUUM cannot run inside a transaction. Failure is non-fatal —
        // the DELETE already removed the data, VACUUM is just storage
        // hygiene.
        if let Err(err) = conn.execute("VACUUM", []) {
            tracing::warn!(error = %err, "history: VACUUM after clear_all failed");
        }
        Ok(n)
    }
}

/// Builder for [`History`]. Defaults to an in-memory database with
/// [`SystemClock`] and the canonical [`DEFAULT_SKIP_SCHEMES`] list;
/// override each option with the consuming builder methods.
pub struct HistoryBuilder {
    path: Option<PathBuf>, // None => in-memory
    clock: Box<dyn Clock>,
    skip_schemes: Option<Vec<String>>, // None => DEFAULT_SKIP_SCHEMES
}

impl Default for HistoryBuilder {
    fn default() -> Self {
        Self {
            path: None,
            clock: Box::new(SystemClock),
            skip_schemes: None,
        }
    }
}

impl HistoryBuilder {
    /// Back the history with the SQLite database at `path`.
    pub fn path(mut self, path: impl AsRef<Path>) -> Self {
        self.path = Some(path.as_ref().to_path_buf());
        self
    }

    /// Use an ephemeral in-memory database (the default).
    pub fn in_memory(mut self) -> Self {
        self.path = None;
        self
    }

    /// Use a custom [`Clock`] instead of [`SystemClock`]. Needed by
    /// tests to drive the dedupe window deterministically.
    pub fn clock(mut self, clock: Box<dyn Clock>) -> Self {
        self.clock = clock;
        self
    }

    /// Replace the canonical [`DEFAULT_SKIP_SCHEMES`] list. Pass
    /// `config.privacy.skip_schemes.clone()` here. An explicitly empty
    /// list records every scheme — the config's "remove entries to record
    /// them" contract; only omitting this call keeps the defaults.
    pub fn skip_schemes(mut self, schemes: Vec<String>) -> Self {
        self.skip_schemes = Some(schemes);
        self
    }

    /// Build the [`History`]. Omitting [`Self::skip_schemes`] resolves to
    /// the canonical [`DEFAULT_SKIP_SCHEMES`]; an explicit empty list is
    /// honored as "no scheme is skipped".
    pub fn build(self) -> Result<History, HistoryError> {
        let skip_schemes = self
            .skip_schemes
            .unwrap_or_else(|| DEFAULT_SKIP_SCHEMES.iter().map(|s| s.to_string()).collect());
        let mut conn = match self.path {
            Some(path) => {
                buffr_store::open_tuned(&path).map_err(|source| HistoryError::Open { source })?
            }
            None => buffr_store::open_tuned_in_memory()
                .map_err(|source| HistoryError::Open { source })?,
        };
        schema::apply(&mut conn)?;
        Ok(History {
            conn: Mutex::new(conn),
            clock: self.clock,
            skip_schemes,
        })
    }
}

fn row_to_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result<HistoryEntry> {
    Ok(HistoryEntry {
        id: row.get(0)?,
        url: row.get(1)?,
        title: row.get(2)?,
        visit_time: ts_to_dt(row.get(3)?),
        transition: Transition::parse(&row.get::<_, String>(4)?),
    })
}

/// Parse + canonicalise a URL string. Returns `None` when parsing
/// fails. We round-trip via `url::Url` so trailing whitespace, mixed
/// case in the scheme/host, and default ports get normalised.
fn canonicalise(input: &str) -> Option<String> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return None;
    }
    url::Url::parse(trimmed).ok().map(|u| u.to_string())
}

fn is_skip_scheme(canonical_url: &str, skip_schemes: &[String]) -> bool {
    let scheme = match canonical_url.split_once(':') {
        Some((s, _)) => s.to_ascii_lowercase(),
        None => return true,
    };
    skip_schemes.iter().any(|s| s.eq_ignore_ascii_case(&scheme))
}

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

    fn fixed_clock(secs: i64) -> MockClock {
        MockClock::new(DateTime::<Utc>::from_timestamp(secs, 0).expect("ts"))
    }

    /// M54: a profile written by a newer buffr must be refused, not
    /// silently opened with an older binary's expectations.
    #[test]
    fn schema_newer_than_binary_is_refused() {
        let store = History::open_in_memory().unwrap();
        let mut conn = store.conn.lock().unwrap();
        conn.execute(
            "INSERT INTO schema_version(version) VALUES (?1)",
            params![schema::latest_version() + 1],
        )
        .unwrap();
        let err = schema::apply(&mut conn).unwrap_err();
        assert!(
            matches!(err, HistoryError::SchemaTooNew { .. }),
            "got {err:?}"
        );
    }

    #[test]
    fn open_in_memory_runs_migrations() {
        let h = History::open_in_memory().unwrap();
        assert_eq!(h.count().unwrap(), 0);
        assert_eq!(schema::latest_version(), 2);
    }

    #[test]
    fn record_three_visits_recent_orders_newest_first() {
        let clock = fixed_clock(1_000_000_000);
        let h = History::builder()
            .in_memory()
            .clock(Box::new(clock.clone()))
            .build()
            .unwrap();
        h.record_visit("https://a.example/", Some("A"), Transition::Link)
            .unwrap();
        clock.advance(120);
        h.record_visit("https://b.example/", Some("B"), Transition::Link)
            .unwrap();
        clock.advance(120);
        h.record_visit("https://c.example/", Some("C"), Transition::Link)
            .unwrap();

        assert_eq!(h.count().unwrap(), 3);
        let recent = h.recent(10).unwrap();
        assert_eq!(recent.len(), 3);
        assert_eq!(recent[0].url, "https://c.example/");
        assert_eq!(recent[1].url, "https://b.example/");
        assert_eq!(recent[2].url, "https://a.example/");
    }

    #[test]
    fn dedupe_within_window_updates_in_place() {
        let clock = fixed_clock(1_000_000_000);
        let h = History::builder()
            .in_memory()
            .clock(Box::new(clock.clone()))
            .build()
            .unwrap();
        h.record_visit("https://a.example/", Some("A"), Transition::Link)
            .unwrap();
        clock.advance(5);
        h.record_visit("https://a.example/", Some("A"), Transition::Link)
            .unwrap();
        assert_eq!(h.count().unwrap(), 1);
    }

    #[test]
    fn dedupe_boundary_inserts_after_window() {
        let clock = fixed_clock(1_000_000_000);
        let h = History::builder()
            .in_memory()
            .clock(Box::new(clock.clone()))
            .build()
            .unwrap();
        h.record_visit("https://a.example/", Some("A"), Transition::Link)
            .unwrap();
        clock.advance(61);
        h.record_visit("https://a.example/", Some("A"), Transition::Link)
            .unwrap();
        assert_eq!(h.count().unwrap(), 2);
    }

    #[test]
    fn dedupe_keeps_existing_title_when_new_is_empty() {
        let clock = fixed_clock(1_000_000_000);
        let h = History::builder()
            .in_memory()
            .clock(Box::new(clock.clone()))
            .build()
            .unwrap();
        h.record_visit("https://a.example/", Some("Original"), Transition::Link)
            .unwrap();
        clock.advance(10);
        h.record_visit("https://a.example/", None, Transition::Link)
            .unwrap();
        let recent = h.recent(1).unwrap();
        assert_eq!(recent[0].title.as_deref(), Some("Original"));
    }

    #[test]
    fn search_ranks_by_frecency() {
        // a.example: visited 3x within recency window → score 6 + 10 = 16
        // b.example: visited 1x within recency window → score 2 + 10 = 12
        // c.example: visited 5x but ages ago → score 10 + 0 = 10
        let clock = fixed_clock(1_000_000_000);
        let h = History::builder()
            .in_memory()
            .clock(Box::new(clock.clone()))
            .build()
            .unwrap();

        for _ in 0..5 {
            h.record_visit("https://c.example/old", Some("Old"), Transition::Link)
                .unwrap();
            clock.advance(120);
        }
        // Jump 30 days forward so `c.example` falls outside the
        // 7-day recency window.
        clock.advance(30 * 24 * 3600);

        for _ in 0..3 {
            h.record_visit("https://a.example/new", Some("New A"), Transition::Link)
                .unwrap();
            clock.advance(120);
        }
        h.record_visit("https://b.example/new", Some("New B"), Transition::Link)
            .unwrap();

        let results = h.search("example", 10).unwrap();
        assert_eq!(results.len(), 3);
        assert_eq!(results[0].url, "https://a.example/new");
        assert_eq!(results[1].url, "https://b.example/new");
        assert_eq!(results[2].url, "https://c.example/old");
    }

    #[test]
    fn clear_range_deletes_window() {
        let clock = fixed_clock(1_000_000_000);
        let h = History::builder()
            .in_memory()
            .clock(Box::new(clock.clone()))
            .build()
            .unwrap();
        h.record_visit("https://a.example/", None, Transition::Link)
            .unwrap();
        let mid = clock.now();
        clock.advance(120);
        h.record_visit("https://b.example/", None, Transition::Link)
            .unwrap();
        clock.advance(120);
        h.record_visit("https://c.example/", None, Transition::Link)
            .unwrap();

        let to = clock.now();
        let removed = h
            .clear_range(
                mid + chrono::Duration::seconds(1),
                to + chrono::Duration::seconds(1),
            )
            .unwrap();
        assert_eq!(removed, 2);
        assert_eq!(h.count().unwrap(), 1);
        assert_eq!(h.recent(1).unwrap()[0].url, "https://a.example/");
    }

    #[test]
    fn skip_scheme_returns_ok_without_insert() {
        let h = History::open_in_memory().unwrap();
        h.record_visit("about:blank", None, Transition::Link)
            .unwrap();
        h.record_visit("chrome://settings", None, Transition::Link)
            .unwrap();
        h.record_visit("data:text/plain,hi", None, Transition::Link)
            .unwrap();
        assert_eq!(h.count().unwrap(), 0);
    }

    #[test]
    fn custom_skip_schemes_honored() {
        let h = History::builder()
            .in_memory()
            .skip_schemes(vec!["javascript".to_string()])
            .build()
            .unwrap();
        h.record_visit("javascript:alert(1)", None, Transition::Link)
            .unwrap();
        assert_eq!(h.count().unwrap(), 0);
        // A normal https URL must still be recorded.
        h.record_visit("https://example.com/", None, Transition::Link)
            .unwrap();
        assert_eq!(h.count().unwrap(), 1);
    }

    #[test]
    fn empty_skip_schemes_records_everything() {
        // §11-10: `[privacy] skip_schemes = []` means "record all
        // schemes", not "fall back to the defaults".
        let h = History::builder()
            .in_memory()
            .skip_schemes(Vec::new())
            .build()
            .unwrap();
        h.record_visit("file:///etc/passwd", None, Transition::Link)
            .unwrap();
        h.record_visit("about:blank", None, Transition::Link)
            .unwrap();
        assert_eq!(h.count().unwrap(), 2);
    }

    #[test]
    fn unparseable_url_returns_ok_without_insert() {
        let h = History::open_in_memory().unwrap();
        h.record_visit("not a url at all", None, Transition::Link)
            .unwrap();
        assert_eq!(h.count().unwrap(), 0);
    }

    #[test]
    fn clear_all_wipes_table_and_reports_count() {
        let h = History::open_in_memory().unwrap();
        h.record_visit("https://a.example/", None, Transition::Link)
            .unwrap();
        h.record_visit("https://b.example/", None, Transition::Link)
            .unwrap();
        h.record_visit("https://c.example/", None, Transition::Link)
            .unwrap();
        assert_eq!(h.count().unwrap(), 3);
        let removed = h.clear_all().unwrap();
        assert_eq!(removed, 3);
        assert_eq!(h.count().unwrap(), 0);
        // Idempotent: second call returns 0, doesn't error.
        assert_eq!(h.clear_all().unwrap(), 0);
    }

    #[test]
    fn update_latest_title_only_touches_most_recent() {
        let clock = fixed_clock(1_000_000_000);
        let h = History::builder()
            .in_memory()
            .clock(Box::new(clock.clone()))
            .build()
            .unwrap();
        h.record_visit("https://a.example/", Some("first"), Transition::Link)
            .unwrap();
        clock.advance(61);
        h.record_visit("https://a.example/", Some("second"), Transition::Link)
            .unwrap();
        h.update_latest_title("https://a.example/", "fresh")
            .unwrap();
        let recent = h.recent(2).unwrap();
        assert_eq!(recent[0].title.as_deref(), Some("fresh"));
        assert_eq!(recent[1].title.as_deref(), Some("first"));
    }

    #[test]
    fn search_uses_fts_token_match() {
        // URL contains "rust-lang" and "learn"; FTS5 implicit AND on
        // two tokens spanning url + title should match.
        let h = History::open_in_memory().unwrap();
        h.record_visit(
            "https://rust-lang.org/learn",
            Some("Learn Rust"),
            Transition::Link,
        )
        .unwrap();
        // Unrelated page that must NOT appear.
        h.record_visit("https://example.com/", Some("Example"), Transition::Link)
            .unwrap();

        let results = h.search("learn rust", 10).unwrap();
        assert_eq!(results.len(), 1, "expected exactly one FTS match");
        assert_eq!(results[0].url, "https://rust-lang.org/learn");
    }

    #[test]
    fn search_multiword_uses_implicit_and_not_phrase() {
        // A5: multi-word queries use FTS5 implicit AND, not a phrase.
        // The tokens of `rust learn` are never adjacent in one column
        // (url token stream: `https rust lang org learn`; title: `learn
        // rust`), yet the documented contract matches the row anyway.
        let h = History::open_in_memory().unwrap();
        h.record_visit(
            "https://rust-lang.org/learn",
            Some("Learn Rust"),
            Transition::Link,
        )
        .unwrap();

        let results = h.search("rust learn", 10).unwrap();
        assert_eq!(
            results.len(),
            1,
            "AND must not require tokens to be adjacent or in order"
        );
        assert_eq!(results[0].url, "https://rust-lang.org/learn");

        let results = h.search("learn rust", 10).unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].url, "https://rust-lang.org/learn");

        // An embedded double-quote must be escaped, not parsed as an FTS5
        // operator — a 0-row result is fine, an error is not.
        assert!(h.search("it's \"fine\"", 10).is_ok());
    }

    #[test]
    fn search_handles_special_chars_in_query() {
        // Characters like `*` are FTS5 operators. The quoting logic must
        // neutralise them so the query returns Ok rather than Err.
        let h = History::open_in_memory().unwrap();
        h.record_visit("https://example.com/", None, Transition::Link)
            .unwrap();

        // These should not panic or error — result set may be empty.
        assert!(h.search("foo*bar", 10).is_ok());
        assert!(h.search("foo:bar", 10).is_ok());
        assert!(h.search("OR AND NOT", 10).is_ok());
        assert!(h.search("\"quoted\"", 10).is_ok());
    }

    #[test]
    fn search_empty_query_returns_empty() {
        // Empty (and whitespace-only) queries return Ok(vec![]) rather
        // than matching everything. Callers wanting recent entries should
        // use History::recent directly.
        let h = History::open_in_memory().unwrap();
        h.record_visit("https://example.com/", None, Transition::Link)
            .unwrap();

        assert_eq!(h.search("", 10).unwrap(), vec![]);
        assert_eq!(h.search("   ", 10).unwrap(), vec![]);
    }

    #[test]
    fn migration_v2_indexes_existing_and_new_rows() {
        // Open a fresh in-memory DB (runs both v1 + v2 migrations).
        // Insert a row, then verify it is discoverable via search (which
        // goes through visits_fts MATCH). This exercises both the
        // backfill INSERT in v2 *and* the visits_ai trigger path.
        let h = History::open_in_memory().unwrap();
        h.record_visit(
            "https://doc.rust-lang.org/",
            Some("Rust Documentation"),
            Transition::Link,
        )
        .unwrap();

        let results = h.search("documentation", 10).unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].url, "https://doc.rust-lang.org/");
    }
}
