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

use rusqlite::types::Value;
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};

use crate::news::types::{
    ArticleUpsertSummary, ClassifiedNewsArticle, FeedSourceRecord, HighlightReason, NewsArticle,
    NewsSentiment, NewsSourceConfig, QueryNewsFeedRequest, QueryNewsFeedResponse,
    UpdateNewsArticleStateRequest,
};
use crate::news::{NewsError, Result};

#[derive(Clone)]
pub struct NewsRepository {
    db_path: PathBuf,
    connections: Arc<Mutex<Vec<Connection>>>,
}

impl NewsRepository {
    pub fn new(db_path: PathBuf) -> Result<Self> {
        if let Some(parent) = db_path.parent() {
            std::fs::create_dir_all(parent)?;
        }

        let connection = open_connection(&db_path)?;
        let repository = Self {
            db_path,
            connections: Arc::new(Mutex::new(Vec::new())),
        };
        repository.initialize_schema(&connection)?;
        repository.store_connection(connection)?;
        Ok(repository)
    }

    pub fn sync_sources_blocking(&self, sources: Vec<NewsSourceConfig>) -> Result<()> {
        let mut connection = self.open_connection()?;
        sync_sources_in_connection(&mut connection, sources)
    }

    pub async fn sync_sources(&self, sources: Vec<NewsSourceConfig>) -> Result<()> {
        self.with_connection(move |connection| sync_sources_in_connection(connection, sources))
            .await
    }

    pub async fn list_sources(&self) -> Result<Vec<FeedSourceRecord>> {
        self.with_connection(|connection| {
            let mut statement = connection.prepare(
                "SELECT id, name, url, refresh_minutes, etag, last_modified,
                        last_checked_at, last_success_at, last_error, failure_count
                 FROM feed_sources
                 ORDER BY name ASC",
            )?;
            let rows = statement.query_map([], |row| {
                Ok(FeedSourceRecord {
                    id: row.get(0)?,
                    name: row.get(1)?,
                    url: row.get(2)?,
                    refresh_minutes: row.get::<_, u32>(3)?,
                    etag: row.get(4)?,
                    last_modified: row.get(5)?,
                    last_checked_at: row.get(6)?,
                    last_success_at: row.get(7)?,
                    last_error: row.get(8)?,
                    failure_count: row.get::<_, u32>(9)?,
                })
            })?;

            rows.collect::<std::result::Result<Vec<_>, _>>()
                .map_err(NewsError::from)
        })
        .await
    }

    pub async fn record_fetch_success(
        &self,
        source_id: String,
        checked_at: String,
        etag: Option<String>,
        last_modified: Option<String>,
    ) -> Result<()> {
        self.with_connection(move |connection| {
            connection.execute(
                "UPDATE feed_sources
                 SET etag = COALESCE(?2, etag),
                     last_modified = COALESCE(?3, last_modified),
                     last_checked_at = ?4,
                     last_success_at = ?4,
                     last_error = NULL,
                     failure_count = 0
                 WHERE id = ?1",
                params![source_id, etag, last_modified, checked_at],
            )?;
            Ok(())
        })
        .await
    }

    pub async fn record_fetch_failure(
        &self,
        source_id: String,
        checked_at: String,
        error_message: String,
    ) -> Result<()> {
        self.with_connection(move |connection| {
            connection.execute(
                "UPDATE feed_sources
                 SET last_checked_at = ?2,
                     last_error = ?3,
                     failure_count = failure_count + 1
                 WHERE id = ?1",
                params![source_id, checked_at, error_message],
            )?;
            Ok(())
        })
        .await
    }

