//! Wiki INDEX.md 自动生成。
//!
//! 从 ledger projection 生成 `<vault_root>/INDEX.md`,按 scope (user / project) 分节
//! 列出所有 accepted/canonical 记录。该文件作为 LLM 在 wakeup 时的知识导航入口,
//! 类比 Karpathy LLM Wiki 的 index 层。
//!
//! ## 不变量
//! - 只列 Accepted / Canonical 记录 (candidate 在 review queue,archived 不导航)
//! - 按 scope 分段:User-Level (always loaded) → Project: `<id>` 若干段
//! - 段内按 recorded_at 倒序,最新优先
//! - 幂等:内容不变不写盘,避免 mtime 抖动
//! - 失败降级:错误日志 stderr,不阻断 lifecycle 主路径

use crate::domain::{MemoryLifecycleState, MemoryScope};
use crate::lifecycle_store::{LedgerEntry, LifecycleStore, latest_state_entries};
use anyhow::{Context, Result};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

pub const INDEX_FILE_NAME: &str = "INDEX.md";

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IndexWriteStatus {
    Created,
    Updated,
    Unchanged,
}

#[derive(Debug, Clone)]
pub struct IndexWriteResult {
    pub path: PathBuf,
    pub status: IndexWriteStatus,
    pub user_entries: usize,
    pub project_entries: usize,
}

pub fn index_path(vault_root: &Path) -> PathBuf {
    vault_root.join(INDEX_FILE_NAME)
}

/// 把 ledger 条目渲染为 markdown 导航索引。
///
/// 输出结构:
/// ```text
/// # Spool Knowledge Index
///
/// ## User-Level (always loaded)
/// - [title] type · record_id
///
/// ## Project: <project-id>
/// - [title] type · record_id
/// ```
pub fn render_index(entries: &[LedgerEntry]) -> String {
    let mut user_entries: Vec<&LedgerEntry> = Vec::new();
    let mut project_entries: BTreeMap<String, Vec<&LedgerEntry>> = BTreeMap::new();
    let mut other_entries: Vec<&LedgerEntry> = Vec::new();

    for entry in entries {
        if !matches!(
            entry.record.state,
            MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
        ) {
            continue;
        }
        match &entry.record.scope {
            MemoryScope::User => user_entries.push(entry),
            MemoryScope::Project => {
                let project_id = entry
                    .record
                    .project_id
                    .clone()
                    .unwrap_or_else(|| "unknown".to_string());
                project_entries.entry(project_id).or_default().push(entry);
            }
            MemoryScope::Workspace | MemoryScope::Team | MemoryScope::Agent => {
                other_entries.push(entry);
            }
        }
    }

    sort_desc_by_recorded_at(&mut user_entries);
    for entries in project_entries.values_mut() {
        sort_desc_by_recorded_at(entries);
    }
    sort_desc_by_recorded_at(&mut other_entries);

    let mut out = String::new();
    out.push_str("# Spool Knowledge Index\n\n");
    out.push_str(
        "> 自动生成的知识导航。仅列 accepted / canonical 记录。由 spool lifecycle \
         writes 自动刷新,不要手动编辑。\n\n",
    );

    out.push_str("## User-Level (always loaded)\n\n");
    if user_entries.is_empty() {
        out.push_str("*(尚无用户级记忆)*\n\n");
    } else {
        for entry in &user_entries {
            out.push_str(&render_entry_line(entry));
        }
        out.push('\n');
    }

    if project_entries.is_empty() {
        out.push_str("## Projects\n\n*(尚无 project-scoped 记忆)*\n\n");
    } else {
        for (project_id, entries) in &project_entries {
            out.push_str(&format!("## Project: {}\n\n", project_id));
            for entry in entries {
                out.push_str(&render_entry_line(entry));
            }
            out.push('\n');
        }
    }

    if !other_entries.is_empty() {
        out.push_str("## Shared (workspace / team / agent)\n\n");
        for entry in &other_entries {
            out.push_str(&render_entry_line(entry));
        }
        out.push('\n');
    }

    out
}

