//! Source grounding helpers for notes and citations.

use regex::Regex;
use serde_json::json;

use crate::research::errors::Result;
use crate::research::heuristics::derive_title;
use crate::research::types::{
    AnalystStatus, EvidenceStatus, FreshnessBucket, NoteEmbedInput, NotePriority, NoteProvenance,
    NoteType, PreviewLinkRequest, PreviewLinkResult, ResearchNote, SourceExcerpt, SourceKind,
    SourceRecord, SourceReferenceInput,
};
use crate::research::util::{generate_id, now_rfc3339, sha256_hex};

pub(crate) fn build_source_record(
    workspace_id: &str,
    ticker: Option<&str>,
    input: &SourceReferenceInput,
) -> SourceRecord {
    let now = now_rfc3339();
    let title = input
        .title
        .clone()
        .or_else(|| input.url.as_deref().map(derive_title_from_url))
        .unwrap_or_else(|| "Attached source".to_string());

    SourceRecord {
        id: generate_id("source"),
        workspace_id: workspace_id.to_string(),
        kind: input.kind,
        ticker: ticker.map(ToOwned::to_owned),
        title,
        publisher: input.url.as_deref().and_then(extract_publisher),
        url: input.url.clone(),
        canonical_url: input.url.clone(),
        filing_accession: input.filing_accession.clone(),
        form_type: input.form_type.clone(),
        published_at: input.published_at.clone(),
        as_of_date: input.published_at.clone(),
        ingested_at: now,
        freshness_bucket: FreshnessBucket::Fresh,
        checksum: input.url.as_deref().map(sha256_hex),
        metadata_json: json!({
            "kind": input.kind,
            "locationLabel": input.location_label,
            "excerpt": input.excerpt_text,
        }),
        superseded_by_source_id: None,
    }
}

pub(crate) fn build_source_reference_note(
    workspace_id: &str,
    ticker: Option<&str>,
    source: &SourceRecord,
    excerpt: Option<&SourceExcerpt>,
) -> ResearchNote {
    let now = now_rfc3339();
    let raw_text = format!(
        "{}{}",
        source.title,
        source
            .url
            .as_deref()
            .map(|url| format!(" ({url})"))
            .unwrap_or_default()
    );

    ResearchNote {
        id: generate_id("note"),
        workspace_id: workspace_id.to_string(),
        company_id: None,
        ticker: ticker.map(ToOwned::to_owned),
        source_id: Some(source.id.clone()),
        raw_text: raw_text.clone(),
        cleaned_text: raw_text,
        body_html: None,
        embeds: Vec::new(),
        file_ids: Vec::new(),
        title: derive_title(&source.title, NoteType::SourceReference),
        note_type: NoteType::SourceReference,
        subtype: Some(format!("{:?}", source.kind).to_ascii_lowercase()),
        analyst_status: AnalystStatus::Accepted,
        ai_annotation: None,
        confidence: 1.0,
        evidence_status: EvidenceStatus::SourceLinked,
        inferred_links: Vec::new(),
        ghost_status: crate::research::types::GhostStatus::None,
        thesis_status: crate::research::types::ThesisStatus::None,
        created_at: now.clone(),
        updated_at: now.clone(),
        provenance: NoteProvenance {
            created_by: crate::research::types::ProvenanceActor::Import,
            capture_method: crate::research::types::CaptureMethod::ManualLink,
            source_kind: source.kind,
            origin_note_id: None,
            origin_ghost_id: None,
            model_info: None,
            created_at: now,
            raw_input_hash: sha256_hex(&source.title),
        },
        tags: vec!["source".to_string()],
        catalysts: Vec::new(),
        risks: Vec::new(),
        valuation_refs: Vec::new(),
        time_horizon: None,
        scenario: None,
        priority: NotePriority::Low,
        pinned: false,
        archived: false,
        revision: 1,
        source_excerpt: excerpt.cloned(),
        last_enriched_at: None,
        last_linked_at: None,
        stale_reason: None,
        superseded_by_note_id: None,
    }
}