    pub async fn upsert_articles(
        &self,
        articles: Vec<ClassifiedNewsArticle>,
    ) -> Result<ArticleUpsertSummary> {
        self.with_connection(move |connection| {
            let transaction = connection.transaction()?;
            let mut summary = ArticleUpsertSummary::default();

            for article in articles {
                let existing = transaction
                    .query_row(
                        "SELECT id, source_id, source, headline, summary, url, canonical_url,
                                published_at, published_ts, sentiment, sentiment_score, highlight_reason
                         FROM articles
                         WHERE fingerprint = ?1",
                        params![article.fingerprint.clone()],
                        |row| {
                            Ok((
                                row.get::<_, String>(0)?,
                                row.get::<_, String>(1)?,
                                row.get::<_, String>(2)?,
                                row.get::<_, String>(3)?,
                                row.get::<_, String>(4)?,
                                row.get::<_, Option<String>>(5)?,
                                row.get::<_, Option<String>>(6)?,
                                row.get::<_, String>(7)?,
                                row.get::<_, i64>(8)?,
                                row.get::<_, String>(9)?,
                                row.get::<_, f64>(10)?,
                                row.get::<_, Option<String>>(11)?,
                            ))
                        },
                    )
                    .optional()?;

                let is_changed = existing
                    .as_ref()
                    .map(|existing| {
                        existing.1 != article.source_id
                            || existing.2 != article.source
                            || existing.3 != article.headline
                            || existing.4 != article.summary
                            || existing.5 != article.url
                            || existing.6 != article.canonical_url
                            || existing.7 != article.published_at
                            || existing.8 != article.published_ts
                            || existing.9 != sentiment_to_db(&article.sentiment)
                            || (existing.10 - article.sentiment_score).abs() > f64::EPSILON
                            || existing.11 != article.highlight_reason.as_ref().map(highlight_to_db)
                    })
                    .unwrap_or(true);

                if existing.is_none() {
                    summary.new_articles += 1;
                } else if is_changed {
                    summary.updated_articles += 1;
                } else {
                    summary.unchanged_articles += 1;
                }

                transaction.execute(
                    "INSERT INTO articles (
                        id, source_id, source, headline, summary, url, canonical_url, fingerprint,
                        published_at, published_ts, fetched_at, sentiment, sentiment_score,
                        highlight_reason, is_read, is_saved
                    ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?1, ?8, ?9, ?10, ?11, ?12, ?13, 0, 0)
                    ON CONFLICT(id) DO UPDATE SET
                        source_id = excluded.source_id,
                        source = excluded.source,
                        headline = excluded.headline,
                        summary = excluded.summary,
                        url = excluded.url,
                        canonical_url = excluded.canonical_url,
                        published_at = excluded.published_at,
                        published_ts = excluded.published_ts,
                        fetched_at = excluded.fetched_at,
                        sentiment = excluded.sentiment,
                        sentiment_score = excluded.sentiment_score,
                        highlight_reason = excluded.highlight_reason",
                    params![
                        article.fingerprint,
                        article.source_id,
                        article.source,
                        article.headline,
                        article.summary,
                        article.url,
                        article.canonical_url,
                        article.published_at,
                        article.published_ts,
                        article.fetched_at,
                        sentiment_to_db(&article.sentiment),
                        article.sentiment_score,
                        article.highlight_reason.as_ref().map(highlight_to_db),
                    ],
                )?;

                transaction.execute(
                    "DELETE FROM article_tickers WHERE article_id = ?1",
                    params![article.fingerprint],
                )?;
                for ticker in &article.tickers {
                    transaction.execute(
                        "INSERT OR IGNORE INTO article_tickers (article_id, ticker) VALUES (?1, ?2)",
                        params![article.fingerprint, ticker],
                    )?;
                }

                transaction.execute(
                    "DELETE FROM news_fts WHERE article_id = ?1",
                    params![article.fingerprint],
                )?;
                transaction.execute(
                    "INSERT INTO news_fts (article_id, headline, summary) VALUES (?1, ?2, ?3)",
                    params![article.fingerprint, article.headline, article.summary],
                )?;
            }

            transaction.commit()?;
            Ok(summary)
        })
        .await
    }

    pub async fn query_articles(
        &self,
        request: QueryNewsFeedRequest,
    ) -> Result<QueryNewsFeedResponse> {
        self.with_connection(move |connection| query_articles(connection, request))
            .await
    }

    pub async fn update_article_state(&self, request: UpdateNewsArticleStateRequest) -> Result<()> {
        self.with_connection(move |connection| {
            let rows_updated = connection.execute(
                "UPDATE articles
                 SET is_read = COALESCE(?2, is_read),
                     is_saved = COALESCE(?3, is_saved)
                 WHERE id = ?1",
                params![
                    request.article_id,
                    request.is_read.map(i64::from),
                    request.is_saved.map(i64::from),
                ],
            )?;

            if rows_updated == 0 {
                return Err(NewsError::ArticleNotFound(request.article_id));
            }

            Ok(())
        })
        .await
    }

    fn open_connection(&self) -> Result<Connection> {
        open_connection(&self.db_path)
    }

    fn initialize_schema(&self, connection: &Connection) -> Result<()> {
        connection.execute_batch(
            "PRAGMA foreign_keys = ON;
             PRAGMA journal_mode = WAL;
             PRAGMA synchronous = NORMAL;
             CREATE TABLE IF NOT EXISTS feed_sources (
                 id TEXT PRIMARY KEY,
                 name TEXT NOT NULL,
                 url TEXT NOT NULL,
                 refresh_minutes INTEGER NOT NULL,
                 etag TEXT,
                 last_modified TEXT,
                 last_checked_at TEXT,
                 last_success_at TEXT,
                 last_error TEXT,
                 failure_count INTEGER NOT NULL DEFAULT 0
             );
             CREATE TABLE IF NOT EXISTS articles (
                 id TEXT PRIMARY KEY,
                 source_id TEXT NOT NULL REFERENCES feed_sources(id) ON DELETE CASCADE,
                 source TEXT NOT NULL,
                 headline TEXT NOT NULL,
                 summary TEXT NOT NULL,
                 url TEXT,
                 canonical_url TEXT,
                 fingerprint TEXT NOT NULL UNIQUE,
                 published_at TEXT NOT NULL,
                 published_ts INTEGER NOT NULL,
                 fetched_at TEXT NOT NULL,
                 sentiment TEXT NOT NULL,
                 sentiment_score REAL NOT NULL,
                 highlight_reason TEXT,
                 is_read INTEGER NOT NULL DEFAULT 0,
                 is_saved INTEGER NOT NULL DEFAULT 0
             );
             CREATE INDEX IF NOT EXISTS articles_published_ts_idx ON articles (published_ts DESC);
             CREATE INDEX IF NOT EXISTS articles_highlight_idx ON articles (highlight_reason);
             CREATE INDEX IF NOT EXISTS articles_saved_idx ON articles (is_saved, published_ts DESC);
             CREATE INDEX IF NOT EXISTS articles_read_idx ON articles (is_read, published_ts DESC);
             CREATE TABLE IF NOT EXISTS article_tickers (
                 article_id TEXT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
                 ticker TEXT NOT NULL,
                 PRIMARY KEY (article_id, ticker)
             );
             CREATE INDEX IF NOT EXISTS article_tickers_ticker_idx ON article_tickers (ticker);
             CREATE VIRTUAL TABLE IF NOT EXISTS news_fts USING fts5(article_id UNINDEXED, headline, summary);",
        )?;
        Ok(())
    }

    async fn with_connection<F, T>(&self, task: F) -> Result<T>
    where
        F: FnOnce(&mut Connection) -> Result<T> + Send + 'static,
        T: Send + 'static,
    {
        let db_path = self.db_path.clone();
        let connections = self.connections.clone();
        tokio::task::spawn_blocking(move || {
            let mut connection = take_connection(&connections, &db_path)?;
            let result = task(&mut connection);
            let store_result = store_connection(&connections, connection);
            match (result, store_result) {
                (Ok(value), Ok(())) => Ok(value),
                (Err(error), Ok(())) => Err(error),
                (Ok(_), Err(error)) | (Err(_), Err(error)) => Err(error),
            }
        })
        .await
        .map_err(|error| NewsError::Join(error.to_string()))?
    }

    fn store_connection(&self, connection: Connection) -> Result<()> {
        store_connection(&self.connections, connection)
    }
}

