//! Provisional synthesis generation over note clusters and tensions.

use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::sync::Arc;

use crate::research::ai::{GhostFileContext, ResearchAiGateway};
use crate::research::types::{
    EvidenceStatus, GhostFreshnessRisk, GhostLifecycleState, GhostNote, GhostNoteClass,
    GhostRecommendedAction, GhostResolutionStatus, GhostSupportStrength, GhostTargetClaimKind,
    GhostTone, GhostVisibilityState, LinkType, MemoSectionKind, NoteLink, NoteType,
    ResearchFilePreviewKind, ResearchNote, ResearchWorkspace,
};
use crate::research::util::{now_rfc3339, sha256_hex};

#[must_use]
pub(crate) fn generate_ghost_notes(
    workspace: &ResearchWorkspace,
    notes: &[ResearchNote],
    links: &[NoteLink],
) -> Vec<GhostNote> {
    let mut ghosts = Vec::new();
    ghosts.extend(generate_conviction_interventions(workspace, notes, links));
    ghosts.extend(generate_contradiction_alerts(workspace, notes, links));
    ghosts.extend(generate_candidate_risks(workspace, notes));
    ghosts.extend(generate_candidate_catalysts(workspace, notes));
    ghosts.extend(generate_valuation_bridges(workspace, notes));
    if let Some(thesis) = generate_candidate_thesis(workspace, notes, links) {
        ghosts.push(thesis);
    }

    rank_and_limit_visibility(&mut ghosts);
    ghosts
}

struct GhostDraft {
    ghost_class: GhostNoteClass,
    supporting_note_ids: Vec<String>,
    contradicting_note_ids: Vec<String>,
    source_ids: Vec<String>,
    headline: String,
    body: String,
    confidence: f32,
    evidence_threshold_met: bool,
    visibility_state: GhostVisibilityState,
    memo_section_hint: Option<MemoSectionKind>,
    target_note_id: Option<String>,
    parent_thesis_note_id: Option<String>,
    target_claim_text: String,
    target_claim_kind: GhostTargetClaimKind,
    why_it_matters: String,
    current_support_summary: String,
    counter_case: String,
    recommended_action: GhostRecommendedAction,
    recommended_action_rationale: String,
    resolution_status: GhostResolutionStatus,
    leverage_score: f32,
    support_strength: GhostSupportStrength,
    freshness_risk: GhostFreshnessRisk,
}

#[derive(Debug, Clone, PartialEq)]
pub(crate) struct GhostResolvedFileContext {
    pub file_id: String,
    pub original_name: String,
    pub preview_kind: ResearchFilePreviewKind,
    pub extracted_text: Option<String>,
    pub extracted_text_truncated: bool,
}

fn truncate_text(s: &str, max: usize) -> &str {
    if s.len() <= max {
        return s;
    }
    let mut boundary = max;
    while boundary > 0 && !s.is_char_boundary(boundary) {
        boundary -= 1;
    }
    &s[..boundary]
}

fn generate_conviction_interventions(
    workspace: &ResearchWorkspace,
    notes: &[ResearchNote],
    links: &[NoteLink],
) -> Vec<GhostNote> {
    let thesis_ids = notes
        .iter()
        .filter(|note| matches!(note.note_type, NoteType::Thesis | NoteType::SubThesis))
        .map(|note| note.id.as_str())
        .collect::<BTreeSet<_>>();

    let mut candidates = notes
        .iter()
        .filter(|note| {
            matches!(
                note.note_type,
                NoteType::Claim
                    | NoteType::Thesis
                    | NoteType::SubThesis
                    | NoteType::Risk
                    | NoteType::Catalyst
                    | NoteType::ValuationPoint
                    | NoteType::ScenarioAssumption
            )
        })
        .map(|note| {
            let parent_thesis_note_id = resolve_parent_thesis_id(note, links, &thesis_ids);
            let support_links = links
                .iter()
                .filter(|link| {
                    link.from_note_id == note.id
                        && matches!(link.link_type, LinkType::SourcedBy | LinkType::Supports)
                })
                .count();
            let contradiction_links = links
                .iter()
                .filter(|link| {
                    (link.from_note_id == note.id || link.to_note_id == note.id)
                        && matches!(
                            link.link_type,
                            LinkType::Contradicts | LinkType::ManagementVsReality
                        )
                })
                .count();
            let support_strength = support_strength_for(note, support_links, contradiction_links);
            let ghost_class =
                conviction_ghost_class_for(note, support_links, contradiction_links, support_strength);
            let leverage_score =
                leverage_score_for(note, parent_thesis_note_id.as_deref(), support_links, contradiction_links);
            let claim_excerpt_headline = truncate_text(&note.cleaned_text, 80);
            let claim_excerpt_body = truncate_text(&note.cleaned_text, 120);
            let why_it_matters =
                why_it_matters_for(note.note_type, parent_thesis_note_id.as_deref());
            ghost(
                workspace,
                GhostDraft {
                    ghost_class,
                    supporting_note_ids: vec![note.id.clone()],
                    contradicting_note_ids: Vec::new(),
                    source_ids: note.source_id.iter().cloned().collect(),
                    headline: format!("Pressure-test: \"{}\"", claim_excerpt_headline),
                    body: format!(
                        "Targeted point: \"{}\". Counter-case: the claim may be directionally useful, but conviction should stay limited until the support quality, contradictory evidence, and thesis dependency are explicit.",
                        claim_excerpt_body
                    ),
                    confidence: (0.45 + leverage_score * 0.4).min(0.9),
                    evidence_threshold_met: matches!(support_strength, GhostSupportStrength::Strong),
                    visibility_state: GhostVisibilityState::Visible,
                    memo_section_hint: None,
                    target_note_id: Some(note.id.clone()),
                    parent_thesis_note_id,
                    target_claim_text: note.cleaned_text.clone(),
                    target_claim_kind: target_claim_kind_for(note.note_type),
                    why_it_matters,
                    current_support_summary: support_summary_for(
                        note,
                        support_links,
                        contradiction_links,
                        support_strength,
                    ),
                    counter_case: counter_case_for(note.note_type, &note.cleaned_text),
                    recommended_action: recommended_action_for(
                        ghost_class,
                        support_strength,
                        contradiction_links,
                    ),
                    recommended_action_rationale: recommended_action_rationale_for(
                        ghost_class,
                        support_strength,
                        contradiction_links,
                    ),
                    resolution_status: GhostResolutionStatus::Open,
                    leverage_score,
                    support_strength,
                    freshness_risk: freshness_risk_for(note),
                },
            )
        })
        .collect::<Vec<_>>();

    candidates.sort_by(|left, right| right.leverage_score.total_cmp(&left.leverage_score));
    candidates.into_iter().take(5).collect()
}

