use crate::config::ProjectConfig;
use crate::domain::note::tokenize;
use crate::domain::{
    ConfidenceTier, LifecycleCandidate, MatchedModule, MatchedProject, MatchedScene,
    MemoryLifecycleState, MemoryRecord, MemoryScope, MemorySourceKind, Note, RouteInput,
    ScoreContribution, ScoreSource,
};
use std::collections::BTreeSet;

fn extract_tags(value: &serde_json::Value) -> Vec<&str> {
    match value {
        serde_json::Value::String(tag) => vec![tag.as_str()],
        serde_json::Value::Array(tags) => tags.iter().filter_map(|tag| tag.as_str()).collect(),
        _ => Vec::new(),
    }
}

/// Single scoring loop's running totals. Owns the legacy
/// human-readable `reasons` strings (still emitted to keep markdown
/// output stable) AND the structured `breakdown` rows (source +
/// field + term + weight) used by explain JSON and downstream
/// evaluation tools. Always mutate via [`Self::add`] so reason and
/// breakdown stay in sync with `score`.
struct Accumulator {
    score: i32,
    reasons: Vec<String>,
    breakdown: Vec<ScoreContribution>,
}

impl Accumulator {
    fn new() -> Self {
        Self {
            score: 0,
            reasons: Vec::new(),
            breakdown: Vec::new(),
        }
    }

    fn add(&mut self, source: ScoreSource, field: &str, term: &str, weight: i32, reason: String) {
        self.score += weight;
        if !self.reasons.iter().any(|existing| existing == &reason) {
            self.reasons.push(reason);
        }
        self.breakdown.push(ScoreContribution {
            source,
            field: field.to_string(),
            term: term.to_string(),
            weight,
        });
    }
}

fn apply_structured_frontmatter_score(
    acc: &mut Accumulator,
    note: &Note,
    project: Option<&MatchedProject>,
) {
    if let Some(memory_type) = note.memory_type() {
        let delta = match memory_type {
            "constraint" => 14,
            "decision" => 12,
            "project" => 10,
            "preference" => 9,
            "incident" => 8,
            "workflow" => 7,
            "pattern" => 6,
            "person" => 4,
            "session" => -2,
            _ => 0,
        };
        if delta != 0 {
            acc.add(
                ScoreSource::MemoryType,
                "memory_type",
                memory_type,
                delta,
                format!("memory_type {memory_type} adjusted score {delta:+}"),
            );
        }
    }

    if let Some(project_id) = note.frontmatter_str("project_id")
        && let Some(project) = project
        && project_id == project.id
    {
        acc.add(
            ScoreSource::Frontmatter,
            "project_id",
            &project.id,
            12,
            format!("frontmatter project_id matched {}", project.id),
        );
    }

    if note.source_of_truth() {
        acc.add(
            ScoreSource::Frontmatter,
            "source_of_truth",
            "true",
            10,
            "source_of_truth boosted retrieval".to_string(),
        );
    }

    if let Some(priority) = note.frontmatter_str("retrieval_priority") {
        let delta = match priority {
            "high" => 10,
            "medium" => 4,
            "low" => -4,
            _ => 0,
        };
        if delta != 0 {
            acc.add(
                ScoreSource::Frontmatter,
                "retrieval_priority",
                priority,
                delta,
                format!("retrieval_priority {priority} adjusted score {delta:+}"),
            );
        }
    }

    if let Some(sensitivity) = note.sensitivity() {
        let delta = match sensitivity {
            "public" => 2,
            "internal" => 0,
            "confidential" => -4,
            "secret" => -12,
            _ => 0,
        };
        if delta != 0 {
            acc.add(
                ScoreSource::Sensitivity,
                "sensitivity",
                sensitivity,
                delta,
                format!("sensitivity {sensitivity} adjusted score {delta:+}"),
            );
        } else {
            // Acknowledge the value without changing score so the
            // explain output still surfaces it.
            if !acc
                .reasons
                .iter()
                .any(|r| r == &format!("sensitivity {sensitivity} acknowledged"))
            {
                acc.reasons
                    .push(format!("sensitivity {sensitivity} acknowledged"));
            }
        }
    }
}

