use std::collections::{BTreeSet, HashSet};
use std::sync::OnceLock;

use chrono::Utc;
use regex::Regex;
use reqwest::Url;
use sha2::{Digest, Sha256};

use crate::news::types::{
    ClassifiedNewsArticle, HighlightReason, NewsSentiment, ParsedNewsArticle,
};

const POSITIVE_KEYWORDS: [(&str, f64); 8] = [
    ("surge", 0.22),
    ("beats", 0.18),
    ("record", 0.16),
    ("growth", 0.14),
    ("upgrades", 0.18),
    ("expands", 0.14),
    ("strong demand", 0.2),
    ("raises guidance", 0.28),
];
const NEGATIVE_KEYWORDS: [(&str, f64); 8] = [
    ("plunge", -0.24),
    ("misses", -0.18),
    ("cuts guidance", -0.28),
    ("layoffs", -0.16),
    ("downgrade", -0.18),
    ("fraud", -0.3),
    ("investigation", -0.22),
    ("default", -0.3),
];

pub fn classify_article(article: ParsedNewsArticle) -> ClassifiedNewsArticle {
    let sentiment_score = sentiment_score(&article.headline, &article.summary);
    let sentiment = sentiment_label(sentiment_score);
    let tickers = extract_tickers(&article.headline, &article.summary);
    let canonical_url = normalize_url(article.canonical_url.as_deref()).or(article.canonical_url);
    let highlight_reason = highlight_reason(
        &article.headline,
        &article.summary,
        sentiment_score,
        !tickers.is_empty(),
        article.published_ts,
        &article.source_id,
    );
    let fingerprint = fingerprint(
        canonical_url.as_deref(),
        &article.source,
        &article.headline,
        article.published_ts,
        &article.summary,
    );

    ClassifiedNewsArticle {
        fingerprint,
        source_id: article.source_id,
        source: article.source,
        headline: article.headline,
        summary: article.summary,
        url: article.url,
        canonical_url,
        published_at: article.published_at,
        published_ts: article.published_ts,
        fetched_at: article.fetched_at,
        sentiment,
        sentiment_score,
        highlight_reason,
        tickers,
    }
}

pub fn sentiment_label(score: f64) -> NewsSentiment {
    if score >= 0.35 {
        NewsSentiment::Bull
    } else if score <= -0.35 {
        NewsSentiment::Bear
    } else {
        NewsSentiment::Neutral
    }
}

pub fn sentiment_score(headline: &str, summary: &str) -> f64 {
    let haystack = format!(
        "{} {}",
        headline.to_ascii_lowercase(),
        summary.to_ascii_lowercase()
    );
    let score = POSITIVE_KEYWORDS
        .iter()
        .chain(NEGATIVE_KEYWORDS.iter())
        .filter(|(keyword, _)| haystack.contains(keyword))
        .map(|(_, weight)| *weight)
        .sum::<f64>();

    score.clamp(-1.0, 1.0)
}

pub fn extract_tickers(headline: &str, summary: &str) -> Vec<String> {
    static DOLLAR_RE: OnceLock<Regex> = OnceLock::new();
    static PAREN_RE: OnceLock<Regex> = OnceLock::new();
    static CAPS_RE: OnceLock<Regex> = OnceLock::new();
    static STOPLIST: OnceLock<HashSet<&'static str>> = OnceLock::new();

    let stoplist = STOPLIST.get_or_init(|| {
        [
            "USA", "CEO", "ETF", "GDP", "CPI", "SEC", "FED", "USD", "EPS", "AI", "IPO", "DOJ",
            "FOMC", "ECB",
        ]
        .into_iter()
        .collect()
    });
    let mut tickers = BTreeSet::new();
    let full_text = format!("{headline} {summary}");

    for captures in DOLLAR_RE
        .get_or_init(|| Regex::new(r"\$([A-Z]{1,5})\b").expect("dollar ticker regex"))
        .captures_iter(&full_text)
    {
        tickers.insert(captures[1].to_string());
    }

    for captures in PAREN_RE
        .get_or_init(|| Regex::new(r"\(([A-Z]{1,5})\)").expect("paren ticker regex"))
        .captures_iter(&full_text)
    {
        tickers.insert(captures[1].to_string());
    }

    for captures in CAPS_RE
        .get_or_init(|| Regex::new(r"\b([A-Z]{1,5})\b").expect("caps ticker regex"))
        .captures_iter(headline)
        .chain(
            CAPS_RE
                .get_or_init(|| Regex::new(r"\b([A-Z]{1,5})\b").expect("caps ticker regex"))
                .captures_iter(summary),
        )
    {
        let candidate = captures[1].to_string();
        if !stoplist.contains(candidate.as_str()) {
            tickers.insert(candidate);
        }
    }

    tickers.into_iter().collect()
}

