//! Deterministic relationship inference across research notes.

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

use crate::research::types::{
    EvidenceBasis, LinkOrigin, LinkStrength, LinkType, NoteLink, NoteType, ResearchNote,
};
use crate::research::util::{generate_id, now_rfc3339};

#[must_use]
pub(crate) fn infer_links(notes: &[ResearchNote]) -> Vec<NoteLink> {
    let mut links = Vec::new();

    for left_index in 0..notes.len() {
        let left = &notes[left_index];
        for right in &notes[(left_index + 1)..] {
            if left.workspace_id != right.workspace_id || left.archived || right.archived {
                continue;
            }

            if let Some(link) = infer_pair(left, right) {
                links.push(link);
            }

            if let Some(link) = infer_reverse_pair(left, right) {
                links.push(link);
            }
        }
    }

    links
}

fn infer_pair(left: &ResearchNote, right: &ResearchNote) -> Option<NoteLink> {
    if left.note_type != NoteType::SourceReference
        && right.note_type == NoteType::SourceReference
        && left.source_id.as_deref() == right.source_id.as_deref()
        && left.source_id.is_some()
    {
        return Some(build_link(
            left,
            right,
            LinkType::SourcedBy,
            0.98,
            LinkStrength::Strong,
            EvidenceBasis::SharedSource,
        ));
    }

    if left.note_type == NoteType::ValuationPoint
        && is_valuation_dependency_target(right)
        && shares_keywords(left, right)
    {
        return Some(build_link(
            left,
            right,
            LinkType::ValuationDependsOn,
            0.8,
            LinkStrength::Strong,
            EvidenceBasis::Lexical,
        ));
    }

    if left.note_type == NoteType::Risk && is_thesis_family(right) && shares_keywords(left, right) {
        return Some(build_link(
            left,
            right,
            LinkType::RiskTo,
            0.76,
            LinkStrength::Strong,
            EvidenceBasis::Lexical,
        ));
    }

    if left.note_type == NoteType::Catalyst
        && is_thesis_family(right)
        && shares_keywords(left, right)
    {
        return Some(build_link(
            left,
            right,
            LinkType::CatalystFor,
            0.78,
            LinkStrength::Strong,
            EvidenceBasis::Temporal,
        ));
    }

    if left.note_type == NoteType::ScenarioAssumption
        && is_assumption_target(right)
        && shares_keywords(left, right)
    {
        return Some(build_link(
            left,
            right,
            LinkType::AssumptionFor,
            0.75,
            LinkStrength::Strong,
            EvidenceBasis::Structured,
        ));
    }

    if is_evidence_note(left) && is_claim_family(right) && shares_keywords(left, right) {
        if signals_contradiction(left, right) {
            return Some(build_link(
                left,
                right,
                LinkType::Contradicts,
                0.79,
                LinkStrength::Critical,
                EvidenceBasis::Lexical,
            ));
        }
        return Some(build_link(
            left,
            right,
            LinkType::Supports,
            0.72,
            LinkStrength::Strong,
            EvidenceBasis::Lexical,
        ));
    }

    if left.note_type == NoteType::ManagementSignal
        && matches!(
            right.note_type,
            NoteType::Fact | NoteType::ChannelCheck | NoteType::EventTakeaway
        )
        && shares_keywords(left, right)
        && signals_contradiction(left, right)
    {
        return Some(build_link(
            left,
            right,
            LinkType::ManagementVsReality,
            0.9,
            LinkStrength::Critical,
            EvidenceBasis::Temporal,
        ));
    }

    if left.note_type == right.note_type
        && left.ticker == right.ticker
        && text_similarity(left, right) > 0.94
        && left.revision != right.revision
    {
        let link_type = if left.updated_at <= right.updated_at {
            LinkType::Updates
        } else {
            LinkType::Supersedes
        };
        return Some(build_link(
            left,
            right,
            link_type,
            0.7,
            LinkStrength::Medium,
            EvidenceBasis::Structured,
        ));
    }

    if shares_keywords(left, right)
        && left.time_horizon.is_some()
        && right.time_horizon.is_some()
        && left.time_horizon != right.time_horizon
    {
        return Some(build_link(
            left,
            right,
            LinkType::TimeframeConflict,
            0.67,
            LinkStrength::Medium,
            EvidenceBasis::Temporal,
        ));
    }

    None
}