fn score_named_match(acc: &mut Accumulator, note: &Note, label: &str, term: &str) {
    if note.search_index.matches_title(term) {
        acc.add(
            ScoreSource::NamedMatch,
            "title",
            term,
            18,
            format!("{label} matched title {term}"),
        );
    }
    if note.search_index.matches_heading(term) {
        acc.add(
            ScoreSource::NamedMatch,
            "heading",
            term,
            14,
            format!("{label} matched heading {term}"),
        );
    }
    if note.search_index.matches_wikilink(term) {
        acc.add(
            ScoreSource::NamedMatch,
            "wikilink",
            term,
            12,
            format!("{label} matched wikilink {term}"),
        );
    }
    if note.search_index.matches_path(term) {
        acc.add(
            ScoreSource::NamedMatch,
            "path",
            term,
            10,
            format!("{label} matched path {term}"),
        );
    }
    if note.search_index.matches_body(term) {
        acc.add(
            ScoreSource::NamedMatch,
            "body",
            term,
            8,
            format!("{label} matched body {term}"),
        );
    }
}

fn confidence_label(tier: ConfidenceTier) -> &'static str {
    match tier {
        ConfidenceTier::High => "high",
        ConfidenceTier::Medium => "medium",
        ConfidenceTier::Low => "low",
    }
}

fn derive_note_confidence(note: &Note) -> ConfidenceTier {
    if note.source_of_truth() {
        return ConfidenceTier::High;
    }
    let sensitivity = note.sensitivity().unwrap_or("internal");
    if sensitivity == "secret" {
        return ConfidenceTier::Low;
    }
    let priority = note
        .frontmatter_str("retrieval_priority")
        .unwrap_or("medium");
    if priority == "low" {
        return ConfidenceTier::Low;
    }
    let memory_type = note.memory_type().unwrap_or("");
    if priority == "high" && matches!(memory_type, "constraint" | "decision" | "project") {
        return ConfidenceTier::High;
    }
    ConfidenceTier::Medium
}

fn derive_lifecycle_confidence(
    state: MemoryLifecycleState,
    source_kind: MemorySourceKind,
    sensitivity: Option<&str>,
) -> ConfidenceTier {
    let base = match state {
        MemoryLifecycleState::Canonical => ConfidenceTier::High,
        MemoryLifecycleState::Accepted => match source_kind {
            MemorySourceKind::Manual => ConfidenceTier::High,
            _ => ConfidenceTier::Medium,
        },
        MemoryLifecycleState::Candidate => ConfidenceTier::Low,
        _ => ConfidenceTier::Medium,
    };
    if sensitivity == Some("secret") {
        match base {
            ConfidenceTier::High => return ConfidenceTier::Medium,
            _ => return ConfidenceTier::Low,
        }
    }
    base
}

fn query_terms(input: &RouteInput) -> (BTreeSet<String>, BTreeSet<String>) {
    let task_terms = tokenize(&input.task);
    let file_terms = input
        .files
        .iter()
        .flat_map(|file| tokenize(file))
        .filter(|segment| segment.chars().count() >= 3)
        .collect();
    (task_terms, file_terms)
}

