use std::io::Cursor;
use std::sync::OnceLock;

use atom_syndication::Feed as AtomFeed;
use chrono::{DateTime, Utc};
use regex::Regex;
use rss::Channel;

use crate::news::types::{FeedSourceRecord, ParsedFeed, ParsedNewsArticle};
use crate::news::{NewsError, Result};

pub fn parse_feed(source: &FeedSourceRecord, body: &str, fetched_at: &str) -> Result<ParsedFeed> {
    if let Ok(channel) = Channel::read_from(Cursor::new(body.as_bytes())) {
        return Ok(parse_rss(source, &channel, fetched_at));
    }

    if let Ok(feed) = AtomFeed::read_from(Cursor::new(body.as_bytes())) {
        return Ok(parse_atom(source, &feed, fetched_at));
    }

    Err(NewsError::Parse(format!(
        "feed {} is neither valid RSS nor Atom",
        source.id
    )))
}

fn parse_rss(source: &FeedSourceRecord, channel: &Channel, fetched_at: &str) -> ParsedFeed {
    let mut articles = Vec::new();
    let mut malformed_entries = 0;

    for item in channel.items() {
        match parse_rss_item(source, item, fetched_at) {
            Ok(article) => articles.push(article),
            Err(_) => malformed_entries += 1,
        }
    }

    ParsedFeed {
        articles,
        malformed_entries,
    }
}

fn parse_atom(source: &FeedSourceRecord, feed: &AtomFeed, fetched_at: &str) -> ParsedFeed {
    let mut articles = Vec::new();
    let mut malformed_entries = 0;

    for entry in feed.entries() {
        match parse_atom_entry(source, entry, fetched_at) {
            Ok(article) => articles.push(article),
            Err(_) => malformed_entries += 1,
        }
    }

    ParsedFeed {
        articles,
        malformed_entries,
    }
}

fn parse_rss_item(
    source: &FeedSourceRecord,
    item: &rss::Item,
    fetched_at: &str,
) -> Result<ParsedNewsArticle> {
    let headline = item
        .title()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| NewsError::Parse(format!("feed {} item missing title", source.id)))?;

    let summary = item
        .content()
        .or_else(|| item.description())
        .map(strip_markup)
        .unwrap_or_default();

    let url = item
        .link()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string);
    let published = item.pub_date().or_else(|| {
        item.dublin_core_ext()
            .and_then(|ext| ext.dates().first().map(String::as_str))
    });

    Ok(ParsedNewsArticle {
        source_id: source.id.clone(),
        source: source.name.clone(),
        headline: strip_markup(headline),
        summary,
        canonical_url: url.clone(),
        url,
        published_at: resolve_published_at(published, fetched_at)?,
        published_ts: resolve_published_ts(published, fetched_at)?,
        fetched_at: fetched_at.to_string(),
    })
}

fn parse_atom_entry(
    source: &FeedSourceRecord,
    entry: &atom_syndication::Entry,
    fetched_at: &str,
) -> Result<ParsedNewsArticle> {
    let headline = entry.title().trim();
    if headline.is_empty() {
        return Err(NewsError::Parse(format!(
            "feed {} entry missing title",
            source.id
        )));
    }

    let summary = entry
        .summary()
        .map(|text| strip_markup(text.as_str()))
        .or_else(|| {
            entry
                .content()
                .and_then(|content| content.value())
                .map(strip_markup)
        })
        .unwrap_or_default();
    let url = entry
        .links()
        .iter()
        .find(|link| link.rel() == "alternate" || link.rel().is_empty() || link.rel() == "self")
        .map(|link| link.href().trim().to_string())
        .filter(|value| !value.is_empty());
    let published = entry
        .published()
        .map(|value| value.to_rfc3339())
        .unwrap_or_else(|| entry.updated().to_rfc3339());

    Ok(ParsedNewsArticle {
        source_id: source.id.clone(),
        source: source.name.clone(),
        headline: strip_markup(headline),
        summary,
        canonical_url: url.clone(),
        url,
        published_at: resolve_published_at(Some(published.as_str()), fetched_at)?,
        published_ts: resolve_published_ts(Some(published.as_str()), fetched_at)?,
        fetched_at: fetched_at.to_string(),
    })
}

fn resolve_published_at(raw_value: Option<&str>, fallback: &str) -> Result<String> {
    match raw_value.and_then(parse_datetime) {
        Some(value) => Ok(value.to_rfc3339()),
        None if !fallback.is_empty() => Ok(fallback.to_string()),
        None => Err(NewsError::Parse(
            "feed entry missing publish time".to_string(),
        )),
    }
}

fn resolve_published_ts(raw_value: Option<&str>, fallback: &str) -> Result<i64> {
    match raw_value.and_then(parse_datetime) {
        Some(value) => Ok(value.timestamp()),
        None if !fallback.is_empty() => DateTime::parse_from_rfc3339(fallback)
            .map(|value| value.timestamp())
            .map_err(|error| NewsError::Parse(error.to_string())),
        None => Err(NewsError::Parse(
            "feed entry missing publish timestamp".to_string(),
        )),
    }
}

fn parse_datetime(value: &str) -> Option<DateTime<Utc>> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return None;
    }

    DateTime::parse_from_rfc2822(trimmed)
        .or_else(|_| DateTime::parse_from_rfc3339(trimmed))
        .map(|value| value.with_timezone(&Utc))
        .ok()
}

fn strip_markup(value: &str) -> String {
    static TAG_RE: OnceLock<Regex> = OnceLock::new();
    static WHITESPACE_RE: OnceLock<Regex> = OnceLock::new();

    let without_tags = TAG_RE
        .get_or_init(|| Regex::new(r"(?is)<[^>]+>").expect("tag regex should compile"))
        .replace_all(value, " ");

    WHITESPACE_RE
        .get_or_init(|| Regex::new(r"\s+").expect("whitespace regex should compile"))
        .replace_all(without_tags.trim(), " ")
        .trim()
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::parse_feed;
    use crate::news::types::FeedSourceRecord;

    #[test]
    fn parse_feed_should_read_rss_fixture() {
        let parsed = parse_feed(
            &sample_source(),
            include_str!("../../tests/fixtures/news/sample.rss"),
            "2026-04-08T10:00:00Z",
        )
        .unwrap();

        assert_eq!(parsed.articles.len(), 2);
        assert_eq!(parsed.articles[0].headline, "Fed signals steady rates");
    }

    #[test]
    fn parse_feed_should_read_atom_fixture() {
        let parsed = parse_feed(
            &sample_source(),
            include_str!("../../tests/fixtures/news/sample.atom"),
            "2026-04-08T10:00:00Z",
        )
        .unwrap();

        assert_eq!(parsed.articles.len(), 2);
        assert_eq!(parsed.articles[0].source, "Sample Feed");
    }

    #[test]
    fn parse_feed_should_skip_malformed_entries() {
        let parsed = parse_feed(
            &sample_source(),
            include_str!("../../tests/fixtures/news/malformed.rss"),
            "2026-04-08T10:00:00Z",
        )
        .unwrap();

        assert_eq!(parsed.articles.len(), 1);
        assert_eq!(parsed.malformed_entries, 1);
    }

    fn sample_source() -> FeedSourceRecord {
        FeedSourceRecord {
            id: "sample".to_string(),
            name: "Sample Feed".to_string(),
            url: "https://example.com/feed.xml".to_string(),
            refresh_minutes: 15,
            etag: None,
            last_modified: None,
            last_checked_at: None,
            last_success_at: None,
            last_error: None,
            failure_count: 0,
        }
    }
}