fn sort_desc_by_recorded_at(entries: &mut [&LedgerEntry]) {
    entries.sort_by(|a, b| b.recorded_at.cmp(&a.recorded_at));
}

fn render_entry_line(entry: &LedgerEntry) -> String {
    let title = if entry.record.title.trim().is_empty() {
        "(untitled)"
    } else {
        entry.record.title.as_str()
    };
    let memory_type = &entry.record.memory_type;
    let state_marker = match entry.record.state {
        MemoryLifecycleState::Canonical => "★",
        _ => "·",
    };
    format!(
        "- {} [{}] {} — `{}`\n",
        state_marker, memory_type, title, entry.record_id
    )
}

/// 把 `entries` 渲染成 INDEX.md 并写入 `<vault_root>/INDEX.md`,幂等。
pub fn write_index(vault_root: &Path, entries: &[LedgerEntry]) -> Result<IndexWriteResult> {
    let path = index_path(vault_root);
    let desired = render_index(entries);

    let (status, _) = match fs::read_to_string(&path) {
        Ok(existing) if existing == desired => (IndexWriteStatus::Unchanged, existing),
        Ok(existing) => (IndexWriteStatus::Updated, existing),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            (IndexWriteStatus::Created, String::new())
        }
        Err(err) => {
            return Err(anyhow::Error::new(err).context(format!(
                "failed to read existing INDEX.md at {}",
                path.display()
            )));
        }
    };

    if !matches!(status, IndexWriteStatus::Unchanged) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).with_context(|| {
                format!("failed to create INDEX.md parent dir {}", parent.display())
            })?;
        }
        fs::write(&path, &desired)
            .with_context(|| format!("failed to write INDEX.md at {}", path.display()))?;
    }

    let (user_entries, project_entries) = count_active_entries(entries);
    Ok(IndexWriteResult {
        path,
        status,
        user_entries,
        project_entries,
    })
}

fn count_active_entries(entries: &[LedgerEntry]) -> (usize, usize) {
    let mut user_count = 0;
    let mut project_count = 0;
    for entry in entries {
        if !matches!(
            entry.record.state,
            MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
        ) {
            continue;
        }
        match &entry.record.scope {
            MemoryScope::User => user_count += 1,
            MemoryScope::Project => project_count += 1,
            MemoryScope::Workspace | MemoryScope::Team | MemoryScope::Agent => {}
        }
    }
    (user_count, project_count)
}

/// 从 config_path 加载 AppConfig,resolve vault_root 和 lifecycle_root,
/// 读 latest_state 条目并刷写 INDEX.md。失败降级为 stderr warn 并返回 None,
/// 不阻断调用方的 lifecycle 主路径。
pub fn refresh_index_from_config(config_path: &Path) -> Option<IndexWriteResult> {
    match refresh_index_inner(config_path) {
        Ok(result) => Some(result),
        Err(error) => {
            eprintln!("[spool] wiki index refresh failed: {error:#}");
            None
        }
    }
}

fn refresh_index_inner(config_path: &Path) -> Result<IndexWriteResult> {
    let config = crate::app::load(config_path)
        .with_context(|| format!("failed to load config {}", config_path.display()))?;
    let vault_root = crate::app::resolve_override_path(&config.vault.root, config_path)
        .context("failed to resolve vault root")?;
    let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
    let lifecycle_root = crate::lifecycle_store::lifecycle_root_from_config(config_dir);
    let store = LifecycleStore::new(&lifecycle_root);
    let entries = latest_state_entries(&store).context("failed to read ledger entries")?;
    write_index(&vault_root, &entries)
}

/// CLI-facing variant of `refresh_index_from_config` that propagates errors
/// instead of swallowing them. Used by `memory sync-index --apply`.
pub fn refresh_index_result(config_path: &Path) -> Result<IndexWriteResult> {
    refresh_index_inner(config_path)
}