fn sync_sources_in_connection(
    connection: &mut Connection,
    sources: Vec<NewsSourceConfig>,
) -> Result<()> {
    let transaction = connection.transaction()?;
    let source_ids = sources
        .iter()
        .map(|source| source.id.clone())
        .collect::<Vec<_>>();
    for source in sources {
        transaction.execute(
            "INSERT INTO feed_sources (
                id, name, url, refresh_minutes, failure_count
            ) VALUES (?1, ?2, ?3, ?4, 0)
            ON CONFLICT(id) DO UPDATE SET
                name = excluded.name,
                url = excluded.url,
                refresh_minutes = excluded.refresh_minutes",
            params![
                source.id,
                source.name,
                source.url,
                i64::from(source.refresh_minutes)
            ],
        )?;
    }
    if source_ids.is_empty() {
        transaction.execute("DELETE FROM feed_sources", [])?;
    } else {
        let placeholders = std::iter::repeat_n("?", source_ids.len())
            .collect::<Vec<_>>()
            .join(", ");
        let delete_sql = format!("DELETE FROM feed_sources WHERE id NOT IN ({placeholders})");
        let delete_params = source_ids
            .iter()
            .cloned()
            .map(Value::Text)
            .collect::<Vec<_>>();
        transaction.execute(&delete_sql, params_from_iter(delete_params.iter()))?;
    }
    transaction.commit()?;
    Ok(())
}