pub fn score_note(
    project_config: Option<&ProjectConfig>,
    project: Option<&MatchedProject>,
    modules: &[MatchedModule],
    scenes: &[MatchedScene],
    note: &Note,
    input: &RouteInput,
) -> (i32, Vec<String>, Vec<ScoreContribution>, ConfidenceTier) {
    let mut acc = Accumulator::new();
    let (task_terms, file_terms) = query_terms(input);

    apply_structured_frontmatter_score(&mut acc, note, project);

    if let Some(project) = project {
        score_named_match(&mut acc, note, "project", &project.id);
        score_named_match(&mut acc, note, "project", &project.name);
    }

    if let Some(project_config) = project_config {
        for root in &project_config.note_roots {
            if note.relative_path.starts_with(root) {
                acc.add(
                    ScoreSource::DefaultTag,
                    "note_root",
                    root,
                    10,
                    format!("note under preferred root {root}"),
                );
            }
        }
        let note_tags = note
            .frontmatter
            .get("tags")
            .into_iter()
            .flat_map(extract_tags);
        for tag in &project_config.default_tags {
            if note_tags.clone().any(|note_tag| note_tag == tag.as_str()) {
                acc.add(
                    ScoreSource::DefaultTag,
                    "tag",
                    tag,
                    6,
                    format!("matched frontmatter tag {tag}"),
                );
            }
        }
    }

    for module in modules {
        score_named_match(&mut acc, note, "module", &module.id);
    }

    for scene in scenes {
        score_named_match(&mut acc, note, "scene", &scene.id);
        if scene
            .preferred_notes
            .iter()
            .any(|preferred| preferred == &note.relative_path)
        {
            acc.add(
                ScoreSource::ScenePreferred,
                "preferred_note",
                &scene.id,
                25,
                format!("preferred by scene {}", scene.id),
            );
        }
    }

    for term in file_terms {
        if note.search_index.matches_title(&term) {
            acc.add(
                ScoreSource::TaskToken,
                "title",
                &term,
                7,
                format!("matched file segment {term} in title"),
            );
        }
        if note.search_index.matches_heading(&term) {
            acc.add(
                ScoreSource::TaskToken,
                "heading",
                &term,
                5,
                format!("matched file segment {term} in heading"),
            );
        }
        if note.search_index.matches_wikilink(&term) {
            acc.add(
                ScoreSource::TaskToken,
                "wikilink",
                &term,
                5,
                format!("matched file segment {term} in wikilink"),
            );
        }
        if note.search_index.matches_body(&term) || note.search_index.matches_path(&term) {
            acc.add(
                ScoreSource::TaskToken,
                "body_or_path",
                &term,
                3,
                format!("matched file segment {term}"),
            );
        }
    }

    for token in task_terms {
        if note.search_index.matches_title(&token) {
            acc.add(
                ScoreSource::TaskToken,
                "title",
                &token,
                7,
                format!("matched task token {token} in title"),
            );
        }
        if note.search_index.matches_heading(&token) {
            acc.add(
                ScoreSource::TaskToken,
                "heading",
                &token,
                5,
                format!("matched task token {token} in heading"),
            );
        }
        if note.search_index.matches_wikilink(&token) {
            acc.add(
                ScoreSource::TaskToken,
                "wikilink",
                &token,
                5,
                format!("matched task token {token} in wikilink"),
            );
        }
        if note.search_index.matches_body(&token) || note.search_index.matches_path(&token) {
            acc.add(
                ScoreSource::TaskToken,
                "body_or_path",
                &token,
                3,
                format!("matched task token {token}"),
            );
        }
    }

    let confidence = derive_note_confidence(note);
    let confidence_weight = match confidence {
        ConfidenceTier::High => 6,
        ConfidenceTier::Medium => 0,
        ConfidenceTier::Low => -4,
    };
    if confidence_weight != 0 {
        acc.add(
            ScoreSource::Confidence,
            "confidence",
            confidence_label(confidence),
            confidence_weight,
            format!(
                "confidence={} adjusted score {:+}",
                confidence_label(confidence),
                confidence_weight
            ),
        );
    }

    (acc.score, acc.reasons, acc.breakdown, confidence)
}

fn lifecycle_memory_type_weight(memory_type: &str) -> i32 {
    match memory_type {
        "knowledge" => 16,
        "constraint" => 14,
        "decision" => 12,
        "project" => 10,
        "preference" => 9,
        "incident" => 8,
        "workflow" => 7,
        "pattern" => 6,
        "person" => 4,
        "session" => -2,
        _ => 0,
    }
}