fn resolve_parent_thesis_id(
    note: &ResearchNote,
    links: &[NoteLink],
    thesis_ids: &BTreeSet<&str>,
) -> Option<String> {
    if thesis_ids.contains(note.id.as_str()) {
        return Some(note.id.clone());
    }

    links.iter().find_map(|link| {
        if link.from_note_id == note.id && thesis_ids.contains(link.to_note_id.as_str()) {
            Some(link.to_note_id.clone())
        } else if link.to_note_id == note.id && thesis_ids.contains(link.from_note_id.as_str()) {
            Some(link.from_note_id.clone())
        } else {
            None
        }
    })
}

fn support_strength_for(
    note: &ResearchNote,
    support_links: usize,
    contradiction_links: usize,
) -> GhostSupportStrength {
    if contradiction_links > 0 {
        return GhostSupportStrength::Mixed;
    }
    match note.evidence_status {
        EvidenceStatus::Corroborated | EvidenceStatus::Quoted => GhostSupportStrength::Strong,
        EvidenceStatus::SourceLinked | EvidenceStatus::Inferred if support_links > 0 => {
            GhostSupportStrength::Weak
        }
        EvidenceStatus::Contradicted => GhostSupportStrength::Mixed,
        _ if support_links > 1 => GhostSupportStrength::Strong,
        _ if support_links == 1 || note.source_id.is_some() => GhostSupportStrength::Weak,
        _ => GhostSupportStrength::None,
    }
}

fn conviction_ghost_class_for(
    note: &ResearchNote,
    support_links: usize,
    contradiction_links: usize,
    support_strength: GhostSupportStrength,
) -> GhostNoteClass {
    if contradiction_links > 0 || matches!(note.evidence_status, EvidenceStatus::Contradicted) {
        return GhostNoteClass::ContradictionResolution;
    }
    if looks_numeric(&note.cleaned_text)
        && !matches!(support_strength, GhostSupportStrength::Strong)
    {
        return GhostNoteClass::NumericalClaimVerification;
    }
    if matches!(note.note_type, NoteType::Thesis | NoteType::SubThesis) && support_links == 0 {
        return GhostNoteClass::ThesisDependencyGap;
    }
    match support_strength {
        GhostSupportStrength::None => GhostNoteClass::ClaimSupportGap,
        GhostSupportStrength::Weak | GhostSupportStrength::Mixed => {
            GhostNoteClass::EvidenceQualityWarning
        }
        GhostSupportStrength::Strong => GhostNoteClass::ClaimPressureTest,
    }
}

fn looks_numeric(text: &str) -> bool {
    text.chars().any(|ch| ch.is_ascii_digit())
        || text.contains('%')
        || text.contains('$')
        || text.to_ascii_lowercase().contains("bps")
}

fn leverage_score_for(
    note: &ResearchNote,
    parent_thesis_note_id: Option<&str>,
    support_links: usize,
    contradiction_links: usize,
) -> f32 {
    let mut score: f32 = match note.note_type {
        NoteType::Thesis => 1.0,
        NoteType::SubThesis => 0.92,
        NoteType::Claim | NoteType::ValuationPoint | NoteType::ScenarioAssumption => 0.78,
        NoteType::Risk | NoteType::Contradiction => 0.74,
        NoteType::Catalyst => 0.68,
        _ => 0.45,
    };
    if parent_thesis_note_id.is_some() {
        score += 0.12;
    }
    if contradiction_links > 0 {
        score += 0.14;
    }
    if support_links == 0 {
        score += 0.08;
    }
    score.min(1.0)
}

