//! Transcript → candidate memory extractor.
//!
//! 从 Claude Code / Codex 的会话消息里启发式地挖出"值得变成记忆"的片段，
//! 产出 `TranscriptCandidate`；上层再把它转成 `ProposeMemoryRequest` 走
//! `LifecycleService::propose_ai`，入库后以 `Candidate` 状态进入审核队列。
//!
//! 启发式目前刻意保守：宁可漏掉模糊的东西，也不要产生一堆噪音候选。
//! 覆盖三类信号：
//! 1. 用户反馈 / 纠正（"不要每次都"/"always"/"don't"/"记住"/"prefer"）
//!    → memory_type="preference"
//! 2. 决策 / 方案敲定（"决定"/"就这么"/"decided to"/"chose"）
//!    → memory_type="decision"
//! 3. 事故 / 踩坑回顾（"出过"/"曾经"/"之前"+"bug|incident|regression"）
//!    → memory_type="incident"

use crate::desktop::DesktopSessionMessage;
use crate::domain::memory_lifecycle::MemoryScope;
use crate::lifecycle_service::LifecycleService;
use crate::lifecycle_store::{ProposeMemoryRequest, TransitionMetadata};
use crate::session_sources::{load_provider_messages, load_provider_sessions, raw_session_id};
use serde::Serialize;
use std::path::Path;
use ts_rs::TS;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub struct TranscriptCandidate {
    pub title: String,
    pub summary: String,
    pub memory_type: String,
    pub scope: MemoryScope,
    pub source_ref: String,
    pub evidence_refs: Vec<String>,
    pub signal: CandidateSignal,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub enum CandidateSignal {
    Preference,
    Decision,
    Incident,
}

impl CandidateSignal {
    pub fn memory_type(self) -> &'static str {
        match self {
            Self::Preference => "preference",
            Self::Decision => "decision",
            Self::Incident => "incident",
        }
    }
}

/// 构建一次 session 的候选集合。
///
/// `session_ref` 形如 `claude:<id>` 或 `codex:<id>`，会作为 `source_ref`
/// 写入候选；`evidence_refs` 额外追加命中消息的 role:timestamp 方便审核溯源。
pub fn extract_from_messages(
    session_ref: &str,
    messages: &[DesktopSessionMessage],
) -> Vec<TranscriptCandidate> {
    let mut out = Vec::new();
    for msg in messages {
        let Some(signal) = classify(msg) else {
            continue;
        };
        let trimmed = normalize(&msg.content);
        if trimmed.is_empty() {
            continue;
        }
        let summary = truncate_chars(&trimmed, 320);
        let title = truncate_chars(&first_line(&trimmed), 80);
        let evidence = format!("{}:{}", msg.role, msg.timestamp);
        out.push(TranscriptCandidate {
            title,
            summary,
            memory_type: signal.memory_type().to_string(),
            scope: default_scope_for(signal),
            source_ref: session_ref.to_string(),
            evidence_refs: vec![session_ref.to_string(), evidence],
            signal,
        });
    }
    dedupe_by_summary(out)
}

impl TranscriptCandidate {
    pub fn into_propose_request(self, actor: Option<String>) -> ProposeMemoryRequest {
        ProposeMemoryRequest {
            title: self.title,
            summary: self.summary,
            memory_type: self.memory_type,
            scope: self.scope,
            source_ref: self.source_ref,
            project_id: None,
            user_id: None,
            sensitivity: None,
            metadata: TransitionMetadata {
                actor,
                reason: Some("imported from session transcript".to_string()),
                evidence_refs: self.evidence_refs,
            },
            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,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub enum ImportProvider {
    Claude,
    Codex,
}

impl ImportProvider {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Claude => "claude",
            Self::Codex => "codex",
        }
    }

    pub fn parse(value: &str) -> anyhow::Result<Self> {
        match value {
            "claude" => Ok(Self::Claude),
            "codex" => Ok(Self::Codex),
            other => Err(anyhow::anyhow!(
                "unsupported importer provider: {other} (expected: claude | codex)"
            )),
        }
    }
}

#[derive(Debug, Clone, Serialize, TS)]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub struct ImportSessionResponse {
    pub session_ref: String,
    pub total_messages: usize,
    pub candidate_count: usize,
    pub applied: bool,
    pub applied_record_ids: Vec<String>,
    pub candidates: Vec<TranscriptCandidate>,
}

