use std::collections::HashSet;

use regex::Regex;
use reqwest::StatusCode;
use scraper::{ElementRef, Html, Selector};
use url::Url;

use super::CrawlerError;

const EMAIL_REGEX: &str = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}";

pub fn extract_links(html: &Html, base_url: &Url) -> Vec<Url> {
    let Ok(selector) = Selector::parse("a[href]") else {
        return vec![];
    };
    html.select(&selector)
        .filter_map(|el| el.value().attr("href"))
        .filter_map(|href| Url::parse(href).or_else(|_| base_url.join(href)).ok())
        .collect()
}

/// Extract email addresses and phone numbers from `mailto:` and `tel:` anchor links.
pub fn extract_contact_links(html: &Html) -> (Vec<String>, Vec<String>) {
    let Ok(selector) = Selector::parse("a[href]") else {
        return (vec![], vec![]);
    };
    let hrefs: Vec<&str> = html
        .select(&selector)
        .filter_map(|el| el.value().attr("href"))
        .collect();
    let emails = unique_scheme_values(&hrefs, "mailto:", true);
    let phones = unique_scheme_values(&hrefs, "tel:", false);
    (emails, phones)
}

fn unique_scheme_values(hrefs: &[&str], prefix: &str, strip_query: bool) -> Vec<String> {
    let mut seen = HashSet::new();
    hrefs
        .iter()
        .filter_map(|href| href.strip_prefix(prefix))
        .map(|v| {
            if strip_query {
                v.split('?').next().unwrap_or(v)
            } else {
                v
            }
        })
        .map(str::trim)
        .filter(|v| !v.is_empty() && seen.insert(v.to_string()))
        .map(|v| v.to_string())
        .collect()
}

/// Extract all email addresses found anywhere in the visible text of the document.
pub fn extract_emails_from_html(html: &Html) -> Vec<String> {
    let full_text: String = html.root_element().text().collect();
    let Ok(re) = Regex::new(EMAIL_REGEX) else {
        return vec![];
    };
    let mut seen = HashSet::new();
    re.find_iter(&full_text)
        .map(|m| m.as_str().to_string())
        .filter(|e| seen.insert(e.clone()))
        .collect()
}

pub fn extract_text_content(html: &Html) -> String {
    log::debug!("Raw HTML input length: {} bytes", html.html().len());
    log::debug!(
        "Raw HTML preview: {}",
        &html.html().chars().take(500).collect::<String>()
    );

    let mut text = String::new();

    let selectors = vec![
        ("meta[name='description']", "content"),
        ("meta[property='og:description']", "content"),
        ("title", "text"),
        ("h1", "text"),
        ("h2", "text"),
        ("h3", "text"),
        (".content", "text"),
        (".main", "text"),
        ("article", "text"),
        ("p", "text"),
        ("address", "text"),
        ("footer", "text"),
    ];

    for (sel_str, attr_type) in selectors {
        if let Ok(selector) = Selector::parse(sel_str) {
            html.select(&selector)
                .take(10)
                .for_each(|el| append_element_text(&mut text, &el, attr_type));
        }
        if text.len() > 8000 {
            break;
        }
    }

    let result = text
        .split_whitespace()
        .take(1500)
        .collect::<Vec<_>>()
        .join(" ");

    log::debug!("Extracted plain text length: {} chars", result.len());
    log::debug!("Extracted plain text: {}", result);

    result
}

fn append_element_text(text: &mut String, element: &ElementRef, attr_type: &str) {
    if attr_type == "content" {
        if let Some(content) = element.value().attr("content") {
            text.push_str(content);
            text.push(' ');
        }
    } else {
        let content: String = element.text().collect();
        let trimmed = content.trim();
        if !trimmed.is_empty() {
            text.push_str(trimmed);
            text.push(' ');
        }
    }
}

pub fn is_not_found_error(error: &CrawlerError) -> bool {
    matches!(
        error,
        CrawlerError::ReqwestError(e) if e.status() == Some(StatusCode::NOT_FOUND)
    )
}

pub fn has_duplicate_segments(url: &Url) -> bool {
    let segments: Vec<&str> = url.path().split('/').filter(|s| !s.is_empty()).collect();
    segments.windows(2).any(|w| w[0] == w[1])
}

pub fn deduplicate_url_segments(url: &Url) -> Option<Url> {
    let mut segments: Vec<&str> = url.path().split('/').filter(|s| !s.is_empty()).collect();
    segments.dedup();

    let new_path = if segments.is_empty() {
        "/".to_string()
    } else {
        format!("/{}", segments.join("/"))
    };

    let mut new_url = url.clone();
    new_url.set_path(&new_path);
    Some(new_url)
}

/// Extend `base` with items from `extra`, skipping those already present.
pub fn merge_unique(base: &mut Vec<String>, extra: Vec<String>) {
    for item in extra {
        if !base.contains(&item) {
            base.push(item);
        }
    }
}

/// Parse a raw HTML string and return a formatted text entry suitable for
/// extraction: visible text combined with any emails and phone numbers found.
pub fn page_entry(url: &Url, html_str: &str) -> String {
    let html = Html::parse_document(html_str);
    let text = extract_text_content(&html);
    let (mut emails, phones) = extract_contact_links(&html);
    merge_unique(&mut emails, extract_emails_from_html(&html));
    let mut parts = vec![format!("URL: {}", url)];
    if !emails.is_empty() {
        parts.push(format!("Emails: {}", emails.join(", ")));
    }
    if !phones.is_empty() {
        parts.push(format!("Phones: {}", phones.join(", ")));
    }
    parts.push(text);
    sanitize_for_json(parts.join("\n"))
}