fn open_connection(path: &Path) -> Result<Connection> {
    let connection = Connection::open(path)?;
    connection.execute_batch(
        "PRAGMA foreign_keys = ON;
         PRAGMA journal_mode = WAL;
         PRAGMA synchronous = NORMAL;",
    )?;
    Ok(connection)
}

fn take_connection(pool: &Mutex<Vec<Connection>>, db_path: &Path) -> Result<Connection> {
    {
        let mut guard = pool.lock().map_err(|error| {
            NewsError::Config(format!("news connection pool poisoned: {error}"))
        })?;
        if let Some(connection) = guard.pop() {
            return Ok(connection);
        }
    }

    open_connection(db_path)
}

fn store_connection(pool: &Mutex<Vec<Connection>>, connection: Connection) -> Result<()> {
    let mut guard = pool
        .lock()
        .map_err(|error| NewsError::Config(format!("news connection pool poisoned: {error}")))?;
    guard.push(connection);
    Ok(())
}

fn query_articles(
    connection: &mut Connection,
    request: QueryNewsFeedRequest,
) -> Result<QueryNewsFeedResponse> {
    let (where_clause, parameters) = build_article_filters(&request);
    let count_sql = format!("SELECT COUNT(*) FROM articles a WHERE {where_clause}");
    let total = connection.query_row(&count_sql, params_from_iter(parameters.iter()), |row| {
        row.get::<_, usize>(0)
    })?;

    let limit = i64::try_from(request.limit.unwrap_or(50).min(200))
        .map_err(|error| NewsError::Parse(error.to_string()))?;
    let offset = i64::try_from(request.offset.unwrap_or(0))
        .map_err(|error| NewsError::Parse(error.to_string()))?;

    let mut query_params = parameters.clone();
    query_params.push(Value::Integer(limit));
    query_params.push(Value::Integer(offset));

    let query_sql = format!(
        "SELECT a.id, a.source_id, a.source, a.headline, a.summary, a.url, a.canonical_url,
                a.published_at, a.published_ts, a.fetched_at, a.sentiment, a.sentiment_score,
                a.highlight_reason, a.is_read, a.is_saved
         FROM articles a
         WHERE {where_clause}
         ORDER BY a.published_ts DESC, a.fetched_at DESC
         LIMIT ? OFFSET ?"
    );

    let mut statement = connection.prepare(&query_sql)?;
    let rows = statement.query_map(params_from_iter(query_params.iter()), |row| {
        Ok(NewsArticle {
            id: row.get(0)?,
            source_id: row.get(1)?,
            source: row.get(2)?,
            headline: row.get(3)?,
            summary: row.get(4)?,
            url: row.get(5)?,
            canonical_url: row.get(6)?,
            published_at: row.get(7)?,
            published_ts: row.get(8)?,
            fetched_at: row.get(9)?,
            sentiment: sentiment_from_db(&row.get::<_, String>(10)?),
            sentiment_score: row.get(11)?,
            highlight_reason: row
                .get::<_, Option<String>>(12)?
                .as_deref()
                .map(highlight_from_db),
            is_read: row.get::<_, i64>(13)? != 0,
            is_saved: row.get::<_, i64>(14)? != 0,
            tickers: Vec::new(),
        })
    })?;

    let mut articles = rows.collect::<std::result::Result<Vec<_>, _>>()?;
    let ticker_map = load_tickers_for_articles(connection, &articles)?;
    for article in &mut articles {
        article.tickers = ticker_map.get(&article.id).cloned().unwrap_or_default();
    }

    let last_synced_at = connection
        .query_row("SELECT MAX(last_success_at) FROM feed_sources", [], |row| {
            row.get::<_, Option<String>>(0)
        })
        .optional()?
        .flatten();
    let sources = load_source_statuses(connection)?;

    Ok(QueryNewsFeedResponse {
        articles,
        total,
        last_synced_at,
        sources,
    })
}