fn infer_reverse_pair(left: &ResearchNote, right: &ResearchNote) -> Option<NoteLink> {
    if left.note_type == NoteType::ManagementSignal
        && matches!(
            right.note_type,
            NoteType::Fact | NoteType::ChannelCheck | NoteType::EventTakeaway
        )
        && shares_keywords(left, right)
        && signals_contradiction(left, right)
    {
        return Some(build_link(
            right,
            left,
            LinkType::Contradicts,
            0.84,
            LinkStrength::Critical,
            EvidenceBasis::Temporal,
        ));
    }

    None
}

fn build_link(
    from: &ResearchNote,
    to: &ResearchNote,
    link_type: LinkType,
    confidence: f32,
    strength: LinkStrength,
    evidence_basis: EvidenceBasis,
) -> NoteLink {
    let now = now_rfc3339();
    NoteLink {
        id: generate_id("link"),
        workspace_id: from.workspace_id.clone(),
        from_note_id: from.id.clone(),
        to_note_id: to.id.clone(),
        link_type,
        directional: !matches!(link_type, LinkType::TimeframeConflict),
        confidence,
        strength,
        evidence_basis,
        created_by: LinkOrigin::Heuristic,
        created_at: now.clone(),
        updated_at: now,
        source_revision_pair: (from.revision, to.revision),
        stale: false,
        stale_reason: None,
    }
}

fn is_claim_family(note: &ResearchNote) -> bool {
    matches!(
        note.note_type,
        NoteType::Claim
            | NoteType::Thesis
            | NoteType::SubThesis
            | NoteType::Risk
            | NoteType::Catalyst
            | NoteType::MosaicInsight
            | NoteType::ManagementSignal
    )
}

fn is_evidence_note(note: &ResearchNote) -> bool {
    matches!(
        note.note_type,
        NoteType::Fact
            | NoteType::Quote
            | NoteType::EventTakeaway
            | NoteType::ChannelCheck
            | NoteType::IndustryObservation
            | NoteType::CompetitorComparison
            | NoteType::ManagementSignal
    )
}

fn is_thesis_family(note: &ResearchNote) -> bool {
    matches!(
        note.note_type,
        NoteType::Thesis | NoteType::SubThesis | NoteType::Claim | NoteType::MosaicInsight
    )
}

fn is_assumption_target(note: &ResearchNote) -> bool {
    matches!(
        note.note_type,
        NoteType::ValuationPoint | NoteType::Thesis | NoteType::SubThesis | NoteType::Risk
    )
}

fn is_valuation_dependency_target(note: &ResearchNote) -> bool {
    matches!(
        note.note_type,
        NoteType::Fact
            | NoteType::Catalyst
            | NoteType::ScenarioAssumption
            | NoteType::IndustryObservation
            | NoteType::ManagementSignal
            | NoteType::Claim
    )
}

fn shares_keywords(left: &ResearchNote, right: &ResearchNote) -> bool {
    let left_words = significant_words(&left.cleaned_text);
    let right_words = significant_words(&right.cleaned_text);

    left_words.intersection(&right_words).count() >= 2
        || left.tags.iter().any(|tag| right.tags.contains(tag))
        || left.ticker == right.ticker && left.ticker.is_some()
}

fn signals_contradiction(left: &ResearchNote, right: &ResearchNote) -> bool {
    let negative = [
        "decline",
        "weak",
        "pressure",
        "discount",
        "inventory",
        "miss",
        "soft",
    ];
    let positive = [
        "improve",
        "improved",
        "normalized",
        "strong",
        "reaccelerat",
        "above",
    ];
    let left_lower = left.cleaned_text.to_ascii_lowercase();
    let right_lower = right.cleaned_text.to_ascii_lowercase();

    has_any(&left_lower, &positive) && has_any(&right_lower, &negative)
        || has_any(&left_lower, &negative) && has_any(&right_lower, &positive)
}