fn target_claim_kind_for(note_type: NoteType) -> GhostTargetClaimKind {
    match note_type {
        NoteType::Thesis => GhostTargetClaimKind::CoreThesis,
        NoteType::SubThesis | NoteType::Claim => GhostTargetClaimKind::SubClaim,
        NoteType::Risk | NoteType::Contradiction => GhostTargetClaimKind::Risk,
        NoteType::Catalyst | NoteType::EventTakeaway => GhostTargetClaimKind::Catalyst,
        NoteType::ScenarioAssumption | NoteType::ValuationPoint => GhostTargetClaimKind::Assumption,
        _ => GhostTargetClaimKind::SupportingPoint,
    }
}

fn why_it_matters_for(note_type: NoteType, parent_thesis_note_id: Option<&str>) -> String {
    let dependency = if parent_thesis_note_id.is_some() {
        " It is connected to the active thesis tree, so resolving it can change conviction."
    } else {
        " It is not yet clearly anchored to a thesis, so the first question is whether it deserves that role."
    };
    let lead = match note_type {
        NoteType::Thesis => {
            "This is a core thesis point; weak support limits conviction in the whole case."
        }
        NoteType::SubThesis | NoteType::Claim => {
            "This claim appears to carry part of the investment argument."
        }
        NoteType::Risk | NoteType::Contradiction => {
            "This risk can cap conviction unless the magnitude and probability are bounded."
        }
        NoteType::Catalyst => {
            "This catalyst matters only if timing, mechanism, and observable proof are specific."
        }
        NoteType::ValuationPoint | NoteType::ScenarioAssumption => {
            "This assumption can move the valuation bridge and should be tested before promotion."
        }
        _ => "This point may influence the research case if it becomes linked to the thesis.",
    };
    format!("{lead}{dependency}")
}

fn support_summary_for(
    note: &ResearchNote,
    support_links: usize,
    contradiction_links: usize,
    support_strength: GhostSupportStrength,
) -> String {
    format!(
        "Support is {:?}: {} linked support item(s), {} contradiction(s), evidence status {:?}, {} direct source reference(s).",
        support_strength,
        support_links,
        contradiction_links,
        note.evidence_status,
        usize::from(note.source_id.is_some())
    )
}

fn counter_case_for(note_type: NoteType, claim: &str) -> String {
    let excerpt = truncate_text(claim, 180);
    match note_type {
        NoteType::Risk | NoteType::Contradiction => format!(
            "The downside may be overstated if \"{}\" is isolated, stale, or already reflected in expectations.",
            excerpt
        ),
        NoteType::Catalyst => format!(
            "The catalyst may fail to build conviction if \"{}\" lacks a dated mechanism or measurable KPI.",
            excerpt
        ),
        NoteType::ValuationPoint | NoteType::ScenarioAssumption => format!(
            "The valuation impact may not hold if \"{}\" depends on an untested assumption or unsupported multiple bridge.",
            excerpt
        ),
        _ => format!(
            "The claim may be true but non-decisive, or it may be directionally right while the market already discounts it: \"{}\".",
            excerpt
        ),
    }
}

fn recommended_action_for(
    ghost_class: GhostNoteClass,
    support_strength: GhostSupportStrength,
    contradiction_links: usize,
) -> GhostRecommendedAction {
    if contradiction_links > 0 || matches!(ghost_class, GhostNoteClass::ContradictionResolution) {
        return GhostRecommendedAction::ResolveContradiction;
    }
    match (ghost_class, support_strength) {
        (GhostNoteClass::NumericalClaimVerification, _) => {
            GhostRecommendedAction::FindPrimarySource
        }
        (_, GhostSupportStrength::None) => GhostRecommendedAction::FindPrimarySource,
        (_, GhostSupportStrength::Weak) => GhostRecommendedAction::LinkExistingNote,
        (_, GhostSupportStrength::Mixed) => GhostRecommendedAction::ResolveContradiction,
        (_, GhostSupportStrength::Strong) => GhostRecommendedAction::RewriteClaim,
    }
}

fn recommended_action_rationale_for(
    ghost_class: GhostNoteClass,
    support_strength: GhostSupportStrength,
    contradiction_links: usize,
) -> String {
    if contradiction_links > 0 || matches!(ghost_class, GhostNoteClass::ContradictionResolution) {
        return "Resolve the conflicting evidence before using this point to build conviction."
            .to_string();
    }
    match support_strength {
        GhostSupportStrength::None => {
            "Find a primary source before treating this point as evidence.".to_string()
        }
        GhostSupportStrength::Weak => {
            "Attach a stronger existing note or source excerpt so the point can graduate from assertion to support.".to_string()
        }
        GhostSupportStrength::Mixed => {
            "Separate what is supported from what is contradicted before promoting the claim.".to_string()
        }
        GhostSupportStrength::Strong => {
            "The evidence exists; tighten the claim so it states only what the support can carry.".to_string()
        }
    }
}