fn build_article_filters(request: &QueryNewsFeedRequest) -> (String, Vec<Value>) {
    let mut clauses = vec!["1 = 1".to_string()];
    let mut parameters = Vec::new();

    if let Some(ticker) = request
        .ticker
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        clauses.push(
            "EXISTS (SELECT 1 FROM article_tickers at WHERE at.article_id = a.id AND at.ticker = ?)"
                .to_string(),
        );
        parameters.push(Value::Text(ticker.to_ascii_uppercase()));
    }

    if request.only_highlighted.unwrap_or(false) {
        clauses.push("a.highlight_reason IS NOT NULL".to_string());
    }
    if request.only_saved.unwrap_or(false) {
        clauses.push("a.is_saved = 1".to_string());
    }
    if request.only_unread.unwrap_or(false) {
        clauses.push("a.is_read = 0".to_string());
    }
    if let Some(search) = request
        .search
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        clauses.push(
            "EXISTS (SELECT 1 FROM news_fts WHERE news_fts.article_id = a.id AND news_fts MATCH ?)"
                .to_string(),
        );
        parameters.push(Value::Text(fts_query(search)));
    }

    (clauses.join(" AND "), parameters)
}

fn fts_query(input: &str) -> String {
    let tokens = input
        .split(|character: char| !character.is_ascii_alphanumeric())
        .filter(|value| !value.is_empty())
        .map(|value| format!("{value}*"))
        .collect::<Vec<_>>();

    if tokens.is_empty() {
        input.to_string()
    } else {
        tokens.join(" AND ")
    }
}

fn load_tickers_for_articles(
    connection: &Connection,
    articles: &[NewsArticle],
) -> Result<HashMap<String, Vec<String>>> {
    let mut map = HashMap::new();
    let mut statement = connection
        .prepare("SELECT ticker FROM article_tickers WHERE article_id = ?1 ORDER BY ticker ASC")?;

    for article in articles {
        let rows = statement.query_map(params![article.id], |row| row.get::<_, String>(0))?;
        map.insert(
            article.id.clone(),
            rows.collect::<std::result::Result<Vec<_>, _>>()?,
        );
    }

    Ok(map)
}