fn significant_words(text: &str) -> HashSet<String> {
    static STOP_WORDS: std::sync::LazyLock<BTreeSet<&'static str>> =
        std::sync::LazyLock::new(|| {
            BTreeSet::from([
                "the", "and", "for", "with", "that", "from", "this", "next", "says", "said",
                "have", "has", "into", "above", "below", "about", "quarter", "company",
            ])
        });

    text.to_ascii_lowercase()
        .split(|character: char| !character.is_ascii_alphanumeric())
        .filter(|value| value.len() >= 4 && !STOP_WORDS.contains(*value))
        .map(ToOwned::to_owned)
        .collect()
}

fn text_similarity(left: &ResearchNote, right: &ResearchNote) -> f32 {
    let left_words = significant_words(&left.cleaned_text);
    let right_words = significant_words(&right.cleaned_text);
    if left_words.is_empty() || right_words.is_empty() {
        return 0.0;
    }

    let intersection = left_words.intersection(&right_words).count() as f32;
    let union = left_words.union(&right_words).count() as f32;
    intersection / union
}

fn has_any(input: &str, needles: &[&str]) -> bool {
    needles.iter().any(|needle| input.contains(needle))
}

#[cfg(test)]
mod tests {
    use crate::research::types::{
        AnalystStatus, EvidenceStatus, GhostStatus, NotePriority, NoteProvenance, NoteType,
        ProvenanceActor, ResearchNote, ThesisStatus,
    };
    use crate::research::util::{now_rfc3339, sha256_hex};

    use super::infer_links;

    fn note(id: &str, note_type: NoteType, text: &str) -> ResearchNote {
        let now = now_rfc3339();
        ResearchNote {
            id: id.to_string(),
            workspace_id: "workspace-1".to_string(),
            company_id: None,
            ticker: Some("AAPL".to_string()),
            source_id: None,
            raw_text: text.to_string(),
            cleaned_text: text.to_string(),
            body_html: None,
            embeds: Vec::new(),
            file_ids: Vec::new(),
            title: None,
            note_type,
            subtype: None,
            analyst_status: AnalystStatus::Captured,
            ai_annotation: None,
            confidence: 0.8,
            evidence_status: EvidenceStatus::Unsourced,
            inferred_links: Vec::new(),
            ghost_status: GhostStatus::None,
            thesis_status: ThesisStatus::None,
            created_at: now.clone(),
            updated_at: now.clone(),
            provenance: NoteProvenance {
                created_by: ProvenanceActor::Manual,
                capture_method: crate::research::types::CaptureMethod::QuickEntry,
                source_kind: crate::research::types::SourceKind::Manual,
                origin_note_id: None,
                origin_ghost_id: None,
                model_info: None,
                created_at: now,
                raw_input_hash: sha256_hex(text),
            },
            tags: Vec::new(),
            catalysts: Vec::new(),
            risks: Vec::new(),
            valuation_refs: Vec::new(),
            time_horizon: None,
            scenario: None,
            priority: NotePriority::Normal,
            pinned: false,
            archived: false,
            revision: 1,
            source_excerpt: None,
            last_enriched_at: None,
            last_linked_at: None,
            stale_reason: None,
            superseded_by_note_id: None,
        }
    }

    #[test]
    fn infer_links_should_create_management_vs_reality_for_conflicting_notes() {
        let links = infer_links(&[
            note(
                "mgmt",
                NoteType::ManagementSignal,
                "Management says inventory is now normalized.",
            ),
            note(
                "fact",
                NoteType::Fact,
                "Inventory days increased 12% sequentially and discounting remains elevated.",
            ),
        ]);

        assert!(links
            .iter()
            .any(|link| link.link_type == crate::research::types::LinkType::ManagementVsReality));
    }
}