/// Render the INDEX markdown without writing to disk (dry-run preview).
pub fn render_index_from_config(config_path: &Path) -> Result<String> {
    let config = crate::app::load(config_path)
        .with_context(|| format!("failed to load config {}", config_path.display()))?;
    let vault_root = crate::app::resolve_override_path(&config.vault.root, config_path)
        .context("failed to resolve vault root")?;
    let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
    let lifecycle_root = crate::lifecycle_store::lifecycle_root_from_config(config_dir);
    let store = LifecycleStore::new(&lifecycle_root);
    let entries = latest_state_entries(&store).context("failed to read ledger entries")?;
    let index_path = index_path(&vault_root);
    let desired = render_index(&entries);
    let status_hint = match fs::read_to_string(&index_path) {
        Ok(existing) if existing == desired => "unchanged",
        Ok(_) => "would update",
        Err(_) => "would create",
    };
    Ok(format!(
        "{desired}\n---\nTarget: {}\nStatus: {status_hint}\n",
        index_path.display()
    ))
}

/// 读 `<vault_root>/INDEX.md`,按当前 scope 过滤出相关 section:
/// - User-Level 段始终保留
/// - 若 `current_project_id = Some(id)`,额外保留 `## Project: <id>` 段
/// - 其他 project 段丢弃,避免 wakeup 随记忆总量线性膨胀
/// - INDEX 缺失或读失败时返回 None,不抛错
///
/// 返回 Some(markdown_fragment) 表示有内容可注入 wakeup,None 表示跳过。
pub fn load_index_section(vault_root: &Path, current_project_id: Option<&str>) -> Option<String> {
    let path = index_path(vault_root);
    let raw = fs::read_to_string(&path).ok()?;
    let filtered = filter_index_sections(&raw, current_project_id);
    if filtered.trim().is_empty() {
        None
    } else {
        Some(filtered)
    }
}