fn load_source_statuses(
    connection: &Connection,
) -> Result<Vec<crate::news::types::NewsSourceStatus>> {
    let mut statement = connection.prepare(
        "SELECT id, name, url, refresh_minutes, last_checked_at, last_success_at, last_error, failure_count
         FROM feed_sources
         ORDER BY name ASC",
    )?;
    let rows = statement.query_map([], |row| {
        Ok(crate::news::types::NewsSourceStatus {
            id: row.get(0)?,
            name: row.get(1)?,
            url: row.get(2)?,
            refresh_minutes: row.get::<_, u32>(3)?,
            last_checked_at: row.get(4)?,
            last_success_at: row.get(5)?,
            last_error: row.get(6)?,
            failure_count: row.get::<_, u32>(7)?,
        })
    })?;

    rows.collect::<std::result::Result<Vec<_>, _>>()
        .map_err(NewsError::from)
}

fn sentiment_to_db(value: &NewsSentiment) -> &'static str {
    match value {
        NewsSentiment::Bull => "BULL",
        NewsSentiment::Bear => "BEAR",
        NewsSentiment::Neutral => "NEUTRAL",
    }
}

fn sentiment_from_db(value: &str) -> NewsSentiment {
    match value {
        "BULL" => NewsSentiment::Bull,
        "BEAR" => NewsSentiment::Bear,
        _ => NewsSentiment::Neutral,
    }
}

fn highlight_to_db(value: &HighlightReason) -> String {
    match value {
        HighlightReason::BreakingKeyword => "breaking_keyword",
        HighlightReason::MacroEvent => "macro_event",
        HighlightReason::StrongSentiment => "strong_sentiment",
        HighlightReason::TickerDetected => "ticker_detected",
        HighlightReason::RecentHighValue => "recent_high_value",
    }
    .to_string()
}