/// 驱动一次 session → candidate 流水。
///
/// - `provider` + `session_id` 对齐 `session_sources::load_provider_sessions`
///   产出的 `DesktopSessionItem { provider, session_id }` 契约。
///   `session_id` 只接 raw id（不带 `<provider>:` 前缀）。
/// - `apply=false` 是 dry run，只返回候选，不写 ledger。
/// - `apply=true` 会把每个候选走 `LifecycleService::propose_ai`，record_id 回传。
/// - `actor` 作为写入候选时的 provenance actor。
pub fn import_session(
    config_path: &Path,
    provider: ImportProvider,
    session_id: &str,
    apply: bool,
    actor: Option<String>,
) -> anyhow::Result<ImportSessionResponse> {
    let sessions = load_provider_sessions(None)?;
    let raw = session_id.to_string();
    let provider_str = provider.as_str();
    let target = sessions
        .into_iter()
        .find(|s| s.provider == provider_str && raw_session_id(&s.session_id) == raw)
        .ok_or_else(|| {
            anyhow::anyhow!(
                "session not found: provider={} session_id={} (先用桌面端或 memory browse 列表确认)",
                provider_str,
                raw,
            )
        })?;

    let messages = load_provider_messages(&target, 0, 0)?;
    let session_ref = format!("{}:{}", provider_str, raw);
    let candidates = extract_from_messages(&session_ref, &messages.messages);
    let candidate_count = candidates.len();

    let mut applied_record_ids = Vec::new();
    if apply && !candidates.is_empty() {
        let service = LifecycleService::new();
        for candidate in &candidates {
            let request = candidate.clone().into_propose_request(actor.clone());
            let result = service.propose_ai(config_path, request)?;
            applied_record_ids.push(result.entry.record_id);
        }
    }

    Ok(ImportSessionResponse {
        session_ref,
        total_messages: messages.total_messages,
        candidate_count,
        applied: apply,
        applied_record_ids,
        candidates,
    })
}

fn classify(msg: &DesktopSessionMessage) -> Option<CandidateSignal> {
    let content = msg.content.to_lowercase();
    if msg.role == "user" && contains_any(&content, PREFERENCE_KEYS) {
        return Some(CandidateSignal::Preference);
    }
    if contains_any(&content, DECISION_KEYS) {
        return Some(CandidateSignal::Decision);
    }
    if contains_any(&content, INCIDENT_KEYS) {
        return Some(CandidateSignal::Incident);
    }
    None
}

fn default_scope_for(signal: CandidateSignal) -> MemoryScope {
    match signal {
        CandidateSignal::Preference => MemoryScope::User,
        CandidateSignal::Decision => MemoryScope::Project,
        CandidateSignal::Incident => MemoryScope::Project,
    }
}

const PREFERENCE_KEYS: &[&str] = &[
    "不要每次",
    "别每次",
    "不用每次",
    "记住",
    "以后都",
    "默认都",
    "我喜欢",
    "我倾向",
    "我偏好",
    "下次请",
    "以后请",
    "prefer",
    "please always",
    "please don't",
    "stop doing",
    "from now on",
    "never do",
    "always use",
    "i like",
    "i want",
    "don't ever",
];

const DECISION_KEYS: &[&str] = &[
    "决定",
    "就这么定",
    "敲定",
    "定了",
    "选型",
    "方案就",
    "确认用",
    "最终选",
    "decided to",
    "we'll go with",
    "chose",
    "final decision",
    "going with",
    "let's use",
    "settled on",
];

const INCIDENT_KEYS: &[&str] = &[
    "踩过坑",
    "出过问题",
    "之前翻过",
    "回归了",
    "上次挂掉",
    "踩坑",
    "bug 是因为",
    "根因是",
    "regressed",
    "broke production",
    "incident",
    "postmortem",
    "root caused to",
    "root cause was",
    "caused by",
    "lesson learned",
];

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

fn normalize(content: &str) -> String {
    content.trim().replace("\r\n", "\n")
}

fn first_line(content: &str) -> String {
    content
        .lines()
        .find(|line| !line.trim().is_empty())
        .unwrap_or("")
        .trim()
        .to_string()
}

fn truncate_chars(value: &str, max_chars: usize) -> String {
    let chars: Vec<char> = value.chars().collect();
    if chars.len() <= max_chars {
        return value.to_string();
    }
    let mut truncated: String = chars.iter().take(max_chars).collect();
    truncated.push('…');
    truncated
}