fn freshness_risk_for(note: &ResearchNote) -> GhostFreshnessRisk {
    match note.evidence_status {
        EvidenceStatus::Stale | EvidenceStatus::Superseded => GhostFreshnessRisk::High,
        EvidenceStatus::Inferred | EvidenceStatus::Unsourced => GhostFreshnessRisk::Medium,
        _ => GhostFreshnessRisk::Low,
    }
}

fn generate_contradiction_alerts(
    workspace: &ResearchWorkspace,
    notes: &[ResearchNote],
    links: &[NoteLink],
) -> Vec<GhostNote> {
    let notes_by_id = notes
        .iter()
        .map(|note| (note.id.as_str(), note))
        .collect::<BTreeMap<_, _>>();
    links.iter()
        .filter(|link| matches!(link.link_type, LinkType::Contradicts | LinkType::ManagementVsReality))
        .filter_map(|link| {
            let left = notes_by_id.get(link.from_note_id.as_str())?;
            let right = notes_by_id.get(link.to_note_id.as_str())?;
            Some(ghost(
                workspace,
                GhostDraft {
                    ghost_class: GhostNoteClass::ContradictionAlert,
                    supporting_note_ids: vec![left.id.clone(), right.id.clone()],
                    contradicting_note_ids: vec![right.id.clone()],
                    source_ids: collect_source_ids([*left, *right]),
                    headline: "Contradiction alert".to_string(),
                    body: format!(
                        "Observed: {}. Inference: this conflicts with {}. What would confirm/refute: reconcile the newer datapoint, source freshness, and management framing before treating either statement as settled.",
                        left.cleaned_text, right.cleaned_text
                    ),
                    confidence: 0.86,
                    evidence_threshold_met: true,
                    visibility_state: GhostVisibilityState::Visible,
                    memo_section_hint: Some(MemoSectionKind::RiskRegister),
                    target_note_id: Some(left.id.clone()),
                    parent_thesis_note_id: None,
                    target_claim_text: left.cleaned_text.clone(),
                    target_claim_kind: GhostTargetClaimKind::Risk,
                    why_it_matters: "This contradiction can materially change conviction unless the source freshness and operating reality are reconciled.".to_string(),
                    current_support_summary: "The point has direct conflicting evidence and should be resolved before promotion.".to_string(),
                    counter_case: format!("The apparent conflict may be a timing mismatch, scope mismatch, or evidence-quality issue rather than a true thesis break: \"{}\" versus \"{}\".", truncate_text(&left.cleaned_text, 100), truncate_text(&right.cleaned_text, 100)),
                    recommended_action: GhostRecommendedAction::ResolveContradiction,
                    recommended_action_rationale: "Compare the two notes, prioritize fresher primary evidence, and decide which claim should carry the thesis.".to_string(),
                    resolution_status: GhostResolutionStatus::Open,
                    leverage_score: 0.9,
                    support_strength: GhostSupportStrength::Mixed,
                    freshness_risk: GhostFreshnessRisk::Medium,
                },
            ))
        })
        .collect()
}

fn generate_candidate_risks(
    workspace: &ResearchWorkspace,
    notes: &[ResearchNote],
) -> Vec<GhostNote> {
    let risk_notes = notes
        .iter()
        .filter(|note| {
            matches!(
                note.note_type,
                NoteType::Risk | NoteType::Contradiction | NoteType::ChannelCheck
            )
        })
        .collect::<Vec<_>>();
    if risk_notes.len() < 3 {
        return Vec::new();
    }

    let supporting_ids = risk_notes
        .iter()
        .map(|note| note.id.clone())
        .collect::<Vec<_>>();
    vec![ghost(
        workspace,
        GhostDraft {
            ghost_class: GhostNoteClass::CandidateRisk,
            supporting_note_ids: supporting_ids,
            contradicting_note_ids: Vec::new(),
            source_ids: collect_source_ids(risk_notes.into_iter()),
            headline: "Possible emerging risk cluster".to_string(),
            body: "Observed: several notes point to downside pressure around the same operating area. Inference: this may represent an investable risk theme, but it should remain provisional until the source trail is tightened. What would confirm/refute: corroborate with fresh operating evidence or management disclosures.".to_string(),
            confidence: 0.73,
            evidence_threshold_met: true,
            visibility_state: GhostVisibilityState::Visible,
            memo_section_hint: Some(MemoSectionKind::RiskRegister),
            target_note_id: None,
            parent_thesis_note_id: None,
            target_claim_text: "Emerging risk cluster".to_string(),
            target_claim_kind: GhostTargetClaimKind::Risk,
            why_it_matters: "A repeated risk pattern can cap conviction unless magnitude, timing, and mitigation are bounded.".to_string(),
            current_support_summary: "Multiple risk-oriented notes exist, but the cluster still needs explicit thesis impact.".to_string(),
            counter_case: "The cluster may reflect repeated framing of the same issue rather than independent risk evidence.".to_string(),
            recommended_action: GhostRecommendedAction::RewriteClaim,
            recommended_action_rationale: "Turn the cluster into a bounded risk claim with probability, magnitude, and evidence threshold.".to_string(),
            resolution_status: GhostResolutionStatus::Open,
            leverage_score: 0.72,
            support_strength: GhostSupportStrength::Weak,
            freshness_risk: GhostFreshnessRisk::Low,
        },
    )]
}

