//! `spool hook stop` — drain pending signals + scan transcript for
//! self-tags and incidents + persist via the shared distill pipeline,
//! then attempt silent knowledge distillation.
//!
//! ## Pipeline
//! 1. Resolve `transcript_path` (priority: `--transcript-path` flag,
//!    `--hook-input` JSON `transcript_path` field, fallback scan of
//!    `~/.claude/projects/<sanitized-cwd>/`).
//! 2. Delegate to [`crate::distill::pipeline::run`] which performs:
//!    queue drain → self-tag heuristic → extraction heuristic →
//!    redact + dedupe + LifecycleService writes (accepted +
//!    candidate).
//! 3. Attempt silent knowledge distillation: detect clusters of
//!    related fragments and auto-create candidate knowledge pages
//!    (actor: `spool-auto-distill`). Failures are logged to stderr
//!    and never propagate.
//! 4. Write `<cwd>/.spool/last-stop.unix` marker for debugging.
//!
//! Stop hook is the **automatic** entry point for distill. The MCP
//! `memory_distill_pending` tool exposes the same pipeline as a
//! manual on-demand entry point (R4a).

use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result};
use serde_json::Value;

use super::project_runtime_dir;
use crate::config::loader::load_from_path;
use crate::distill::pipeline::{self, DistillReport, DistillRequest};
use crate::distill::transcript;
use crate::engine::project_matcher::match_project;
use crate::knowledge;

#[derive(Debug, Clone, Default)]
pub struct StopArgs {
    pub config_path: PathBuf,
    pub cwd: Option<PathBuf>,
    pub transcript_path: Option<PathBuf>,
    pub hook_input: Option<String>,
    pub home: Option<PathBuf>,
}

#[derive(Debug, Clone, Default)]
pub struct StopReport {
    pub distill: DistillReport,
    /// Number of knowledge pages auto-created during silent distillation.
    pub knowledge_pages_created: usize,
    /// Record IDs of knowledge page candidates created.
    pub knowledge_page_ids: Vec<String>,
}

impl StopReport {
    pub fn transcript_path(&self) -> Option<&Path> {
        self.distill.transcript_path.as_deref()
    }

    pub fn signals_persisted(&self) -> &[String] {
        &self.distill.signals_persisted
    }

    pub fn candidates_persisted(&self) -> &[String] {
        &self.distill.candidates_persisted
    }
}

pub fn run(args: StopArgs) -> Result<StopReport> {
    let cwd = match args.cwd {
        Some(p) => p,
        None => std::env::current_dir().context("resolving cwd for stop hook")?,
    };
    let runtime_dir = project_runtime_dir(&cwd)?;

    let home = args.home.or_else(crate::support::home_dir);
    let transcript_path = args
        .transcript_path
        .or_else(|| parse_transcript_path_from_hook_input(args.hook_input.as_deref()))
        .or_else(|| {
            home.as_deref()
                .and_then(|h| transcript::find_latest_for_cwd(&cwd, h))
        });

    let request = DistillRequest::new(args.config_path.clone(), cwd.clone(), transcript_path)
        .with_actor("spool-hook-stop")
        .with_source_refs("hook:stop:self-tag", "hook:stop:extraction")
        .with_project_id(
            load_from_path(&args.config_path)
                .ok()
                .and_then(|cfg| match_project(&cfg, &cwd))
                .map(|p| p.id),
        );
    let distill = pipeline::run(request)?;

    // Silent knowledge distillation: detect clusters and auto-create
    // candidate knowledge pages. Wrapped in a catch-all so failures
    // never propagate to the caller (hooks must exit 0).
    let (knowledge_pages_created, knowledge_page_ids) =
        run_silent_knowledge_distill(&args.config_path);

    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let marker_path = runtime_dir.join("last-stop.unix");
    std::fs::write(&marker_path, stamp.to_string())
        .with_context(|| format!("writing stop timestamp {}", marker_path.display()))?;

    Ok(StopReport {
        distill,
        knowledge_pages_created,
        knowledge_page_ids,
    })
}

/// Attempt silent knowledge distillation. Returns (pages_created, record_ids).
/// Any error is logged to stderr and swallowed — this must never break the
/// Stop hook.
fn run_silent_knowledge_distill(config_path: &Path) -> (usize, Vec<String>) {
    match try_knowledge_distill(config_path) {
        Ok((count, ids)) => (count, ids),
        Err(err) => {
            eprintln!("[spool hook stop] knowledge distill suppressed: {:#}", err);
            (0, Vec::new())
        }
    }
}

/// Inner knowledge distillation logic. Detects clusters and persists
/// candidate knowledge pages.
fn try_knowledge_distill(config_path: &Path) -> Result<(usize, Vec<String>)> {
    let drafts = knowledge::detect_knowledge_clusters(config_path)?;
    if drafts.is_empty() {
        return Ok((0, Vec::new()));
    }
    let ids = knowledge::apply_distill(config_path, &drafts, "spool-auto-distill")?;
    Ok((ids.len(), ids))
}