fn dedupe_by_summary(candidates: Vec<TranscriptCandidate>) -> Vec<TranscriptCandidate> {
    let mut seen = std::collections::BTreeSet::new();
    let mut out = Vec::with_capacity(candidates.len());
    for candidate in candidates {
        let key = (candidate.memory_type.clone(), candidate.summary.clone());
        if seen.insert(key) {
            out.push(candidate);
        }
    }
    out
}

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

    fn msg(role: &str, ts: &str, content: &str) -> DesktopSessionMessage {
        DesktopSessionMessage {
            role: role.to_string(),
            timestamp: ts.to_string(),
            content: content.to_string(),
            truncated: false,
        }
    }

    #[test]
    fn user_preference_is_captured_as_preference() {
        let out = extract_from_messages(
            "claude:abc",
            &[msg(
                "user",
                "2026-04-18T12:00Z",
                "以后都别每次都问我是否继续，默认自主推进",
            )],
        );
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].signal, CandidateSignal::Preference);
        assert_eq!(out[0].scope, MemoryScope::User);
        assert_eq!(out[0].memory_type, "preference");
        assert_eq!(out[0].source_ref, "claude:abc");
        assert!(
            out[0]
                .evidence_refs
                .contains(&"user:2026-04-18T12:00Z".to_string())
        );
    }

    #[test]
    fn assistant_decision_is_captured_as_decision() {
        let out = extract_from_messages(
            "codex:xyz",
            &[msg(
                "assistant",
                "2026-04-18T12:30Z",
                "I've decided to use React + shadcn/ui for the desktop UI.",
            )],
        );
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].signal, CandidateSignal::Decision);
        assert_eq!(out[0].memory_type, "decision");
        assert_eq!(out[0].scope, MemoryScope::Project);
    }

    #[test]
    fn incident_wording_is_captured_as_incident() {
        let out = extract_from_messages(
            "claude:abc",
            &[msg(
                "user",
                "2026-04-18T12:45Z",
                "这个地方之前翻过，上次 mock 的测试过了但 prod 挂掉了，要走真实数据库",
            )],
        );
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].signal, CandidateSignal::Incident);
    }

    #[test]
    fn non_signal_messages_are_skipped() {
        let out = extract_from_messages(
            "claude:abc",
            &[
                msg("user", "t1", "把这个函数改成小写好吗"),
                msg("assistant", "t2", "好的，已经改了"),
            ],
        );
        assert!(out.is_empty());
    }

    #[test]
    fn duplicates_collapse_by_summary() {
        let m = msg("user", "t", "请以后都记住：不要每次都问我确认");
        let out = extract_from_messages("claude:abc", &[m.clone(), m]);
        assert_eq!(out.len(), 1);
    }

    #[test]
    fn long_content_is_truncated_in_summary_and_title() {
        let long = "不要每次都问了, ".repeat(80);
        let out = extract_from_messages("claude:abc", &[msg("user", "t", &long)]);
        assert_eq!(out.len(), 1);
        let summary_chars: Vec<char> = out[0].summary.chars().collect();
        assert!(summary_chars.len() <= 321); // 320 + ellipsis
        let title_chars: Vec<char> = out[0].title.chars().collect();
        assert!(title_chars.len() <= 81);
    }

    #[test]
    fn into_propose_request_fills_metadata_with_actor_and_reason() {
        let candidate = extract_from_messages("claude:abc", &[msg("user", "t", "prefer 中文回复")])
            .into_iter()
            .next()
            .unwrap();
        let request = candidate.into_propose_request(Some("spool-importer".to_string()));
        assert_eq!(request.metadata.actor.as_deref(), Some("spool-importer"));
        assert_eq!(
            request.metadata.reason.as_deref(),
            Some("imported from session transcript"),
        );
        assert!(
            request
                .metadata
                .evidence_refs
                .contains(&"claude:abc".to_string())
        );
    }

    #[test]
    fn import_provider_parse_rejects_unknown() {
        assert!(matches!(
            ImportProvider::parse("claude").unwrap(),
            ImportProvider::Claude
        ));
        assert!(matches!(
            ImportProvider::parse("codex").unwrap(),
            ImportProvider::Codex
        ));
        assert!(ImportProvider::parse("opencode").is_err());
    }

    #[test]
    fn import_session_errors_when_provider_session_missing() {
        use tempfile::TempDir;
        let tmp = TempDir::new().unwrap();
        let config = tmp.path().join("spool.toml");
        std::fs::write(&config, "# empty stub for importer test\n").unwrap();
        let err = import_session(
            &config,
            ImportProvider::Claude,
            "definitely-not-a-real-session-id-zzz",
            false,
            None,
        )
        .expect_err("should error for missing session");
        let text = format!("{err}");
        assert!(
            text.contains("session not found"),
            "unexpected error: {text}"
        );
    }
}