pub fn fingerprint(
    canonical_url: Option<&str>,
    source: &str,
    headline: &str,
    published_ts: i64,
    summary: &str,
) -> String {
    let payload = if let Some(url) = canonical_url.filter(|value| !value.is_empty()) {
        normalize_url(Some(url)).unwrap_or_else(|| url.to_string())
    } else {
        let published_day = published_ts.div_euclid(86_400);
        format!(
            "{}|{}|{}|{}",
            source.to_ascii_lowercase(),
            normalize_text(headline),
            published_day,
            summary
                .chars()
                .take(180)
                .collect::<String>()
                .to_ascii_lowercase()
        )
    };

    hex::encode(Sha256::digest(payload.as_bytes()))
}

fn highlight_reason(
    headline: &str,
    summary: &str,
    sentiment_score: f64,
    has_ticker: bool,
    published_ts: i64,
    source_id: &str,
) -> Option<HighlightReason> {
    let haystack = format!(
        "{} {}",
        headline.to_ascii_lowercase(),
        summary.to_ascii_lowercase()
    );

    if ["breaking", "alert", "just in", "urgent"]
        .iter()
        .any(|keyword| haystack.contains(keyword))
    {
        return Some(HighlightReason::BreakingKeyword);
    }

    if [
        "federal reserve",
        "interest rate",
        "monetary policy",
        "inflation",
        "cpi",
        "gdp",
        "jobs report",
        "8-k",
        "10-q",
    ]
    .iter()
    .any(|keyword| haystack.contains(keyword))
    {
        return Some(HighlightReason::MacroEvent);
    }

    if sentiment_score.abs() >= 0.7 {
        return Some(HighlightReason::StrongSentiment);
    }

    if has_ticker {
        return Some(HighlightReason::TickerDetected);
    }

    let recent_seconds = Utc::now().timestamp() - published_ts;
    if recent_seconds <= 6 * 60 * 60
        && (source_id.contains("sec")
            || source_id.contains("fed")
            || ["filing", "guidance", "earnings", "policy", "meeting"]
                .iter()
                .any(|keyword| haystack.contains(keyword)))
    {
        return Some(HighlightReason::RecentHighValue);
    }

    None
}

fn normalize_url(url: Option<&str>) -> Option<String> {
    let raw = url?.trim();
    if raw.is_empty() {
        return None;
    }

    let mut parsed = Url::parse(raw).ok()?;
    parsed.set_fragment(None);
    let retained_pairs = parsed
        .query_pairs()
        .filter(|(key, _)| !key.starts_with("utm_") && key != "cmpid" && key != "ref")
        .map(|(key, value)| (key.to_string(), value.to_string()))
        .collect::<Vec<_>>();
    parsed
        .query_pairs_mut()
        .clear()
        .extend_pairs(retained_pairs);

    let normalized = parsed.to_string().trim_end_matches('?').to_string();
    Some(normalized.trim_end_matches('/').to_string())
}

fn normalize_text(value: &str) -> String {
    value
        .chars()
        .map(|character| {
            if character.is_ascii_alphanumeric() || character.is_ascii_whitespace() {
                character.to_ascii_lowercase()
            } else {
                ' '
            }
        })
        .collect::<String>()
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}

#[cfg(test)]
mod tests {
    use super::{extract_tickers, sentiment_label, sentiment_score};
    use crate::news::types::NewsSentiment;

    #[test]
    fn sentiment_label_should_return_bull_above_threshold() {
        let score = sentiment_score(
            "NVIDIA beats estimates and raises guidance",
            "Shares surge on strong demand",
        );

        assert_eq!(sentiment_label(score), NewsSentiment::Bull);
    }

    #[test]
    fn sentiment_label_should_return_bear_below_threshold() {
        let score = sentiment_score(
            "Company cuts guidance after fraud investigation",
            "Shares plunge on downgrade",
        );

        assert_eq!(sentiment_label(score), NewsSentiment::Bear);
    }

    #[test]
    fn extract_tickers_should_filter_stoplist_symbols() {
        let tickers = extract_tickers("FED mentions $NVDA and (TSLA)", "USA GDP CPI CEO ETF");

        assert_eq!(tickers, vec!["NVDA".to_string(), "TSLA".to_string()]);
    }
}