pub(crate) fn source_excerpt_from_input(
    source_id: &str,
    input: &SourceReferenceInput,
) -> Option<SourceExcerpt> {
    if input.excerpt_text.is_none() && input.location_label.is_none() {
        return None;
    }

    Some(SourceExcerpt {
        source_id: source_id.to_string(),
        excerpt_text: input.excerpt_text.clone(),
        location_label: input.location_label.clone(),
        start_offset: None,
        end_offset: None,
    })
}

const JINA_READER_BASE: &str = "https://r.jina.ai/";

fn build_http_client() -> Result<reqwest::Client> {
    Ok(reqwest::Client::builder()
        .redirect(reqwest::redirect::Policy::limited(8))
        .user_agent(
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
             AppleWebKit/537.36 (KHTML, like Gecko) \
             Chrome/131.0.0.0 Safari/537.36",
        )
        .build()?)
}

struct RenderedPage {
    title: Option<String>,
    description: Option<String>,
}

async fn fetch_via_jina(url: &str) -> Option<RenderedPage> {
    let client = build_http_client().ok()?;
    let response = client
        .get(format!("{JINA_READER_BASE}{url}"))
        .header("Accept", "text/plain")
        .send()
        .await
        .ok()?;

    if !response.status().is_success() {
        return None;
    }

    let title = response
        .headers()
        .get("x-title")
        .and_then(|v| v.to_str().ok())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty() && !is_js_fallback_message(s));

    let description = response
        .headers()
        .get("x-description")
        .and_then(|v| v.to_str().ok())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty() && !is_js_fallback_message(s));

    let body = response.text().await.ok()?;

    let title = title.or_else(|| extract_first_heading(&body));
    let description = description.or_else(|| extract_first_paragraph(&body));

    Some(RenderedPage { title, description })
}

fn extract_first_heading(markdown: &str) -> Option<String> {
    static HEADING_RE: std::sync::LazyLock<Regex> =
        std::sync::LazyLock::new(|| Regex::new(r"(?m)^#{1,3}\s+(.+)$").unwrap());
    HEADING_RE
        .captures(markdown)
        .and_then(|caps| caps.get(1))
        .map(|m| m.as_str().trim().to_string())
        .filter(|s| !s.is_empty())
}

fn extract_first_paragraph(markdown: &str) -> Option<String> {
    markdown
        .lines()
        .map(|l| l.trim())
        .find(|l| {
            !l.is_empty()
                && !l.starts_with('#')
                && !l.starts_with('!')
                && !l.starts_with('[')
                && !l.starts_with('-')
                && !l.starts_with('>')
        })
        .map(|s| trim_to_char_boundary(s, 240).to_string())
}

struct TweetData {
    author_name: String,
    author_handle: String,
    text: String,
}

fn is_twitter_url(url: &str) -> bool {
    let lower = url.to_lowercase();
    lower.contains("twitter.com/") || lower.contains("x.com/")
}