/// 按 scope / project 过滤一条 lifecycle 记忆，未通过返回 None。
/// 通过的记忆返回包含 score + reasons + 原始 memory_type 等信息的 `LifecycleCandidate`。
///
/// `reference_map` 提供 staleness 信息：如果某条记忆长时间未被检索，
/// 会施加负分惩罚（-2 到 -8），让更活跃的记忆排在前面。
///
/// `existing_records` 用于矛盾检测：当提供时，会检查当前记忆是否与已有记忆矛盾，
/// 并填充 `contradicts` 字段（仅可见性标注，不影响分数）。
pub fn score_lifecycle_candidate(
    project: Option<&MatchedProject>,
    record_id: &str,
    record: &MemoryRecord,
    input: &RouteInput,
    reference_map: Option<&crate::reference_tracker::ReferenceMap>,
    existing_records: Option<&[(String, MemoryRecord)]>,
) -> Option<LifecycleCandidate> {
    // Archived and Draft records are not retrievable.
    if matches!(
        record.state,
        MemoryLifecycleState::Archived | MemoryLifecycleState::Draft
    ) {
        return None;
    }

    let mut score = 0;
    let mut reasons: Vec<String> = Vec::new();

    match record.scope {
        MemoryScope::Project => {
            let project_match = record
                .project_id
                .as_deref()
                .zip(project.map(|matched| matched.id.as_str()))
                .map(|(record_pid, matched_pid)| record_pid == matched_pid)
                .unwrap_or(false);
            if !project_match {
                return None;
            }
            score += 10;
            reasons.push(format!(
                "scope=project matched project_id {}",
                record.project_id.clone().unwrap_or_default()
            ));
        }
        MemoryScope::Workspace => {
            // 当前 ledger 没独立 workspace 路径字段，保守用 project_id 近似。
            let project_match = record
                .project_id
                .as_deref()
                .zip(project.map(|matched| matched.id.as_str()))
                .map(|(record_pid, matched_pid)| record_pid == matched_pid)
                .unwrap_or(false);
            if !project_match {
                return None;
            }
            score += 6;
            reasons.push("scope=workspace matched project proxy".to_string());
        }
        MemoryScope::User | MemoryScope::Agent | MemoryScope::Team => {
            score += 4;
            reasons.push(format!(
                "scope={} kept as cross-project",
                scope_label(record.scope)
            ));
        }
    }

    let memory_type_weight = lifecycle_memory_type_weight(&record.memory_type);
    if memory_type_weight != 0 {
        score += memory_type_weight;
        reasons.push(format!(
            "memory_type {} adjusted score {:+}",
            record.memory_type, memory_type_weight
        ));
    }

    if matches!(record.state, MemoryLifecycleState::Canonical) {
        score += 3;
        reasons.push("state=canonical boosted".to_string());
    }

    let task_tokens = tokenize(&input.task);
    let file_tokens: BTreeSet<String> = input
        .files
        .iter()
        .flat_map(|file| tokenize(file))
        .filter(|segment| segment.chars().count() >= 3)
        .collect();
    let title_lc = record.title.to_lowercase();
    let summary_lc = record.summary.to_lowercase();
    let task_lc = input.task.to_lowercase();

    if title_lc.len() >= 4 && (task_lc.contains(&title_lc) || title_lc.contains(&task_lc)) {
        score += 6;
        reasons.push("title substring matched task".to_string());
    }

    let mut token_bonus = 0_i32;
    for token in task_tokens.iter().chain(file_tokens.iter()) {
        if token.is_empty() {
            continue;
        }
        let needle = token.to_lowercase();
        if title_lc.contains(&needle) || summary_lc.contains(&needle) {
            token_bonus += 4;
            reasons.push(format!("task/file token {token} matched lifecycle text"));
            if token_bonus >= 12 {
                break;
            }
        }
    }
    score += token_bonus.min(12);

    // Structured field scoring: entities, tags, triggers, related_files, applies_to
    let all_query_tokens: BTreeSet<String> = task_tokens
        .iter()
        .chain(file_tokens.iter())
        .map(|t| t.to_lowercase())
        .collect();

    // entities match task tokens → +6 per match (cap +18)
    let mut entities_bonus = 0_i32;
    for entity in &record.entities {
        let entity_lc = entity.to_lowercase();
        if all_query_tokens
            .iter()
            .any(|t| entity_lc.contains(t) || t.contains(&entity_lc))
        {
            entities_bonus += 6;
            reasons.push(format!("entity {entity} matched query token"));
            if entities_bonus >= 18 {
                break;
            }
        }
    }
    score += entities_bonus.min(18);

    // tags match task tokens → +4 per match (cap +12)
    let mut tags_bonus = 0_i32;
    for tag in &record.tags {
        let tag_lc = tag.to_lowercase();
        if all_query_tokens
            .iter()
            .any(|t| tag_lc.contains(t) || t.contains(&tag_lc))
        {
            tags_bonus += 4;
            reasons.push(format!("tag {tag} matched query token"));
            if tags_bonus >= 12 {
                break;
            }
        }
    }
    score += tags_bonus.min(12);

    // Knowledge pages with domain:user-profile always get a baseline boost
    // so they surface in any session (user habits, preferences, etc.)
    if record.memory_type == "knowledge" && record.tags.iter().any(|t| t == "domain:user-profile") {
        score += 8;
        reasons.push("knowledge domain:user-profile always-on boost".to_string());
    }

    // triggers exact match task/file tokens → +8 per match (cap +16)
    let mut triggers_bonus = 0_i32;
    for trigger in &record.triggers {
        let trigger_lc = trigger.to_lowercase();
        if all_query_tokens.contains(&trigger_lc) {
            triggers_bonus += 8;
            reasons.push(format!("trigger {trigger} exact-matched query token"));
            if triggers_bonus >= 16 {
                break;
            }
        }
    }
    score += triggers_bonus.min(16);

    // related_files match current files → +10 per match (cap +20)
    let mut files_bonus = 0_i32;
    for related_file in &record.related_files {
        let rf_lc = related_file.to_lowercase();
        if input.files.iter().any(|f| {
            let f_lc = f.to_lowercase();
            f_lc.contains(&rf_lc) || rf_lc.contains(&f_lc)
        }) {
            files_bonus += 10;
            reasons.push(format!("related_file {related_file} matched input file"));
            if files_bonus >= 20 {
                break;
            }
        }
    }
    score += files_bonus.min(20);

    // applies_to match current project → +8
    if let Some(matched_project) = project
        && record
            .applies_to
            .iter()
            .any(|a| a.eq_ignore_ascii_case(&matched_project.id))
    {
        score += 8;
        reasons.push(format!("applies_to matched project {}", matched_project.id));
    }

    if score <= 0 {
        return None;
    }

    let confidence = derive_lifecycle_confidence(
        record.state,
        record.origin.source_kind,
        record.sensitivity.as_deref(),
    );
    let confidence_weight = match confidence {
        ConfidenceTier::High => 5,
        ConfidenceTier::Medium => 0,
        ConfidenceTier::Low => -3,
    };
    if confidence_weight != 0 {
        score += confidence_weight;
        reasons.push(format!(
            "confidence={} adjusted score {:+}",
            confidence_label(confidence),
            confidence_weight
        ));
    }

    if let Some(ref_map) = reference_map {
        let age = ref_map
            .records
            .get(record_id)
            .and_then(crate::reference_tracker::age_days);
        let penalty = crate::reference_tracker::staleness_penalty(age);
        if penalty != 0 {
            score += penalty;
            reasons.push(format!(
                "staleness penalty {:+} (age={} days)",
                penalty,
                age.unwrap_or(0)
            ));
        }
    }

    // Contradiction detection — populate contradicts field (visibility only, no score change)
    let contradicts: Vec<String> = if let Some(existing) = existing_records {
        crate::contradiction::detect(&record.summary, &record.memory_type, existing)
            .into_iter()
            .map(|hit| hit.existing_record_id)
            .collect()
    } else {
        Vec::new()
    };

    Some(LifecycleCandidate {
        record_id: record_id.to_string(),
        title: record.title.clone(),
        summary: record.summary.clone(),
        memory_type: record.memory_type.clone(),
        scope: record.scope,
        state: record.state,
        score,
        reasons,
        project_id: record.project_id.clone(),
        confidence,
        contradicts,
    })
}