fn parse_transcript_path_from_hook_input(input: Option<&str>) -> Option<PathBuf> {
    let raw = input?.trim();
    if raw.is_empty() {
        return None;
    }
    let value: Value = serde_json::from_str(raw).ok()?;
    value
        .get("transcript_path")
        .and_then(|v| v.as_str())
        .map(PathBuf::from)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::MemoryScope;
    use crate::lifecycle_service::LifecycleService;
    use crate::lifecycle_store::{RecordMemoryRequest, TransitionMetadata};
    use serde_json::{Value, json};
    use std::fs;
    use tempfile::tempdir;

    fn fixture_config(temp: &tempfile::TempDir) -> PathBuf {
        let cfg = temp.path().join("spool.toml");
        fs::write(&cfg, "[vault]\nroot = \"/tmp\"\n").unwrap();
        cfg
    }

    fn write_transcript(path: &Path, entries: &[Value]) {
        let mut body = String::new();
        for e in entries {
            body.push_str(&e.to_string());
            body.push('\n');
        }
        fs::write(path, body).unwrap();
    }

    #[test]
    fn run_writes_marker_when_no_transcript() {
        let temp = tempdir().unwrap();
        let cfg = fixture_config(&temp);
        let cwd = temp.path().join("repo");
        fs::create_dir_all(&cwd).unwrap();

        let report = run(StopArgs {
            config_path: cfg,
            cwd: Some(cwd.clone()),
            transcript_path: None,
            hook_input: None,
            home: Some(temp.path().join("fake-home")),
        })
        .unwrap();

        assert_eq!(report.distill.signals_detected, 0);
        assert!(cwd.join(".spool").join("last-stop.unix").exists());
    }

    #[test]
    fn run_extracts_self_tag_via_pipeline() {
        let temp = tempdir().unwrap();
        let cfg = fixture_config(&temp);
        let cwd = temp.path().join("repo");
        fs::create_dir_all(&cwd).unwrap();
        let transcript_path = temp.path().join("session.jsonl");
        write_transcript(
            &transcript_path,
            &[json!({
                "type": "user",
                "message": {"role": "user", "content": "记一下：cargo install 是默认安装路径"}
            })],
        );

        let report = run(StopArgs {
            config_path: cfg.clone(),
            cwd: Some(cwd),
            transcript_path: Some(transcript_path),
            hook_input: None,
            home: None,
        })
        .unwrap();
        assert_eq!(report.distill.signals_persisted.len(), 1);
        let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
        assert_eq!(snap.wakeup_ready.len(), 1);
        assert_eq!(snap.wakeup_ready[0].record.memory_type, "preference");
    }

    #[test]
    fn run_skips_assistant_self_tag_attempts() {
        let temp = tempdir().unwrap();
        let cfg = fixture_config(&temp);
        let cwd = temp.path().join("repo");
        fs::create_dir_all(&cwd).unwrap();
        let transcript_path = temp.path().join("session.jsonl");
        write_transcript(
            &transcript_path,
            &[json!({
                "type": "assistant",
                "message": {"role": "assistant", "content": "记一下：assistant tried to inject"}
            })],
        );
        let report = run(StopArgs {
            config_path: cfg.clone(),
            cwd: Some(cwd),
            transcript_path: Some(transcript_path),
            hook_input: None,
            home: None,
        })
        .unwrap();
        assert_eq!(report.distill.signals_detected, 0);
        let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
        assert!(snap.wakeup_ready.is_empty());
    }

    #[test]
    fn run_emits_incident_candidate_for_repeated_frustration() {
        let temp = tempdir().unwrap();
        let cfg = fixture_config(&temp);
        let cwd = temp.path().join("repo");
        fs::create_dir_all(&cwd).unwrap();
        let transcript_path = temp.path().join("session.jsonl");
        write_transcript(
            &transcript_path,
            &[
                json!({"type":"user","message":{"role":"user","content":"试一下 cargo test"}}),
                json!({"type":"user","message":{"role":"user","content":"还是错了，看看日志"}}),
                json!({"type":"user","message":{"role":"user","content":"又失败了"}}),
            ],
        );
        let report = run(StopArgs {
            config_path: cfg.clone(),
            cwd: Some(cwd),
            transcript_path: Some(transcript_path),
            hook_input: None,
            home: None,
        })
        .unwrap();
        assert_eq!(report.distill.candidates_persisted.len(), 1);
        let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
        assert_eq!(snap.pending_review.len(), 1);
        assert_eq!(snap.pending_review[0].record.memory_type, "incident");
    }

    #[test]
    fn run_drops_signals_when_redact_finds_secret() {
        let temp = tempdir().unwrap();
        let cfg = fixture_config(&temp);
        let cwd = temp.path().join("repo");
        fs::create_dir_all(&cwd).unwrap();
        let transcript_path = temp.path().join("session.jsonl");
        write_transcript(
            &transcript_path,
            &[json!({
                "type":"user",
                "message":{"role":"user","content":"记一下: token sk-abcDEFghi1234567890ABCDEFGHIJ for prod"}
            })],
        );
        let report = run(StopArgs {
            config_path: cfg.clone(),
            cwd: Some(cwd),
            transcript_path: Some(transcript_path),
            hook_input: None,
            home: None,
        })
        .unwrap();
        assert_eq!(report.distill.signals_detected, 1);
        assert_eq!(report.distill.signals_redacted_dropped, 1);
        assert!(report.distill.signals_persisted.is_empty());
    }

    #[test]
    fn run_dedupes_against_existing_record() {
        let temp = tempdir().unwrap();
        let cfg = fixture_config(&temp);
        let cwd = temp.path().join("repo");
        fs::create_dir_all(&cwd).unwrap();
        LifecycleService::new()
            .record_manual(
                &cfg,
                RecordMemoryRequest {
                    title: "[seed] x".into(),
                    summary: "cargo install 是默认安装路径".into(),
                    memory_type: "preference".into(),
                    scope: MemoryScope::Project,
                    source_ref: "manual:seed".into(),
                    project_id: None,
                    user_id: None,
                    sensitivity: None,
                    metadata: TransitionMetadata::default(),
                    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,
                },
            )
            .unwrap();
        let transcript_path = temp.path().join("session.jsonl");
        write_transcript(
            &transcript_path,
            &[json!({
                "type": "user",
                "message": {"role": "user", "content": "记一下：cargo install 是默认安装路径"}
            })],
        );
        let report = run(StopArgs {
            config_path: cfg.clone(),
            cwd: Some(cwd),
            transcript_path: Some(transcript_path),
            hook_input: None,
            home: None,
        })
        .unwrap();
        assert_eq!(report.distill.signals_duplicate_dropped, 1);
        assert!(report.distill.signals_persisted.is_empty());
        let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
        assert_eq!(snap.wakeup_ready.len(), 1);
    }

    #[test]
    fn run_drains_queue_even_without_transcript() {
        let temp = tempdir().unwrap();
        let cfg = fixture_config(&temp);
        let cwd = temp.path().join("repo");
        fs::create_dir_all(&cwd).unwrap();
        let runtime_dir = cwd.join(".spool");
        fs::create_dir_all(&runtime_dir).unwrap();
        crate::distill_queue::append(
            &runtime_dir,
            &crate::distill_queue::DistillSignal {
                recorded_at: 1,
                tool_name: Some("Bash".into()),
                cwd: cwd.display().to_string(),
                payload: Some("ls".into()),
            },
            crate::distill_queue::DEFAULT_LRU_CAP,
        )
        .unwrap();

        let report = run(StopArgs {
            config_path: cfg,
            cwd: Some(cwd.clone()),
            transcript_path: None,
            hook_input: None,
            home: Some(temp.path().join("fake-home")),
        })
        .unwrap();
        assert_eq!(report.distill.queue_drained, 1);
        assert!(
            crate::distill_queue::peek_all(&runtime_dir)
                .unwrap()
                .is_empty()
        );
    }

    #[test]
    fn run_resolves_transcript_from_hook_input_json() {
        let temp = tempdir().unwrap();
        let cfg = fixture_config(&temp);
        let cwd = temp.path().join("repo");
        fs::create_dir_all(&cwd).unwrap();
        let transcript_path = temp.path().join("session.jsonl");
        write_transcript(
            &transcript_path,
            &[json!({
                "type": "user",
                "message": {"role": "user", "content": "记一下: hook-input wired"}
            })],
        );
        let hook_input = json!({
            "session_id": "abc",
            "transcript_path": transcript_path.to_string_lossy()
        })
        .to_string();
        let report = run(StopArgs {
            config_path: cfg.clone(),
            cwd: Some(cwd),
            transcript_path: None,
            hook_input: Some(hook_input),
            home: None,
        })
        .unwrap();
        assert_eq!(report.distill.signals_persisted.len(), 1);
    }

    #[test]
    fn parse_transcript_path_from_hook_input_handles_blanks() {
        assert!(parse_transcript_path_from_hook_input(None).is_none());
        assert!(parse_transcript_path_from_hook_input(Some("")).is_none());
        assert!(parse_transcript_path_from_hook_input(Some("   ")).is_none());
        assert!(parse_transcript_path_from_hook_input(Some("not json")).is_none());
    }

    #[test]
    fn parse_transcript_path_from_hook_input_extracts_field() {
        let input = json!({"transcript_path": "/abs/x.jsonl", "other": 1}).to_string();
        let path = parse_transcript_path_from_hook_input(Some(&input)).unwrap();
        assert_eq!(path, PathBuf::from("/abs/x.jsonl"));
    }

    #[test]
    fn report_helpers_expose_inner_distill_fields() {
        let mut report = StopReport::default();
        assert!(report.transcript_path().is_none());
        assert!(report.signals_persisted().is_empty());
        assert!(report.candidates_persisted().is_empty());
        report.distill.signals_persisted.push("rid".into());
        assert_eq!(report.signals_persisted(), &["rid".to_string()]);
    }
}