fn generate_candidate_catalysts(
    workspace: &ResearchWorkspace,
    notes: &[ResearchNote],
) -> Vec<GhostNote> {
    let catalyst_notes = notes
        .iter()
        .filter(|note| {
            matches!(
                note.note_type,
                NoteType::Catalyst | NoteType::EventTakeaway | NoteType::ManagementSignal
            )
        })
        .collect::<Vec<_>>();
    if catalyst_notes.len() < 2 {
        return Vec::new();
    }

    let supporting_ids = catalyst_notes
        .iter()
        .map(|note| note.id.clone())
        .collect::<Vec<_>>();
    vec![ghost(
        workspace,
        GhostDraft {
            ghost_class: GhostNoteClass::CandidateCatalyst,
            supporting_note_ids: supporting_ids,
            contradicting_note_ids: Vec::new(),
            source_ids: collect_source_ids(catalyst_notes.into_iter()),
            headline: "Possible catalyst cluster".to_string(),
            body: "Observed: multiple notes point toward an identifiable event or operating trigger. Inference: this could matter for the next stock move if timing and evidence quality hold. What would confirm/refute: map the catalyst to dated milestones and an observable KPI.".to_string(),
            confidence: 0.7,
            evidence_threshold_met: true,
            visibility_state: GhostVisibilityState::Visible,
            memo_section_hint: Some(MemoSectionKind::CatalystCalendar),
            target_note_id: None,
            parent_thesis_note_id: None,
            target_claim_text: "Possible catalyst cluster".to_string(),
            target_claim_kind: GhostTargetClaimKind::Catalyst,
            why_it_matters: "A catalyst only builds conviction if it has timing, mechanism, and observable proof.".to_string(),
            current_support_summary: "Multiple catalyst-oriented notes exist; the next step is to connect them to a dated milestone or KPI.".to_string(),
            counter_case: "The event may be real but already anticipated, poorly timed, or too small to change the thesis.".to_string(),
            recommended_action: GhostRecommendedAction::RewriteClaim,
            recommended_action_rationale: "State the catalyst as a testable event with expected mechanism and measurable outcome.".to_string(),
            resolution_status: GhostResolutionStatus::Open,
            leverage_score: 0.68,
            support_strength: GhostSupportStrength::Weak,
            freshness_risk: GhostFreshnessRisk::Low,
        },
    )]
}

fn generate_valuation_bridges(
    workspace: &ResearchWorkspace,
    notes: &[ResearchNote],
) -> Vec<GhostNote> {
    let valuation_notes = notes
        .iter()
        .filter(|note| note.note_type == NoteType::ValuationPoint)
        .collect::<Vec<_>>();
    let driver_notes = notes
        .iter()
        .filter(|note| {
            matches!(
                note.note_type,
                NoteType::IndustryObservation
                    | NoteType::ManagementSignal
                    | NoteType::Fact
                    | NoteType::Catalyst
            )
        })
        .collect::<Vec<_>>();

    if valuation_notes.len() < 2 || driver_notes.is_empty() {
        return Vec::new();
    }

    let mut support = valuation_notes
        .iter()
        .map(|note| note.id.clone())
        .collect::<Vec<_>>();
    support.extend(driver_notes.iter().take(2).map(|note| note.id.clone()));
    vec![ghost(
        workspace,
        GhostDraft {
            ghost_class: GhostNoteClass::ValuationBridge,
            supporting_note_ids: support,
            contradicting_note_ids: Vec::new(),
            source_ids: collect_source_ids(valuation_notes.into_iter().chain(driver_notes.into_iter())),
            headline: "Possible valuation bridge".to_string(),
            body: "Observed: valuation notes point to a discount while operating notes suggest a driver that could narrow that gap. Inference: there may be a rerating bridge if the operating evidence persists. What would confirm/refute: track the KPI that should transmit into multiple expansion.".to_string(),
            confidence: 0.76,
            evidence_threshold_met: true,
            visibility_state: GhostVisibilityState::Visible,
            memo_section_hint: Some(MemoSectionKind::ValuationWriteUp),
            target_note_id: None,
            parent_thesis_note_id: None,
            target_claim_text: "Possible valuation bridge".to_string(),
            target_claim_kind: GhostTargetClaimKind::Assumption,
            why_it_matters: "Valuation conviction depends on whether the operating driver can plausibly transmit into estimates or multiple expansion.".to_string(),
            current_support_summary: "Valuation notes and driver notes coexist, but the bridge still needs a causal KPI.".to_string(),
            counter_case: "The discount may persist if the operating driver is already priced in, temporary, or not material to forward estimates.".to_string(),
            recommended_action: GhostRecommendedAction::RewriteClaim,
            recommended_action_rationale: "Make the valuation bridge explicit: driver, KPI, estimate impact, and multiple implication.".to_string(),
            resolution_status: GhostResolutionStatus::Open,
            leverage_score: 0.76,
            support_strength: GhostSupportStrength::Weak,
            freshness_risk: GhostFreshnessRisk::Low,
        },
    )]
}