fn scope_label(scope: MemoryScope) -> &'static str {
    match scope {
        MemoryScope::User => "user",
        MemoryScope::Project => "project",
        MemoryScope::Workspace => "workspace",
        MemoryScope::Agent => "agent",
        MemoryScope::Team => "team",
    }
}

#[cfg(test)]
mod tests {
    use super::{score_lifecycle_candidate, score_note};
    use crate::domain::{
        MatchedModule, MatchedProject, MemoryLifecycleState, MemoryOrigin, MemoryRecord,
        MemoryScope, MemorySourceKind, Note, OutputFormat, RouteInput, Section, TargetTool,
    };
    use serde_json::json;
    use std::collections::BTreeMap;
    use std::path::PathBuf;

    fn make_input(task: &str, files: &[&str]) -> RouteInput {
        RouteInput {
            task: task.to_string(),
            cwd: PathBuf::from("/tmp/repo"),
            files: files.iter().map(|value| value.to_string()).collect(),
            target: TargetTool::Codex,
            format: OutputFormat::Prompt,
        }
    }

    fn make_record(
        title: &str,
        summary: &str,
        memory_type: &str,
        scope: MemoryScope,
        project_id: Option<&str>,
        state: MemoryLifecycleState,
    ) -> MemoryRecord {
        MemoryRecord {
            title: title.to_string(),
            summary: summary.to_string(),
            memory_type: memory_type.to_string(),
            scope,
            state,
            origin: MemoryOrigin {
                source_kind: MemorySourceKind::Manual,
                source_ref: "test".to_string(),
            },
            project_id: project_id.map(|v| v.to_string()),
            user_id: None,
            sensitivity: None,
            entities: Vec::new(),
            tags: Vec::new(),
            triggers: Vec::new(),
            related_files: Vec::new(),
            related_records: Vec::new(),
            supersedes: None,
            applies_to: Vec::new(),
            valid_until: None,
        }
    }

    fn make_note(
        relative_path: &str,
        title: &str,
        heading: Option<&str>,
        content: &str,
        wikilinks: &[&str],
    ) -> Note {
        Note::new(
            PathBuf::from(format!("/tmp/vault/{relative_path}")),
            relative_path.to_string(),
            title.to_string(),
            BTreeMap::new(),
            vec![Section {
                heading: heading.map(|value| value.to_string()),
                level: usize::from(heading.is_some()),
                content: content.to_string(),
            }],
            wikilinks.iter().map(|value| value.to_string()).collect(),
            content.to_string(),
        )
    }

    fn make_note_with_frontmatter(
        relative_path: &str,
        title: &str,
        content: &str,
        frontmatter: BTreeMap<String, serde_json::Value>,
    ) -> Note {
        Note::new(
            PathBuf::from(format!("/tmp/vault/{relative_path}")),
            relative_path.to_string(),
            title.to_string(),
            frontmatter,
            vec![Section {
                heading: Some("Context".to_string()),
                level: 1,
                content: content.to_string(),
            }],
            Vec::new(),
            content.to_string(),
        )
    }