fn highlight_from_db(value: &str) -> HighlightReason {
    match value {
        "breaking_keyword" => HighlightReason::BreakingKeyword,
        "macro_event" => HighlightReason::MacroEvent,
        "strong_sentiment" => HighlightReason::StrongSentiment,
        "ticker_detected" => HighlightReason::TickerDetected,
        _ => HighlightReason::RecentHighValue,
    }
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::PathBuf;
    use std::time::{SystemTime, UNIX_EPOCH};

    use super::NewsRepository;
    use crate::news::types::{
        ClassifiedNewsArticle, HighlightReason, NewsSentiment, NewsSourceConfig,
        QueryNewsFeedRequest, UpdateNewsArticleStateRequest,
    };

    #[tokio::test]
    async fn upsert_articles_should_preserve_read_and_saved_flags() {
        let repository = sample_repository().await;
        seed_source(&repository).await;
        repository
            .upsert_articles(vec![sample_article("article-1", vec!["NVDA".to_string()])])
            .await
            .unwrap();
        repository
            .update_article_state(UpdateNewsArticleStateRequest {
                article_id: "article-1".to_string(),
                is_read: Some(true),
                is_saved: Some(true),
            })
            .await
            .unwrap();

        repository
            .upsert_articles(vec![ClassifiedNewsArticle {
                headline: "Updated headline".to_string(),
                ..sample_article("article-1", vec!["NVDA".to_string()])
            }])
            .await
            .unwrap();

        let response = repository
            .query_articles(QueryNewsFeedRequest {
                ticker: Some("NVDA".to_string()),
                search: None,
                only_highlighted: None,
                only_saved: Some(true),
                only_unread: None,
                limit: Some(10),
                offset: Some(0),
            })
            .await
            .unwrap();

        assert_eq!(response.articles.len(), 1);
        assert!(response.articles[0].is_read);
        assert!(response.articles[0].is_saved);
    }

    #[tokio::test]
    async fn query_articles_should_filter_by_ticker_saved_unread_highlight_and_search() {
        let repository = sample_repository().await;
        seed_source(&repository).await;
        repository
            .upsert_articles(vec![
                sample_article("article-1", vec!["NVDA".to_string()]),
                ClassifiedNewsArticle {
                    fingerprint: "article-2".to_string(),
                    headline: "Fed policy update".to_string(),
                    summary: "Macro event".to_string(),
                    tickers: vec!["AAPL".to_string()],
                    highlight_reason: Some(HighlightReason::MacroEvent),
                    ..sample_article("article-2", vec!["AAPL".to_string()])
                },
            ])
            .await
            .unwrap();
        repository
            .update_article_state(UpdateNewsArticleStateRequest {
                article_id: "article-2".to_string(),
                is_read: Some(true),
                is_saved: Some(true),
            })
            .await
            .unwrap();

        let response = repository
            .query_articles(QueryNewsFeedRequest {
                ticker: Some("AAPL".to_string()),
                search: Some("policy".to_string()),
                only_highlighted: Some(true),
                only_saved: Some(true),
                only_unread: Some(false),
                limit: Some(10),
                offset: Some(0),
            })
            .await
            .unwrap();

        assert_eq!(response.total, 1);
        assert_eq!(response.articles[0].id, "article-2");
    }

    #[tokio::test]
    async fn new_should_create_schema_on_empty_database() {
        let root = unique_test_directory("news-repository");
        let repository = NewsRepository::new(root.join("news.sqlite")).unwrap();

        let response = repository
            .query_articles(QueryNewsFeedRequest {
                ticker: None,
                search: None,
                only_highlighted: None,
                only_saved: None,
                only_unread: None,
                limit: Some(5),
                offset: Some(0),
            })
            .await
            .unwrap();

        assert_eq!(response.total, 0);
    }

    #[tokio::test]
    async fn sync_sources_should_remove_sources_missing_from_latest_config() {
        let repository = sample_repository().await;

        repository
            .sync_sources(vec![
                NewsSourceConfig {
                    id: "sample".to_string(),
                    name: "Sample".to_string(),
                    url: "https://example.com/feed.xml".to_string(),
                    refresh_minutes: 15,
                },
                NewsSourceConfig {
                    id: "macro".to_string(),
                    name: "Macro".to_string(),
                    url: "https://example.com/macro.xml".to_string(),
                    refresh_minutes: 30,
                },
            ])
            .await
            .unwrap();

        repository
            .sync_sources(vec![NewsSourceConfig {
                id: "sample".to_string(),
                name: "Sample".to_string(),
                url: "https://example.com/feed.xml".to_string(),
                refresh_minutes: 60,
            }])
            .await
            .unwrap();

        let sources = repository.list_sources().await.unwrap();
        assert_eq!(sources.len(), 1);
        assert_eq!(sources[0].id, "sample");
        assert_eq!(sources[0].refresh_minutes, 60);
    }

    async fn sample_repository() -> NewsRepository {
        let root = unique_test_directory("news-repository");
        NewsRepository::new(root.join("news.sqlite")).unwrap()
    }

    async fn seed_source(repository: &NewsRepository) {
        repository
            .sync_sources(vec![NewsSourceConfig {
                id: "sample".to_string(),
                name: "Sample".to_string(),
                url: "https://example.com/feed.xml".to_string(),
                refresh_minutes: 15,
            }])
            .await
            .unwrap();
    }

    fn sample_article(fingerprint: &str, tickers: Vec<String>) -> ClassifiedNewsArticle {
        ClassifiedNewsArticle {
            fingerprint: fingerprint.to_string(),
            source_id: "sample".to_string(),
            source: "Sample".to_string(),
            headline: "NVIDIA beats estimates".to_string(),
            summary: "Strong demand lifts outlook".to_string(),
            url: Some("https://example.com/story".to_string()),
            canonical_url: Some("https://example.com/story".to_string()),
            published_at: "2026-04-08T10:00:00Z".to_string(),
            published_ts: 1_775_642_400,
            fetched_at: "2026-04-08T10:05:00Z".to_string(),
            sentiment: NewsSentiment::Bull,
            sentiment_score: 0.64,
            highlight_reason: Some(HighlightReason::TickerDetected),
            tickers,
        }
    }

    fn unique_test_directory(prefix: &str) -> PathBuf {
        let suffix = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!("{prefix}-{suffix}"));
        fs::create_dir_all(&path).unwrap();
        path
    }
}