fn extract_tweet_text(html: &str) -> String {
    static TWEET_P_RE: std::sync::LazyLock<Regex> =
        std::sync::LazyLock::new(|| Regex::new(r#"(?is)<p[^>]*>(.*?)</p>"#).unwrap());
    TWEET_P_RE
        .captures_iter(html)
        .filter_map(|caps| caps.get(1))
        .map(|m| {
            let text = strip_tags(m.as_str());
            decode_common_entities(&text).trim().to_string()
        })
        .filter(|s| !s.is_empty())
        .collect::<Vec<_>>()
        .join(" ")
}

async fn fetch_twitter_embed(url: &str) -> Option<TweetData> {
    let client = build_http_client().ok()?;
    let oembed_url = format!(
        "https://publish.twitter.com/oembed?url={}&omit_script=true",
        urlencoding::encode(url)
    );
    let response = client.get(&oembed_url).send().await.ok()?;
    if !response.status().is_success() {
        return None;
    }
    let json: serde_json::Value = response.json().await.ok()?;

    let author_name = json
        .get("author_name")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();

    let author_handle = json
        .get("author_url")
        .and_then(|v| v.as_str())
        .and_then(|u| u.trim_end_matches('/').rsplit('/').next())
        .unwrap_or("")
        .to_string();

    let html = json.get("html").and_then(|v| v.as_str()).unwrap_or("");
    let text = extract_tweet_text(html);

    Some(TweetData {
        author_name,
        author_handle,
        text,
    })
}

pub(crate) async fn refresh_source_metadata(source: &SourceRecord) -> Result<SourceRecord> {
    let Some(url) = source.url.as_deref() else {
        return Ok(source.clone());
    };

    let body = build_http_client()?.get(url).send().await?.text().await?;
    let og_title = extract_og_title(&body);
    let html_title = extract_html_title(&body);
    let og_desc = extract_og_description(&body);
    let html_excerpt = extract_html_excerpt(&body, 240);
    let title_is_fallback = og_title.is_none() && html_title.is_none();
    let excerpt_is_empty = og_desc.is_none() && html_excerpt.is_none();
    let title = og_title
        .or(html_title)
        .unwrap_or_else(|| source.title.clone());
    let excerpt = og_desc.or(html_excerpt);
    let image_url = extract_og_image(&body);

    let (title, excerpt) = if title_is_fallback && excerpt_is_empty {
        if let Some(rendered) = fetch_via_jina(url).await {
            (
                rendered.title.unwrap_or(title),
                rendered.description.or(excerpt),
            )
        } else {
            (title, excerpt)
        }
    } else {
        (title, excerpt)
    };

    let mut refreshed = source.clone();
    refreshed.title = title;
    refreshed.publisher = extract_publisher(url);
    refreshed.metadata_json = json!({
        "kind": refreshed.kind,
        "refreshedFromUrl": url,
        "excerpt": excerpt,
        "imageUrl": image_url,
    });
    Ok(refreshed)
}

pub(crate) async fn preview_link(request: PreviewLinkRequest) -> Result<PreviewLinkResult> {
    let url = request.url.trim();
    validate_preview_url(url)?;
    let kind = request.kind.unwrap_or(SourceKind::Article);

    if is_twitter_url(url) {
        if let Some(tweet) = fetch_twitter_embed(url).await {
            let publisher = if tweet.author_handle.is_empty() {
                Some("X (Twitter)".to_string())
            } else {
                Some(format!(
                    "{} (@{}) on X",
                    tweet.author_name, tweet.author_handle
                ))
            };
            let title = if tweet.text.is_empty() {
                format!("Post by {}", tweet.author_name)
            } else {
                trim_to_char_boundary(&tweet.text, 200).to_string()
            };
            let excerpt = if tweet.text.is_empty() {
                None
            } else {
                Some(tweet.text)
            };

            let embed = NoteEmbedInput {
                url: url.to_string(),
                canonical_url: Some(url.to_string()),
                title: title.clone(),
                publisher,
                excerpt: excerpt.clone(),
                kind,
                image_url: None,
            };

            return Ok(PreviewLinkResult {
                source_ref: SourceReferenceInput {
                    kind,
                    title: Some(title.clone()),
                    url: Some(url.to_string()),
                    published_at: None,
                    filing_accession: None,
                    form_type: None,
                    excerpt_text: excerpt.clone(),
                    location_label: None,
                },
                embed,
                suggested_title: Some(title.clone()),
                suggested_body_text: excerpt.or(Some(title)),
            });
        }
    }

    let response = build_http_client()?.get(url).send().await?;
    let final_url = response.url().to_string();
    let body = response.text().await?;

    let og_title = extract_og_title(&body);
    let html_title = extract_html_title(&body);
    let title_is_fallback = og_title.is_none() && html_title.is_none();
    let title = og_title
        .or(html_title)
        .unwrap_or_else(|| derive_title_from_url(&final_url));
    let publisher = extract_publisher(&final_url);
    let og_desc = extract_og_description(&body);
    let html_excerpt = extract_html_excerpt(&body, 240);
    let excerpt_is_empty = og_desc.is_none() && html_excerpt.is_none();
    let excerpt = og_desc.or(html_excerpt);
    let image_url = extract_og_image(&body);

    let (title, excerpt) = if title_is_fallback && excerpt_is_empty {
        if let Some(rendered) = fetch_via_jina(&final_url).await {
            (
                rendered.title.unwrap_or(title),
                rendered.description.or(excerpt),
            )
        } else {
            (title, excerpt)
        }
    } else {
        (title, excerpt)
    };

    let embed = NoteEmbedInput {
        url: final_url.clone(),
        canonical_url: Some(final_url.clone()),
        title: title.clone(),
        publisher,
        excerpt: excerpt.clone(),
        kind,
        image_url,
    };

    Ok(PreviewLinkResult {
        source_ref: SourceReferenceInput {
            kind,
            title: Some(title.clone()),
            url: Some(final_url),
            published_at: None,
            filing_accession: None,
            form_type: None,
            excerpt_text: excerpt.clone(),
            location_label: None,
        },
        embed,
        suggested_title: Some(title.clone()),
        suggested_body_text: excerpt.or(Some(title)),
    })
}

fn validate_preview_url(url: &str) -> Result<()> {
    if url.starts_with("https://") || url.starts_with("http://") {
        Ok(())
    } else {
        Err(crate::research::errors::ResearchError::Validation(
            "link previews require an http or https URL".to_string(),
        ))
    }
}

fn derive_title_from_url(url: &str) -> String {
    let mut trimmed = url
        .trim_start_matches("https://")
        .trim_start_matches("http://")
        .trim_end_matches('/');
    if let Some((host, path)) = trimmed.split_once('/') {
        trimmed = if path.is_empty() { host } else { path };
    }

    trimmed.replace('-', " ")
}

pub(crate) fn extract_publisher(url: &str) -> Option<String> {
    let without_scheme = url
        .trim_start_matches("https://")
        .trim_start_matches("http://");
    let host = without_scheme.split('/').next().unwrap_or_default();
    if host.is_empty() {
        None
    } else {
        Some(host.to_string())
    }
}

fn extract_html_title(body: &str) -> Option<String> {
    static TITLE_REGEX: std::sync::LazyLock<Regex> =
        std::sync::LazyLock::new(|| Regex::new(r"(?is)<title>(.*?)</title>").unwrap());
    TITLE_REGEX
        .captures(body)
        .and_then(|captures| {
            captures
                .get(1)
                .map(|value| value.as_str().trim().to_string())
        })
        .filter(|value| !value.is_empty())
        .filter(|value| !is_js_fallback_message(value))
}

fn extract_meta_content(body: &str, attr_name: &str, attr_value: &str) -> Option<String> {
    let pattern = format!(
        r#"(?is)<meta\s+[^>]*{attr_name}\s*=\s*["']{}["'][^>]*\bcontent\s*=\s*["']([^"']*)["']"#,
        regex::escape(attr_value)
    );
    if let Ok(re) = Regex::new(&pattern) {
        if let Some(caps) = re.captures(body) {
            if let Some(m) = caps.get(1) {
                let v = m.as_str().trim().to_string();
                if !v.is_empty() {
                    return Some(v);
                }
            }
        }
    }
    let pattern2 = format!(
        r#"(?is)<meta\s+[^>]*\bcontent\s*=\s*["']([^"']*)["'][^>]*{attr_name}\s*=\s*["']{}["']"#,
        regex::escape(attr_value)
    );
    if let Ok(re) = Regex::new(&pattern2) {
        if let Some(caps) = re.captures(body) {
            if let Some(m) = caps.get(1) {
                let v = m.as_str().trim().to_string();
                if !v.is_empty() {
                    return Some(v);
                }
            }
        }
    }
    None
}

fn extract_og_title(body: &str) -> Option<String> {
    extract_meta_content(body, "property", "og:title")
        .or_else(|| extract_meta_content(body, "name", "og:title"))
        .or_else(|| extract_meta_content(body, "property", "twitter:title"))
        .or_else(|| extract_meta_content(body, "name", "twitter:title"))
}

fn extract_og_description(body: &str) -> Option<String> {
    extract_meta_content(body, "property", "og:description")
        .or_else(|| extract_meta_content(body, "name", "og:description"))
        .or_else(|| extract_meta_content(body, "name", "description"))
        .or_else(|| extract_meta_content(body, "property", "twitter:description"))
        .or_else(|| extract_meta_content(body, "name", "twitter:description"))
}

fn extract_og_image(body: &str) -> Option<String> {
    extract_meta_content(body, "property", "og:image")
        .or_else(|| extract_meta_content(body, "property", "og:image:url"))
        .or_else(|| extract_meta_content(body, "name", "twitter:image"))
        .or_else(|| extract_meta_content(body, "property", "twitter:image:src"))
}

fn is_js_fallback_message(text: &str) -> bool {
    static JS_FALLBACK_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
        Regex::new(r"(?i)javascript\s+is\s+not\s+available|enable\s+js|enable\s+javascript|disable\s+ad\s+blocker|requires?\s+javascript|please\s+enable\s+javascript|you\s+need\s+to\s+enable\s+javascript|javascript\s+is\s+disabled|javascript\s+must\s+be\s+enabled|javascript\s+required|detected\s+that\s+javascript|javascript\s+needs?\s+to\s+be").unwrap()
    });
    JS_FALLBACK_REGEX.is_match(text)
}