fn generate_candidate_thesis(
    workspace: &ResearchWorkspace,
    notes: &[ResearchNote],
    links: &[NoteLink],
) -> Option<GhostNote> {
    let source_count = notes
        .iter()
        .filter_map(|note| note.source_id.as_ref())
        .collect::<HashSet<_>>()
        .len();
    let family_count = notes
        .iter()
        .map(|note| note.note_type)
        .collect::<HashSet<_>>()
        .len();
    let corroborated_count = notes
        .iter()
        .filter(|note| {
            matches!(
                note.note_type,
                NoteType::Fact
                    | NoteType::Quote
                    | NoteType::ManagementSignal
                    | NoteType::ValuationPoint
            )
        })
        .count();
    let has_catalyst_or_valuation = notes.iter().any(|note| {
        matches!(
            note.note_type,
            NoteType::Catalyst | NoteType::ValuationPoint
        )
    });
    let has_unresolved_contradiction = links.iter().any(|link| {
        matches!(
            link.link_type,
            LinkType::Contradicts | LinkType::ManagementVsReality
        ) && link.confidence > 0.75
    });

    if notes.len() < 4
        || family_count < 2
        || source_count < 2
        || corroborated_count < 2
        || !has_catalyst_or_valuation
    {
        return None;
    }

    let headline = if has_unresolved_contradiction {
        "Possible thesis emerging, but tension remains"
    } else {
        "Possible candidate thesis"
    };
    let body = if has_unresolved_contradiction {
        "Observed: enough connected evidence exists to suggest an investable pattern, but at least one unresolved contradiction remains. Inference: a thesis may be forming, though conviction should stay tempered until the conflict is resolved. What would confirm/refute: close the contradiction with fresher operating evidence."
    } else {
        "Observed: multiple notes across evidence, catalyst, and valuation categories are pointing in the same direction. Inference: a coherent thesis may be emerging, though it should remain provisional until explicitly accepted by the analyst. What would confirm/refute: one more corroborating datapoint tied to the key driver."
    };

    Some(ghost(
        workspace,
        GhostDraft {
            ghost_class: GhostNoteClass::CandidateThesis,
            supporting_note_ids: notes.iter().take(6).map(|note| note.id.clone()).collect(),
            contradicting_note_ids: Vec::new(),
            source_ids: collect_source_ids(notes.iter()),
            headline: headline.to_string(),
            body: body.to_string(),
            confidence: if has_unresolved_contradiction {
                0.68
            } else {
                0.82
            },
            evidence_threshold_met: !has_unresolved_contradiction,
            visibility_state: GhostVisibilityState::Visible,
            memo_section_hint: Some(MemoSectionKind::InvestmentMemo),
            target_note_id: None,
            parent_thesis_note_id: None,
            target_claim_text: headline.to_string(),
            target_claim_kind: GhostTargetClaimKind::CoreThesis,
            why_it_matters: "This is a synthesis candidate; conviction depends on whether the supporting notes form one coherent investment claim.".to_string(),
            current_support_summary: format!("{} source families and {} note types support an emerging thesis pattern.", source_count, family_count),
            counter_case: if has_unresolved_contradiction {
                "The emerging thesis may be premature because at least one unresolved contradiction can undermine the core driver.".to_string()
            } else {
                "The evidence may show a pattern without proving it is differentiated, material, or mispriced.".to_string()
            },
            recommended_action: if has_unresolved_contradiction {
                GhostRecommendedAction::ResolveContradiction
            } else {
                GhostRecommendedAction::RewriteClaim
            },
            recommended_action_rationale: if has_unresolved_contradiction {
                "Resolve the contradiction before accepting the thesis candidate.".to_string()
            } else {
                "Draft the thesis as a precise claim and link the decisive support.".to_string()
            },
            resolution_status: GhostResolutionStatus::Open,
            leverage_score: 0.86,
            support_strength: if has_unresolved_contradiction {
                GhostSupportStrength::Mixed
            } else {
                GhostSupportStrength::Strong
            },
            freshness_risk: GhostFreshnessRisk::Low,
        },
    ))
}

fn rank_and_limit_visibility(ghosts: &mut [GhostNote]) {
    ghosts.sort_by(|left, right| {
        right
            .leverage_score
            .total_cmp(&left.leverage_score)
            .then_with(|| right.confidence.total_cmp(&left.confidence))
    });
    let mut visible_count = 0usize;
    for ghost in ghosts {
        if matches!(
            ghost.visibility_state,
            GhostVisibilityState::Visible | GhostVisibilityState::Pinned
        ) {
            if visible_count >= 3 {
                ghost.visibility_state = GhostVisibilityState::Hidden;
                ghost.state = GhostLifecycleState::Generated;
            } else {
                visible_count += 1;
                ghost.state = GhostLifecycleState::Visible;
            }
        }
    }
}