/// 纯函数:按 `## ` 大标题切段,保留 User-Level + 当前 project 段。
/// 暴露方便单元测试。
pub fn filter_index_sections(raw: &str, current_project_id: Option<&str>) -> String {
    let wanted_project_heading = current_project_id.map(|id| format!("## Project: {}", id.trim()));

    let mut out = String::new();
    let mut include = false;
    let mut first_line = true;

    for line in raw.lines() {
        if first_line && line.starts_with("# ") {
            out.push_str(line);
            out.push('\n');
            first_line = false;
            continue;
        }
        first_line = false;

        if let Some(stripped) = line.strip_prefix("## ") {
            // Start of a new H2 section — decide whether to include it.
            include = stripped.starts_with("User-Level")
                || wanted_project_heading
                    .as_deref()
                    .is_some_and(|want| line == want);
            if include {
                out.push_str(line);
                out.push('\n');
            }
            continue;
        }

        if include {
            out.push_str(line);
            out.push('\n');
        }
    }

    // Trim trailing blank lines.
    while out.ends_with("\n\n") {
        out.pop();
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::{
        MemoryLedgerAction, MemoryLifecycleState, MemoryOrigin, MemoryRecord, MemoryScope,
        MemorySourceKind,
    };
    use crate::lifecycle_store::TransitionMetadata;
    use tempfile::tempdir;

    fn make_entry(
        record_id: &str,
        title: &str,
        memory_type: &str,
        state: MemoryLifecycleState,
        scope: MemoryScope,
        project_id: Option<&str>,
        recorded_at: &str,
    ) -> LedgerEntry {
        LedgerEntry {
            schema_version: "memory-ledger.v1".to_string(),
            recorded_at: recorded_at.to_string(),
            record_id: record_id.to_string(),
            scope_key: match &scope {
                MemoryScope::User => "user:long".to_string(),
                MemoryScope::Project => {
                    format!("project:{}", project_id.unwrap_or("unknown"))
                }
                MemoryScope::Workspace => "workspace:shared".to_string(),
                MemoryScope::Team => "team:shared".to_string(),
                MemoryScope::Agent => "agent:shared".to_string(),
            },
            action: MemoryLedgerAction::RecordManual,
            source_kind: MemorySourceKind::Manual,
            metadata: TransitionMetadata::default(),
            record: 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: "manual:test".to_string(),
                },
                project_id: project_id.map(ToString::to_string),
                user_id: Some("long".to_string()),
                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,
            },
        }
    }

    #[test]
    fn render_index_should_group_by_scope_and_skip_non_active_states() {
        let entries = vec![
            make_entry(
                "mem-a",
                "User preference",
                "preference",
                MemoryLifecycleState::Accepted,
                MemoryScope::User,
                None,
                "2026-05-10T00:00:00Z",
            ),
            make_entry(
                "mem-b",
                "Project decision",
                "decision",
                MemoryLifecycleState::Canonical,
                MemoryScope::Project,
                Some("spool"),
                "2026-05-11T00:00:00Z",
            ),
            make_entry(
                "mem-c",
                "Candidate to skip",
                "workflow",
                MemoryLifecycleState::Candidate,
                MemoryScope::User,
                None,
                "2026-05-12T00:00:00Z",
            ),
            make_entry(
                "mem-d",
                "Archived to skip",
                "incident",
                MemoryLifecycleState::Archived,
                MemoryScope::Project,
                Some("spool"),
                "2026-05-09T00:00:00Z",
            ),
        ];

        let out = render_index(&entries);

        assert!(out.contains("User-Level"));
        assert!(out.contains("Project: spool"));
        assert!(out.contains("User preference"));
        assert!(out.contains("Project decision"));
        assert!(!out.contains("Candidate to skip"));
        assert!(!out.contains("Archived to skip"));
        // Canonical gets star marker
        assert!(out.contains("★ [decision] Project decision"));
        // Accepted gets bullet marker
        assert!(out.contains("· [preference] User preference"));
    }

    #[test]
    fn render_index_should_sort_desc_by_recorded_at_within_scope() {
        let entries = vec![
            make_entry(
                "mem-old",
                "Old entry",
                "preference",
                MemoryLifecycleState::Accepted,
                MemoryScope::User,
                None,
                "2026-05-01T00:00:00Z",
            ),
            make_entry(
                "mem-new",
                "New entry",
                "preference",
                MemoryLifecycleState::Accepted,
                MemoryScope::User,
                None,
                "2026-05-10T00:00:00Z",
            ),
        ];
        let out = render_index(&entries);
        let new_pos = out.find("New entry").unwrap();
        let old_pos = out.find("Old entry").unwrap();
        assert!(new_pos < old_pos, "newer entries must appear first");
    }

    #[test]
    fn render_index_should_show_placeholders_when_scope_empty() {
        let entries: Vec<LedgerEntry> = Vec::new();
        let out = render_index(&entries);
        assert!(out.contains("尚无用户级记忆"));
        assert!(out.contains("尚无 project-scoped 记忆"));
    }

    #[test]
    fn write_index_should_create_file_and_be_idempotent() {
        let dir = tempdir().unwrap();
        let entries = vec![make_entry(
            "mem-a",
            "User preference",
            "preference",
            MemoryLifecycleState::Accepted,
            MemoryScope::User,
            None,
            "2026-05-10T00:00:00Z",
        )];

        let first = write_index(dir.path(), &entries).unwrap();
        assert_eq!(first.status, IndexWriteStatus::Created);
        assert_eq!(first.user_entries, 1);
        assert_eq!(first.project_entries, 0);
        assert!(first.path.exists());

        let second = write_index(dir.path(), &entries).unwrap();
        assert_eq!(second.status, IndexWriteStatus::Unchanged);
    }

    #[test]
    fn write_index_should_mark_updated_when_content_changes() {
        let dir = tempdir().unwrap();
        let entries_v1 = vec![make_entry(
            "mem-a",
            "Original title",
            "preference",
            MemoryLifecycleState::Accepted,
            MemoryScope::User,
            None,
            "2026-05-10T00:00:00Z",
        )];
        let entries_v2 = vec![make_entry(
            "mem-a",
            "Updated title",
            "preference",
            MemoryLifecycleState::Accepted,
            MemoryScope::User,
            None,
            "2026-05-10T00:00:00Z",
        )];

        write_index(dir.path(), &entries_v1).unwrap();
        let second = write_index(dir.path(), &entries_v2).unwrap();
        assert_eq!(second.status, IndexWriteStatus::Updated);
        let body = fs::read_to_string(&second.path).unwrap();
        assert!(body.contains("Updated title"));
        assert!(!body.contains("Original title"));
    }

    #[test]
    fn filter_index_sections_should_keep_user_level_and_requested_project() {
        let raw = "# Spool Knowledge Index\n\n\
                   > preamble\n\n\
                   ## User-Level (always loaded)\n\n\
                   - · [preference] stuff — `mem-u1`\n\n\
                   ## Project: spool\n\n\
                   - · [decision] chose X — `mem-p1`\n\n\
                   ## Project: other\n\n\
                   - · [note] irrelevant — `mem-o1`\n";
        let filtered = filter_index_sections(raw, Some("spool"));
        assert!(filtered.contains("# Spool Knowledge Index"));
        assert!(filtered.contains("User-Level"));
        assert!(filtered.contains("mem-u1"));
        assert!(filtered.contains("## Project: spool"));
        assert!(filtered.contains("mem-p1"));
        assert!(!filtered.contains("Project: other"));
        assert!(!filtered.contains("mem-o1"));
        // Preamble (between # heading and first ## section) should be dropped.
        assert!(!filtered.contains("preamble"));
    }

    #[test]
    fn filter_index_sections_should_keep_only_user_level_when_no_project() {
        let raw = "# Spool Knowledge Index\n\n\
                   ## User-Level (always loaded)\n\n\
                   - · [preference] stuff — `mem-u1`\n\n\
                   ## Project: spool\n\n\
                   - · [decision] chose X — `mem-p1`\n";
        let filtered = filter_index_sections(raw, None);
        assert!(filtered.contains("User-Level"));
        assert!(filtered.contains("mem-u1"));
        assert!(!filtered.contains("Project: spool"));
        assert!(!filtered.contains("mem-p1"));
    }

    #[test]
    fn load_index_section_should_return_none_when_file_missing() {
        let dir = tempdir().unwrap();
        assert!(load_index_section(dir.path(), Some("spool")).is_none());
    }

    #[test]
    fn load_index_section_should_load_and_filter_written_index() {
        let dir = tempdir().unwrap();
        let entries = vec![
            make_entry(
                "mem-u",
                "User pref",
                "preference",
                MemoryLifecycleState::Accepted,
                MemoryScope::User,
                None,
                "2026-05-10T00:00:00Z",
            ),
            make_entry(
                "mem-p",
                "Project decision",
                "decision",
                MemoryLifecycleState::Accepted,
                MemoryScope::Project,
                Some("spool"),
                "2026-05-11T00:00:00Z",
            ),
            make_entry(
                "mem-x",
                "Other project",
                "note",
                MemoryLifecycleState::Accepted,
                MemoryScope::Project,
                Some("other"),
                "2026-05-12T00:00:00Z",
            ),
        ];
        write_index(dir.path(), &entries).unwrap();

        let filtered = load_index_section(dir.path(), Some("spool"))
            .expect("index file exists and should filter cleanly");
        assert!(filtered.contains("User pref"));
        assert!(filtered.contains("Project decision"));
        assert!(!filtered.contains("Other project"));
    }
}