    #[test]
    fn title_heading_and_wikilinks_should_outscore_body_only_matches() {
        let input = make_input(
            "Improve repo-path routing",
            &["src/engine/project_matcher.rs"],
        );
        let module = MatchedModule {
            id: "routing".to_string(),
            reasons: vec!["task matched keyword routing".to_string()],
        };

        let rich_note = make_note(
            "10-Projects/routing-guide.md",
            "Repo Path Routing Guide",
            Some("Project Matcher"),
            "See [[Project Matcher]] for the main entry point.",
            &["Project Matcher"],
        );
        let body_only_note = make_note(
            "10-Projects/notes.md",
            "Implementation Notes",
            Some("Background"),
            "This note mentions routing once in the body.",
            &[],
        );

        let (rich_score, _, _, _) = score_note(
            None,
            None,
            std::slice::from_ref(&module),
            &[],
            &rich_note,
            &input,
        );
        let (body_score, _, _, _) = score_note(None, None, &[module], &[], &body_only_note, &input);

        assert!(
            rich_score > body_score,
            "rich note score={rich_score}, body note score={body_score}"
        );
    }

    #[test]
    fn scoring_should_normalize_case_and_separator_variants() {
        let input = make_input(
            "Refine Repo-Path matching",
            &["src/engine/RepoPathMatcher.rs"],
        );
        let project = MatchedProject {
            id: "spool".to_string(),
            name: "spool".to_string(),
            reason: "test".to_string(),
        };
        let note = make_note(
            "10-Projects/spool-repo_path.md",
            "repo_path matcher",
            Some("RepoPath"),
            "Normalization should allow mixed case and separator variants.",
            &["Repo Path Matcher"],
        );

        let (score, reasons, _, _) = score_note(None, Some(&project), &[], &[], &note, &input);

        assert!(score > 0);
        assert!(
            reasons
                .iter()
                .any(|reason| reason.contains("task token") || reason.contains("file segment")),
            "reasons were: {reasons:?}"
        );
    }

    #[test]
    fn structured_frontmatter_should_boost_trusted_high_priority_memory() {
        let input = make_input("auth design review", &["src/auth/policy.rs"]);

        let curated = make_note_with_frontmatter(
            "10-Projects/spool-auth.md",
            "Auth Constraints",
            "Authentication design constraints.",
            BTreeMap::from([
                ("memory_type".to_string(), json!("constraint")),
                ("sensitivity".to_string(), json!("internal")),
                ("source_of_truth".to_string(), json!(true)),
                ("retrieval_priority".to_string(), json!("high")),
            ]),
        );
        let generic = make_note_with_frontmatter(
            "10-Projects/spool-notes.md",
            "Auth Notes",
            "Authentication design constraints.",
            BTreeMap::new(),
        );

        let (curated_score, curated_reasons, _, _) =
            score_note(None, None, &[], &[], &curated, &input);
        let (generic_score, _, _, _) = score_note(None, None, &[], &[], &generic, &input);

        assert!(
            curated_score > generic_score,
            "curated={curated_score}, generic={generic_score}"
        );
        assert!(
            curated_reasons
                .iter()
                .any(|reason| reason.contains("memory_type"))
        );
        assert!(
            curated_reasons
                .iter()
                .any(|reason| reason.contains("source_of_truth"))
        );
        assert!(
            curated_reasons
                .iter()
                .any(|reason| reason.contains("retrieval_priority"))
        );
    }

    #[test]
    fn secret_sensitivity_should_reduce_default_retrieval_score() {
        let input = make_input("deploy credentials rotation", &["infra/secrets.tf"]);

        let secret = make_note_with_frontmatter(
            "10-Projects/secrets.md",
            "Deploy Credentials",
            "Credentials rotation checklist.",
            BTreeMap::from([
                ("memory_type".to_string(), json!("workflow")),
                ("sensitivity".to_string(), json!("secret")),
                ("retrieval_priority".to_string(), json!("high")),
            ]),
        );
        let internal = make_note_with_frontmatter(
            "10-Projects/deploy.md",
            "Deploy Credentials",
            "Credentials rotation checklist.",
            BTreeMap::from([
                ("memory_type".to_string(), json!("workflow")),
                ("sensitivity".to_string(), json!("internal")),
                ("retrieval_priority".to_string(), json!("high")),
            ]),
        );

        let (secret_score, secret_reasons, _, _) =
            score_note(None, None, &[], &[], &secret, &input);
        let (internal_score, _, _, _) = score_note(None, None, &[], &[], &internal, &input);

        assert!(
            secret_score < internal_score,
            "secret={secret_score}, internal={internal_score}"
        );
        assert!(
            secret_reasons
                .iter()
                .any(|reason| reason.contains("sensitivity secret"))
        );
    }