fn ghost(workspace: &ResearchWorkspace, draft: GhostDraft) -> GhostNote {
    let now = now_rfc3339();
    let mut key_parts = BTreeSet::new();
    key_parts.extend(draft.supporting_note_ids.iter().cloned());
    key_parts.extend(draft.contradicting_note_ids.iter().cloned());
    let ghost_key = format!(
        "{:?}-{}",
        draft.ghost_class,
        key_parts.into_iter().collect::<Vec<_>>().join(",")
    );

    GhostNote {
        id: format!("ghost-{}", &sha256_hex(&ghost_key)[..16]),
        workspace_id: workspace.id.clone(),
        ghost_class: draft.ghost_class,
        headline: draft.headline,
        body: draft.body,
        tone: GhostTone::Tentative,
        confidence: draft.confidence,
        visibility_state: draft.visibility_state,
        state: GhostLifecycleState::Generated,
        supporting_note_ids: draft.supporting_note_ids,
        contradicting_note_ids: draft.contradicting_note_ids,
        source_ids: draft.source_ids,
        evidence_threshold_met: draft.evidence_threshold_met,
        created_at: now.clone(),
        updated_at: now,
        superseded_by_ghost_id: None,
        promoted_note_id: None,
        memo_section_hint: draft.memo_section_hint,
        target_note_id: draft.target_note_id,
        parent_thesis_note_id: draft.parent_thesis_note_id,
        target_claim_text: draft.target_claim_text,
        target_claim_kind: draft.target_claim_kind,
        why_it_matters: draft.why_it_matters,
        current_support_summary: draft.current_support_summary,
        counter_case: draft.counter_case,
        recommended_action: draft.recommended_action,
        recommended_action_rationale: draft.recommended_action_rationale,
        resolution_status: draft.resolution_status,
        leverage_score: draft.leverage_score,
        support_strength: draft.support_strength,
        freshness_risk: draft.freshness_risk,
        agent_reasoning_version: 1,
    }
}

fn collect_source_ids<'a>(notes: impl IntoIterator<Item = &'a ResearchNote>) -> Vec<String> {
    notes
        .into_iter()
        .filter_map(|note| note.source_id.clone())
        .collect::<HashSet<_>>()
        .into_iter()
        .collect()
}