fn extract_html_excerpt(body: &str, max_chars: usize) -> Option<String> {
    let without_noscript = strip_tag_blocks(body, "noscript");
    let without_scripts = strip_tag_blocks(&without_noscript, "script");
    let without_styles = strip_tag_blocks(&without_scripts, "style");
    let without_tags = strip_tags(&without_styles);
    let decoded = decode_common_entities(&without_tags);
    let collapsed = decoded.split_whitespace().collect::<Vec<_>>().join(" ");
    let trimmed = collapsed.trim();
    if trimmed.is_empty() || is_js_fallback_message(trimmed) {
        return None;
    }

    Some(trim_to_char_boundary(trimmed, max_chars).to_string())
}

fn strip_tag_blocks(input: &str, tag: &str) -> String {
    let pattern = format!(r"(?is)<{tag}\b[^>]*>.*?</{tag}>");
    Regex::new(&pattern)
        .map(|regex| regex.replace_all(input, " ").into_owned())
        .unwrap_or_else(|_| input.to_string())
}

fn strip_tags(input: &str) -> String {
    static TAG_REGEX: std::sync::LazyLock<Regex> =
        std::sync::LazyLock::new(|| Regex::new(r"(?is)<[^>]+>").unwrap());
    TAG_REGEX.replace_all(input, " ").into_owned()
}

fn decode_common_entities(input: &str) -> String {
    input
        .replace("&nbsp;", " ")
        .replace("&amp;", "&")
        .replace("&quot;", "\"")
        .replace("&#39;", "'")
        .replace("&apos;", "'")
        .replace("&lt;", "<")
        .replace("&gt;", ">")
}

fn trim_to_char_boundary(input: &str, max_chars: usize) -> &str {
    if input.chars().count() <= max_chars {
        return input;
    }

    let end = input
        .char_indices()
        .nth(max_chars)
        .map(|(index, _)| index)
        .unwrap_or(input.len());
    input[..end].trim_end()
}