    #[test]
    fn lifecycle_constraint_should_outrank_decision_preference_and_incident() {
        let input = make_input("resume p2 retrieval", &[]);
        let constraint = make_record(
            "避免 mock 测试",
            "production migration 曾因 mock 过度而失败",
            "constraint",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );
        let decision = make_record(
            "采用 React",
            "桌面 UI 用 React + shadcn",
            "decision",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );
        let preference = make_record(
            "中文回复",
            "prefer 中文",
            "preference",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );
        let incident = make_record(
            "CSRF 回归",
            "上次绕过了 CSRF check",
            "incident",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );

        let c = score_lifecycle_candidate(None, "r1", &constraint, &input, None, None).unwrap();
        let d = score_lifecycle_candidate(None, "r2", &decision, &input, None, None).unwrap();
        let p = score_lifecycle_candidate(None, "r3", &preference, &input, None, None).unwrap();
        let i = score_lifecycle_candidate(None, "r4", &incident, &input, None, None).unwrap();

        assert!(
            c.score > d.score,
            "constraint={} decision={}",
            c.score,
            d.score
        );
        assert!(
            d.score > p.score,
            "decision={} preference={}",
            d.score,
            p.score
        );
        assert!(
            p.score > i.score,
            "preference={} incident={}",
            p.score,
            i.score
        );
    }

    #[test]
    fn lifecycle_project_scope_should_filter_non_matching_project() {
        let input = make_input("project work", &[]);
        let project = MatchedProject {
            id: "spool".to_string(),
            name: "spool".to_string(),
            reason: "test".to_string(),
        };
        let matching = make_record(
            "spool 约束",
            "constraint text",
            "constraint",
            MemoryScope::Project,
            Some("spool"),
            MemoryLifecycleState::Accepted,
        );
        let other = make_record(
            "其他项目",
            "other text",
            "constraint",
            MemoryScope::Project,
            Some("other-repo"),
            MemoryLifecycleState::Accepted,
        );

        assert!(
            score_lifecycle_candidate(Some(&project), "r1", &matching, &input, None, None)
                .is_some()
        );
        assert!(
            score_lifecycle_candidate(Some(&project), "r2", &other, &input, None, None).is_none()
        );
    }

    #[test]
    fn lifecycle_user_scope_should_pass_without_project() {
        let input = make_input("anything", &[]);
        let record = make_record(
            "偏好",
            "prefer 简洁回复",
            "preference",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );
        let candidate = score_lifecycle_candidate(None, "r1", &record, &input, None, None).unwrap();
        assert!(candidate.score > 0);
        assert!(
            candidate
                .reasons
                .iter()
                .any(|reason| reason.contains("scope=user"))
        );
    }

    #[test]
    fn lifecycle_task_token_should_bonus_when_title_or_summary_matches() {
        let input = make_input("重构 retrieval 管道", &[]);
        let matched = make_record(
            "retrieval 排序原则",
            "按 memory_type 加权",
            "decision",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );
        let bland = make_record(
            "无关标题",
            "无关内容",
            "decision",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );
        let a = score_lifecycle_candidate(None, "r1", &matched, &input, None, None).unwrap();
        let b = score_lifecycle_candidate(None, "r2", &bland, &input, None, None).unwrap();
        assert!(a.score > b.score, "matched={} bland={}", a.score, b.score);
        assert!(
            a.reasons
                .iter()
                .any(|reason| reason.contains("matched lifecycle text"))
        );
    }

    #[test]
    fn lifecycle_canonical_state_should_edge_over_accepted() {
        let input = make_input("x", &[]);
        let canonical = make_record(
            "规范",
            "body",
            "constraint",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Canonical,
        );
        let accepted = make_record(
            "规范",
            "body",
            "constraint",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );
        let c = score_lifecycle_candidate(None, "r1", &canonical, &input, None, None).unwrap();
        let a = score_lifecycle_candidate(None, "r2", &accepted, &input, None, None).unwrap();
        assert!(c.score > a.score);
    }