pub(crate) async fn enhance_ghosts_with_ai(
    ai_gateway: &Arc<dyn ResearchAiGateway>,
    ghosts: &mut [GhostNote],
    notes: &[ResearchNote],
    files_by_id: &std::collections::HashMap<String, GhostResolvedFileContext>,
) {
    let note_map: std::collections::HashMap<&str, &ResearchNote> =
        notes.iter().map(|n| (n.id.as_str(), n)).collect();

    for ghost in ghosts.iter_mut() {
        if !matches!(
            ghost.visibility_state,
            GhostVisibilityState::Visible | GhostVisibilityState::Pinned
        ) && ghost.agent_reasoning_version == 0
        {
            continue;
        }

        let summaries: Vec<String> = ghost
            .supporting_note_ids
            .iter()
            .filter_map(|id| note_map.get(id.as_str()))
            .map(|note| {
                format!(
                    "[{}] {}",
                    format!("{:?}", note.note_type).to_ascii_lowercase(),
                    note.cleaned_text,
                )
            })
            .collect();
        let mut seen_file_ids = HashSet::new();
        let supporting_files = ghost
            .supporting_note_ids
            .iter()
            .filter_map(|id| note_map.get(id.as_str()))
            .flat_map(|note| {
                note.file_ids
                    .iter()
                    .filter_map(move |file_id| files_by_id.get(file_id).map(|file| (note, file)))
            })
            .filter(|(_, file)| seen_file_ids.insert(file.file_id.clone()))
            .map(|(note, file)| GhostFileContext {
                note_id: note.id.clone(),
                file_name: file.original_name.clone(),
                preview_kind: file.preview_kind,
                extracted_text: file.extracted_text.clone(),
                truncated: file.extracted_text_truncated,
            })
            .collect::<Vec<_>>();

        if summaries.is_empty() {
            continue;
        }

        let ghost_class_label = format!("{:?}", ghost.ghost_class)
            .to_ascii_lowercase()
            .replace('_', " ");
        let original_body = ghost.body.clone();

        let enhanced = ai_gateway
            .synthesize_ghost_body(
                ghost_class_label,
                summaries,
                supporting_files,
                original_body,
            )
            .await;

        ghost.body = enhanced;
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;
    use std::sync::{Arc, Mutex};

    use futures::future::BoxFuture;

    use crate::research::ai::{AiEnrichmentResult, GhostFileContext, ResearchAiGateway};
    use crate::research::types::{
        AnalystStatus, EvidenceStatus, GhostStatus, NotePriority, NoteProvenance, NoteType,
        ProvenanceActor, ResearchFilePreviewKind, ResearchNote, ResearchWorkspace, ThesisStatus,
        WorkspaceScope, WorkspaceViewKind,
    };
    use crate::research::util::{now_rfc3339, sha256_hex};

    use super::{enhance_ghosts_with_ai, generate_ghost_notes, GhostResolvedFileContext};

    #[derive(Default)]
    struct RecordingGateway {
        files: Arc<Mutex<Vec<GhostFileContext>>>,
    }

    impl ResearchAiGateway for RecordingGateway {
        fn enrich_note(
            &self,
            _note: ResearchNote,
            _model_info: Option<crate::research::types::ModelInfo>,
        ) -> BoxFuture<'static, AiEnrichmentResult> {
            Box::pin(async {
                AiEnrichmentResult {
                    note_type: None,
                    annotation: None,
                    tags: Vec::new(),
                    risks: Vec::new(),
                    catalysts: Vec::new(),
                    valuation_refs: Vec::new(),
                    missing_evidence: false,
                }
            })
        }

        fn synthesize_ghost_body(
            &self,
            _ghost_class: String,
            _supporting_summaries: Vec<String>,
            supporting_files: Vec<GhostFileContext>,
            existing_body: String,
        ) -> BoxFuture<'static, String> {
            let files = self.files.clone();
            Box::pin(async move {
                files.lock().unwrap().extend(supporting_files);
                existing_body
            })
        }
    }

    fn workspace() -> ResearchWorkspace {
        let now = now_rfc3339();
        ResearchWorkspace {
            id: "workspace-1".to_string(),
            name: "AAPL".to_string(),
            primary_ticker: "AAPL".to_string(),
            scope: WorkspaceScope::SingleCompany,
            stage: crate::research::types::ResearchStage::Thesis,
            default_view: WorkspaceViewKind::ThesisBuilder,
            pinned_note_ids: Vec::new(),
            archived: false,
            created_at: now.clone(),
            updated_at: now,
        }
    }

    fn note(id: &str, note_type: NoteType, source_id: Option<&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: source_id.map(ToOwned::to_owned),
            raw_text: id.to_string(),
            cleaned_text: id.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::SourceLinked,
            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(id),
            },
            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 generate_ghost_notes_should_surface_candidate_thesis_when_evidence_threshold_met() {
        let ghosts = generate_ghost_notes(
            &workspace(),
            &[
                note("fact-1", NoteType::Fact, Some("source-1")),
                note("quote-1", NoteType::Quote, Some("source-2")),
                note("catalyst-1", NoteType::Catalyst, Some("source-2")),
                note("valuation-1", NoteType::ValuationPoint, Some("source-1")),
            ],
            &[],
        );

        assert!(ghosts
            .iter()
            .any(|ghost| ghost.ghost_class
                == crate::research::types::GhostNoteClass::CandidateThesis));
    }

    #[test]
    fn enhance_ghosts_with_ai_should_include_supporting_note_file_context_once() {
        let mut notes = vec![
            note("fact-1", NoteType::Fact, Some("source-1")),
            note("quote-1", NoteType::Quote, Some("source-2")),
            note("catalyst-1", NoteType::Catalyst, Some("source-2")),
            note("valuation-1", NoteType::ValuationPoint, Some("source-1")),
        ];
        notes[0].file_ids = vec!["file-1".to_string(), "file-2".to_string()];
        notes[1].file_ids = vec!["file-1".to_string()];
        let mut ghosts = generate_ghost_notes(&workspace(), &notes, &[]);
        let files_by_id = HashMap::from([
            (
                "file-1".to_string(),
                GhostResolvedFileContext {
                    file_id: "file-1".to_string(),
                    original_name: "deck.pdf".to_string(),
                    preview_kind: ResearchFilePreviewKind::Pdf,
                    extracted_text: Some("management raised guidance".to_string()),
                    extracted_text_truncated: false,
                },
            ),
            (
                "file-2".to_string(),
                GhostResolvedFileContext {
                    file_id: "file-2".to_string(),
                    original_name: "model.csv".to_string(),
                    preview_kind: ResearchFilePreviewKind::Text,
                    extracted_text: Some("gross margin bridge".to_string()),
                    extracted_text_truncated: true,
                },
            ),
        ]);
        let recording_gateway = RecordingGateway::default();
        let recorded = recording_gateway.files.clone();
        let gateway: Arc<dyn ResearchAiGateway> = Arc::new(recording_gateway);

        tokio::runtime::Runtime::new()
            .unwrap()
            .block_on(enhance_ghosts_with_ai(
                &gateway,
                &mut ghosts,
                &notes,
                &files_by_id,
            ));

        let files = recorded.lock().unwrap().clone();
        assert_eq!(files.len(), 2);
        assert_eq!(files[0].file_name, "deck.pdf");
        assert_eq!(
            files[0].extracted_text.as_deref(),
            Some("management raised guidance")
        );
        assert_eq!(files[1].file_name, "model.csv");
        assert!(files[1].truncated);
    }
}
