//! Heuristic duplicate detection across research notes.

use std::collections::BTreeSet;

use crate::research::types::{NoteType, ResearchNote};

#[must_use]
pub(crate) fn find_duplicate_groups(
    notes: &[ResearchNote],
    similarity_threshold: f32,
) -> Vec<Vec<String>> {
    let mut groups: Vec<Vec<String>> = Vec::new();
    let mut assigned = std::collections::HashSet::new();

    for left_index in 0..notes.len() {
        let left = &notes[left_index];
        if left.archived || assigned.contains(&left.id) {
            continue;
        }

        let mut group = vec![left.id.clone()];

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

            if notes_are_similar(left, right, similarity_threshold) {
                group.push(right.id.clone());
            }
        }

        if group.len() > 1 {
            for id in &group {
                assigned.insert(id.clone());
            }
            groups.push(group);
        }
    }

    groups
}

fn notes_are_similar(left: &ResearchNote, right: &ResearchNote, threshold: f32) -> bool {
    if left.note_type != right.note_type {
        return false;
    }

    if left.note_type == NoteType::SourceReference {
        return false;
    }

    let similarity = text_similarity(&left.cleaned_text, &right.cleaned_text);
    similarity >= threshold
}

fn text_similarity(left: &str, right: &str) -> f32 {
    let left_words = significant_words(left);
    let right_words = significant_words(right);
    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 significant_words(text: &str) -> BTreeSet<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()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn find_duplicate_groups_groups_near_identical_notes() {
        let notes = vec![
            test_note(
                "n1",
                "Gross margin expanded to 72% driven by enterprise mix shift.",
            ),
            test_note(
                "n2",
                "Gross margin expanded to 72% driven by enterprise mix shift.",
            ),
            test_note("n3", "Revenue grew 15% year over year in the quarter."),
        ];

        let groups = find_duplicate_groups(&notes, 0.85);
        assert_eq!(groups.len(), 1);
        assert!(groups[0].contains(&"n1".to_string()));
        assert!(groups[0].contains(&"n2".to_string()));
        assert!(!groups[0].contains(&"n3".to_string()));
    }

    #[test]
    fn find_duplicate_groups_skips_different_types() {
        let notes = vec![
            test_note_typed("n1", "Gross margin expanded.", NoteType::Fact),
            test_note_typed("n2", "Gross margin expanded.", NoteType::Risk),
        ];

        let groups = find_duplicate_groups(&notes, 0.85);
        assert!(groups.is_empty());
    }

    use crate::research::types::{
        AnalystStatus, EvidenceStatus, GhostStatus, NotePriority, NoteProvenance, NoteType,
        ProvenanceActor, ResearchNote, SourceKind, ThesisStatus,
    };
    use crate::research::util::{now_rfc3339, sha256_hex};

    fn test_note(id: &str, text: &str) -> ResearchNote {
        test_note_typed(id, text, NoteType::Fact)
    }

    fn test_note_typed(id: &str, text: &str, note_type: NoteType) -> ResearchNote {
        let now = now_rfc3339();
        ResearchNote {
            id: id.to_string(),
            workspace_id: "ws-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: 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,
        }
    }
}