/// Parse a raw HTML string and return all links found on the page.
pub fn page_links(url: &Url, html_str: &str) -> Vec<Url> {
    let html = Html::parse_document(html_str);
    extract_links(&html, url)
}

#[cfg(feature = "query-helpers")]
pub fn is_tracking_query_key(key: &str) -> bool {
    let normalized = key.to_ascii_lowercase();
    normalized.starts_with("utm_")
        || matches!(
            normalized.as_str(),
            "fbclid" | "gclid" | "msclkid" | "mc_cid" | "mc_eid" | "ref" | "ref_src"
        )
}

#[cfg(feature = "query-helpers")]
pub fn meaningful_query_pairs<'a>(
    url: &'a Url,
) -> impl Iterator<Item = (std::borrow::Cow<'a, str>, std::borrow::Cow<'a, str>)> + 'a {
    url.query_pairs()
        .filter(|(key, _)| !is_tracking_query_key(key))
}

#[cfg(feature = "query-helpers")]
pub fn query_keys_allowed(url: &Url, allowed_keys: &[&str]) -> bool {
    let allowed = allowed_keys
        .iter()
        .map(|key| key.to_ascii_lowercase())
        .collect::<HashSet<_>>();
    meaningful_query_pairs(url).all(|(key, _)| allowed.contains(&key.to_ascii_lowercase()))
}

#[cfg(feature = "query-helpers")]
pub fn keyword_score(text: &str, weighted_keywords: &[(&str, i32)]) -> i32 {
    let normalized = text.to_ascii_lowercase();
    weighted_keywords
        .iter()
        .filter(|(keyword, _)| normalized.contains(&keyword.to_ascii_lowercase()))
        .map(|(_, score)| *score)
        .sum()
}

#[cfg(feature = "query-helpers")]
pub fn path_tokens(path: &str) -> Vec<String> {
    path.split('/')
        .filter(|segment| !segment.is_empty())
        .map(|segment| {
            segment
                .chars()
                .map(|ch| {
                    if ch.is_ascii_alphanumeric() {
                        ch.to_ascii_lowercase()
                    } else {
                        ' '
                    }
                })
                .collect::<String>()
        })
        .flat_map(|segment| {
            segment
                .split_whitespace()
                .filter(|token| !token.is_empty())
                .map(str::to_string)
                .collect::<Vec<_>>()
        })
        .collect()
}

#[cfg(feature = "query-helpers")]
pub fn token_overlap_score(candidate: &[String], reference: &[String]) -> i32 {
    let candidate = candidate.iter().cloned().collect::<HashSet<_>>();
    let reference = reference.iter().cloned().collect::<HashSet<_>>();
    candidate.intersection(&reference).count() as i32
}

#[cfg(feature = "query-helpers")]
pub fn best_path_similarity_score(candidate_path: &str, references: &[Vec<String>]) -> i32 {
    let candidate = path_tokens(candidate_path);
    references
        .iter()
        .map(|reference| token_overlap_score(&candidate, reference))
        .max()
        .unwrap_or(0)
}

#[cfg(feature = "query-helpers")]
fn query_key_tokens(url: &Url) -> Vec<String> {
    meaningful_query_pairs(url)
        .map(|(key, _)| key.to_ascii_lowercase())
        .collect()
}

#[cfg(feature = "query-helpers")]
#[derive(Clone, Debug, Default)]
pub struct UrlPatternMemory {
    path_patterns: Vec<Vec<String>>,
    query_key_patterns: Vec<Vec<String>>,
}

#[cfg(feature = "query-helpers")]
impl UrlPatternMemory {
    pub fn record_success(&mut self, url: &Url) {
        let path_pattern = path_tokens(url.path());
        if !path_pattern.is_empty() && !self.path_patterns.contains(&path_pattern) {
            self.path_patterns.push(path_pattern);
        }

        let query_pattern = query_key_tokens(url);
        if !query_pattern.is_empty() && !self.query_key_patterns.contains(&query_pattern) {
            self.query_key_patterns.push(query_pattern);
        }
    }

    pub fn has_signals(&self) -> bool {
        !(self.path_patterns.is_empty() && self.query_key_patterns.is_empty())
    }

    pub fn best_path_score(&self, url: &Url) -> i32 {
        best_path_similarity_score(url.path(), &self.path_patterns)
    }

    pub fn best_query_key_score(&self, url: &Url) -> i32 {
        let candidate = query_key_tokens(url);
        self.query_key_patterns
            .iter()
            .map(|reference| token_overlap_score(&candidate, reference))
            .max()
            .unwrap_or(0)
    }

    pub fn best_similarity_score(&self, url: &Url) -> i32 {
        self.best_path_score(url) + self.best_query_key_score(url)
    }
}

/// Strip null bytes and C0 control chars (except tab/newline/CR) that would
/// produce invalid JSON when the text is embedded in an API request body.
fn sanitize_for_json(s: String) -> String {
    s.chars()
        .filter(|&c| c >= ' ' || c == '\t' || c == '\n' || c == '\r')
        .collect()
}