    #[test]
    fn lifecycle_staleness_should_penalize_old_reference() {
        use crate::reference_tracker::{ReferenceEntry, ReferenceMap};
        use std::time::{SystemTime, UNIX_EPOCH};

        let input = make_input("test staleness", &[]);
        let record = make_record(
            "偏好",
            "prefer 简洁回复",
            "preference",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );

        // 60 days ago
        let now_secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let sixty_days_ago = now_secs - (60 * 86400);
        let timestamp =
            crate::reference_tracker::tests::unix_secs_to_iso8601_for_test(sixty_days_ago);

        let mut ref_map = ReferenceMap::default();
        ref_map.records.insert(
            "r1".to_string(),
            ReferenceEntry {
                last_referenced_at: timestamp,
                count: 3,
            },
        );

        let with_staleness =
            score_lifecycle_candidate(None, "r1", &record, &input, Some(&ref_map), None).unwrap();
        let without_staleness =
            score_lifecycle_candidate(None, "r1", &record, &input, None, None).unwrap();

        assert!(
            with_staleness.score < without_staleness.score,
            "stale={} fresh={}",
            with_staleness.score,
            without_staleness.score
        );
        assert!(
            with_staleness
                .reasons
                .iter()
                .any(|r| r.contains("staleness penalty"))
        );
    }

    #[test]
    fn lifecycle_staleness_should_not_penalize_fresh_reference() {
        use crate::reference_tracker::{ReferenceEntry, ReferenceMap};
        use std::time::{SystemTime, UNIX_EPOCH};

        let input = make_input("test staleness", &[]);
        let record = make_record(
            "偏好",
            "prefer 简洁回复",
            "preference",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );

        // 5 days ago
        let now_secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let five_days_ago = now_secs - (5 * 86400);
        let timestamp =
            crate::reference_tracker::tests::unix_secs_to_iso8601_for_test(five_days_ago);

        let mut ref_map = ReferenceMap::default();
        ref_map.records.insert(
            "r1".to_string(),
            ReferenceEntry {
                last_referenced_at: timestamp,
                count: 10,
            },
        );

        let with_ref =
            score_lifecycle_candidate(None, "r1", &record, &input, Some(&ref_map), None).unwrap();
        let without_ref =
            score_lifecycle_candidate(None, "r1", &record, &input, None, None).unwrap();

        assert!(
            with_ref.score >= without_ref.score,
            "fresh reference should boost or be neutral: with_ref={} without_ref={}",
            with_ref.score,
            without_ref.score
        );
    }

    #[test]
    fn lifecycle_staleness_should_not_penalize_missing_entry() {
        use crate::reference_tracker::ReferenceMap;

        let input = make_input("test staleness", &[]);
        let record = make_record(
            "偏好",
            "prefer 简洁回复",
            "preference",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );

        // Empty reference map — record not tracked at all
        let ref_map = ReferenceMap::default();

        let with_empty_map =
            score_lifecycle_candidate(None, "r1", &record, &input, Some(&ref_map), None).unwrap();
        let without_map =
            score_lifecycle_candidate(None, "r1", &record, &input, None, None).unwrap();

        assert_eq!(
            with_empty_map.score, without_map.score,
            "missing entry should not penalize: with_map={} without_map={}",
            with_empty_map.score, without_map.score
        );
    }

    #[test]
    fn lifecycle_contradiction_should_populate_contradicts_field() {
        let input = make_input("test contradiction", &[]);
        let existing_record = make_record(
            "用 cargo install",
            "用 cargo install 安装 binary 到 ~/.cargo/bin",
            "preference",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );
        let new_record = make_record(
            "不用 cargo install",
            "不用 cargo install 安装 binary",
            "preference",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );
        let existing = vec![("existing-1".to_string(), existing_record)];

        let candidate =
            score_lifecycle_candidate(None, "new-1", &new_record, &input, None, Some(&existing))
                .unwrap();
        assert!(
            !candidate.contradicts.is_empty(),
            "contradicts should be non-empty when negation detected"
        );
        assert_eq!(candidate.contradicts[0], "existing-1");
    }

    #[test]
    fn lifecycle_no_contradiction_should_have_empty_contradicts() {
        let input = make_input("test no contradiction", &[]);
        let existing_record = make_record(
            "用 cargo install",
            "用 cargo install 安装 binary 到 ~/.cargo/bin",
            "preference",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );
        let new_record = make_record(
            "偏好简洁回复",
            "prefer 简洁直接的回复风格",
            "preference",
            MemoryScope::User,
            None,
            MemoryLifecycleState::Accepted,
        );
        let existing = vec![("existing-1".to_string(), existing_record)];

        let candidate =
            score_lifecycle_candidate(None, "new-1", &new_record, &input, None, Some(&existing))
                .unwrap();
        assert!(
            candidate.contradicts.is_empty(),
            "contradicts should be empty for unrelated records"
        );
    }
}
