//! Self-tag heuristic — detects explicit "remember this" markers in
//! transcript text and emits [`SelfTagSignal`]s for the Stop hook to
//! persist as `accepted` memories.
//!
//! ## Detection model
//! We scan a transcript entry's text for any of a fixed set of
//! markers (Chinese + English; see [`MARKERS`]). When a marker is
//! found:
//!
//! 1. We capture the **content tail** — characters from the position
//!    just after the marker, up to the next "paragraph terminator"
//!    (a Chinese full-stop `。`, an English period `.`, an exclamation
//!    `!` or `！`, a question mark `?` or `？`, or a newline). The
//!    terminator itself is NOT included.
//! 2. We trim leading/trailing whitespace + colons (`:` or `：`) so
//!    "记一下：cargo install 是默认" becomes "cargo install 是默认".
//! 3. We cap content at [`MAX_CONTENT_CHARS`] characters (in code
//!    points, not bytes) so a runaway paragraph doesn't blow out the
//!    ledger entry.
//! 4. Empty / whitespace-only content is rejected — we don't emit a
//!    signal for "记一下" with no follow-up.
//!
//! ## Markers
//! Listed in [`MARKERS`]. Each marker has:
//! - `phrase`: the literal needle (case-insensitive for English).
//! - `kind`: a stable label written into the resulting signal so
//!   downstream consumers can rank / filter (e.g. "decision" vs.
//!   "preference" vs. "general remember").
//!
//! ## What we deliberately do NOT do
//! - We don't try to infer `memory_type` beyond a coarse `kind`.
//!   Stop hook maps `kind` → `memory_type` ("preference" / "decision"
//!   / "workflow") with a small lookup; sophisticated classification
//!   is Tier 2 (sampling) territory.
//! - We don't deduplicate against existing ledger entries here. The
//!   Stop hook may receive the same self-tag twice if the user
//!   repeats themselves — that's fine, the ledger is append-only and
//!   the projection collapses to the latest state.

use serde::{Deserialize, Serialize};

/// Maximum content length in *characters* (not bytes). Above this we
/// truncate at the nearest char boundary and append `…`.
pub const MAX_CONTENT_CHARS: usize = 1000;

/// One detected self-tag signal.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SelfTagSignal {
    /// The literal marker matched (e.g. "记一下", "from now on").
    pub trigger: String,
    /// Coarse classification used to map onto `memory_type` later.
    pub kind: SelfTagKind,
    /// The tail content that follows the marker, trimmed and capped.
    pub content: String,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SelfTagKind {
    /// "记一下", "remember:", "remember this" — generic capture.
    Remember,
    /// "决定", "decided", "decision:" — a decision the user wants
    /// preserved.
    Decision,
    /// "以后都", "from now on", "going forward" — preference / habit.
    Preference,
    /// "我偏好", "i prefer" — explicit preference statement.
    Prefer,
}

impl SelfTagKind {
    /// Maps to the lifecycle `memory_type` string — kept here so
    /// callers don't reimplement the mapping.
    pub fn memory_type(self) -> &'static str {
        match self {
            SelfTagKind::Remember => "preference",
            SelfTagKind::Decision => "decision",
            SelfTagKind::Preference => "preference",
            SelfTagKind::Prefer => "preference",
        }
    }
}

struct Marker {
    phrase: &'static str,
    kind: SelfTagKind,
    case_insensitive: bool,
}

/// Ordered list of recognized markers. Earlier entries win when
/// multiple markers overlap (e.g. "决定" inside "我决定" — we want
/// the longer / more specific phrase first to keep the trigger
/// readable).
static MARKERS: &[Marker] = &[
    // ── Chinese — longest-first to capture compound phrases ────
    Marker {
        phrase: "以后都这样",
        kind: SelfTagKind::Preference,
        case_insensitive: false,
    },
    Marker {
        phrase: "以后都用",
        kind: SelfTagKind::Preference,
        case_insensitive: false,
    },
    Marker {
        phrase: "以后都",
        kind: SelfTagKind::Preference,
        case_insensitive: false,
    },
    Marker {
        phrase: "我偏好",
        kind: SelfTagKind::Prefer,
        case_insensitive: false,
    },
    Marker {
        phrase: "我决定",
        kind: SelfTagKind::Decision,
        case_insensitive: false,
    },
    Marker {
        phrase: "决定用",
        kind: SelfTagKind::Decision,
        case_insensitive: false,
    },
    Marker {
        phrase: "决定",
        kind: SelfTagKind::Decision,
        case_insensitive: false,
    },
    Marker {
        phrase: "记下来",
        kind: SelfTagKind::Remember,
        case_insensitive: false,
    },
    Marker {
        phrase: "记一下",
        kind: SelfTagKind::Remember,
        case_insensitive: false,
    },
    // ── English — case-insensitive ─────────────────────────────
    Marker {
        phrase: "remember this",
        kind: SelfTagKind::Remember,
        case_insensitive: true,
    },
    Marker {
        phrase: "remember:",
        kind: SelfTagKind::Remember,
        case_insensitive: true,
    },
    Marker {
        phrase: "from now on",
        kind: SelfTagKind::Preference,
        case_insensitive: true,
    },
    Marker {
        phrase: "going forward",
        kind: SelfTagKind::Preference,
        case_insensitive: true,
    },
    Marker {
        phrase: "we decided",
        kind: SelfTagKind::Decision,
        case_insensitive: true,
    },
    Marker {
        phrase: "i decided",
        kind: SelfTagKind::Decision,
        case_insensitive: true,
    },
    Marker {
        phrase: "decision:",
        kind: SelfTagKind::Decision,
        case_insensitive: true,
    },
    Marker {
        phrase: "i prefer",
        kind: SelfTagKind::Prefer,
        case_insensitive: true,
    },
];

/// Scan `text` for self-tag markers and emit one [`SelfTagSignal`]
/// per non-overlapping hit, in document order.
///
/// Multiple distinct markers in the same text produce multiple
/// signals. A repeated marker is NOT collapsed — repetition is rare
/// and the ledger's projection layer naturally consolidates by
/// `record_id`.
pub fn detect(text: &str) -> Vec<SelfTagSignal> {
    if text.is_empty() {
        return Vec::new();
    }
    let mut signals = Vec::new();
    let mut cursor = 0usize;
    while cursor < text.len() {
        // Find the earliest marker hit at or after `cursor`. We
        // iterate every marker each step because Markers are
        // longest-first and we want the leftmost match overall — not
        // the first marker that happens to match later.
        let mut best: Option<(usize, &Marker)> = None;
        for marker in MARKERS {
            let haystack = &text[cursor..];
            let pos = if marker.case_insensitive {
                find_case_insensitive(haystack, marker.phrase)
            } else {
                haystack.find(marker.phrase)
            };
            let Some(rel) = pos else {
                continue;
            };
            let abs = cursor + rel;
            match best {
                None => best = Some((abs, marker)),
                Some((existing_abs, _)) if abs < existing_abs => best = Some((abs, marker)),
                Some((existing_abs, existing_marker))
                    if abs == existing_abs
                        && marker.phrase.len() > existing_marker.phrase.len() =>
                {
                    best = Some((abs, marker))
                }
                _ => {}
            }
        }
        let Some((hit_abs, marker)) = best else {
            break;
        };
        // Skip markers that appear inside quoted/code contexts
        if is_in_quoted_context(text, hit_abs) {
            cursor = hit_abs + marker.phrase.len();
            continue;
        }
        let after = hit_abs + marker.phrase.len();
        let tail = extract_tail(&text[after..]);
        if !tail.is_empty() {
            signals.push(SelfTagSignal {
                trigger: marker.phrase.to_string(),
                kind: marker.kind,
                content: tail.clone(),
            });
        }
        // Advance cursor to just past the marker. We don't try to
        // skip the tail content; another marker might appear inside
        // the same paragraph and that's fine — emit it as well.
        cursor = after;
    }
    signals
}

/// Like [`detect`] but also checks user-defined extraction rules
/// from `~/.spool/rules.toml`. Custom triggers are appended after
/// built-in markers so they don't override the default behavior.
pub fn detect_with_user_rules(
    text: &str,
    user_rules: &[crate::rules::ExtractionRule],
) -> Vec<SelfTagSignal> {
    let mut signals = detect(text);
    if user_rules.is_empty() || text.is_empty() {
        return signals;
    }
    let text_lower = text.to_lowercase();
    for rule in user_rules {
        let trigger_lower = rule.trigger.to_lowercase();
        if trigger_lower.is_empty() {
            continue;
        }
        if !text_lower.contains(&trigger_lower) {
            continue;
        }
        if let Some(pos) = find_case_insensitive(text, &rule.trigger) {
            let after = pos + rule.trigger.len();
            let tail = extract_tail(&text[after..]);
            if !tail.is_empty() {
                let kind = match rule.memory_type.as_str() {
                    "decision" => SelfTagKind::Decision,
                    "constraint" => SelfTagKind::Decision,
                    "workflow" => SelfTagKind::Preference,
                    _ => SelfTagKind::Remember,
                };
                signals.push(SelfTagSignal {
                    trigger: rule.trigger.clone(),
                    kind,
                    content: tail,
                });
            }
        }
    }
    signals
}

fn is_in_quoted_context(text: &str, pos: usize) -> bool {
    let before = &text[..pos];
    let quote_count = before.chars().filter(|&c| c == '"').count();
    if quote_count % 2 == 1 {
        return true;
    }
    let backtick_count = before.chars().filter(|&c| c == '`').count();
    if backtick_count % 2 == 1 {
        return true;
    }
    if before.ends_with('"') || before.ends_with('`') || before.ends_with('\'') {
        return true;
    }
    false
}

fn find_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
    if needle.is_empty() {
        return Some(0);
    }
    let needle_lower = needle.to_lowercase();
    // Lowercase the full haystack once — O(n) instead of the previous
    // O(n²) loop that called to_lowercase() on the remaining slice at
    // every character position. Safe for our markers: all
    // case-insensitive markers are ASCII, so lowercasing preserves
    // byte offsets between the lowercased string and the original.
    let haystack_lower = haystack.to_lowercase();
    haystack_lower.find(&needle_lower)
}

fn extract_tail(after: &str) -> String {
    // 1) Strip leading whitespace + Chinese / English colons / dashes
    //    so "记一下：xxx" → "xxx".
    let trimmed_start: &str = trim_leading_separators(after);

    // 2) Walk until we hit a paragraph terminator. The English period
    //    `.` is ambiguous because it appears inside paths (`~/.cargo/bin`)
    //    and abbreviations (`e.g.`, `i.e.`). We only treat it as a
    //    terminator when followed by whitespace / EOF — that captures
    //    real sentence-ending periods without slicing a path mid-segment.
    let chars: Vec<(usize, char)> = trimmed_start.char_indices().collect();
    let mut end = trimmed_start.len();
    for (i, &(idx, ch)) in chars.iter().enumerate() {
        if !is_paragraph_terminator(ch) {
            continue;
        }
        if ch == '.' {
            let next = chars.get(i + 1).map(|&(_, c)| c);
            match next {
                None => {
                    end = idx;
                    break;
                }
                Some(next_ch) if next_ch.is_whitespace() => {
                    end = idx;
                    break;
                }
                // Non-whitespace next char (path / abbreviation) —
                // keep walking.
                Some(_) => continue,
            }
        }
        end = idx;
        break;
    }
    let body: &str = trimmed_start[..end].trim();

    // 3) Cap by character count (not byte count).
    cap_chars(body, MAX_CONTENT_CHARS)
}

fn trim_leading_separators(s: &str) -> &str {
    let mut start = 0;
    for (idx, ch) in s.char_indices() {
        if ch.is_whitespace() || ch == ':' || ch == '：' || ch == '-' || ch == '—' {
            start = idx + ch.len_utf8();
        } else {
            break;
        }
    }
    &s[start..]
}

fn is_paragraph_terminator(ch: char) -> bool {
    matches!(
        ch,
        '\n' | '\r' | '。' | '！' | '？' | '；' | '!' | '?' | ';' | '.'
    )
}

fn cap_chars(s: &str, max_chars: usize) -> String {
    if s.chars().count() <= max_chars {
        return s.to_string();
    }
    let mut out = String::with_capacity(s.len());
    for (i, ch) in s.chars().enumerate() {
        if i >= max_chars {
            break;
        }
        out.push(ch);
    }
    out.push('…');
    out
}

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

    #[test]
    fn detect_returns_empty_for_blank_input() {
        assert!(detect("").is_empty());
        assert!(detect("hello world").is_empty());
        assert!(detect("普通对话内容，没有标记").is_empty());
    }

    #[test]
    fn detect_picks_up_chinese_remember_marker() {
        let signals = detect("记一下：cargo install 是默认安装路径");
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].trigger, "记一下");
        assert_eq!(signals[0].kind, SelfTagKind::Remember);
        assert_eq!(signals[0].content, "cargo install 是默认安装路径");
    }

    #[test]
    fn detect_picks_up_chinese_decision_marker() {
        let signals = detect("我决定用 cargo install 落到 ~/.cargo/bin");
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].trigger, "我决定");
        assert_eq!(signals[0].kind, SelfTagKind::Decision);
        // The English period inside `~/.cargo/bin` is NOT a paragraph
        // terminator (it's followed by a non-whitespace char) — so the
        // path stays intact.
        assert_eq!(signals[0].content, "用 cargo install 落到 ~/.cargo/bin");
    }

    #[test]
    fn detect_picks_up_preference_marker() {
        let signals = detect("以后都用 ~/.cargo/bin/spool-mcp 而不是 target/debug。");
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].trigger, "以后都用");
        assert_eq!(signals[0].kind, SelfTagKind::Preference);
        assert!(signals[0].content.starts_with("~/"));
    }

    #[test]
    fn detect_picks_up_english_remember_marker() {
        let signals = detect("Remember: always run cargo fmt before commit");
        assert_eq!(signals.len(), 1);
        assert_eq!(
            signals[0].trigger.to_lowercase(),
            "remember:".to_lowercase()
        );
        assert_eq!(signals[0].kind, SelfTagKind::Remember);
        assert_eq!(signals[0].content, "always run cargo fmt before commit");
    }

    #[test]
    fn detect_picks_up_from_now_on_marker_case_insensitive() {
        let signals = detect("FROM NOW ON we always test before push");
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].kind, SelfTagKind::Preference);
        assert_eq!(signals[0].content, "we always test before push");
    }

    #[test]
    fn detect_picks_up_decision_english() {
        let signals = detect("We decided to use cargo install");
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].kind, SelfTagKind::Decision);
        assert!(signals[0].content.contains("cargo install"));
    }

    #[test]
    fn detect_picks_up_i_prefer_english() {
        let signals = detect("I prefer concise terminal output");
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].kind, SelfTagKind::Prefer);
        assert_eq!(signals[0].content, "concise terminal output");
    }

    #[test]
    fn detect_returns_multiple_signals_for_multiple_markers() {
        let signals = detect("记一下: A 是 X。\n以后都用 B 不用 C。\n我决定用 D 替换 E");
        assert_eq!(signals.len(), 3);
        assert_eq!(signals[0].kind, SelfTagKind::Remember);
        assert_eq!(signals[1].kind, SelfTagKind::Preference);
        assert_eq!(signals[2].kind, SelfTagKind::Decision);
    }

    #[test]
    fn detect_skips_marker_when_tail_is_empty() {
        // Marker followed by nothing or only terminators.
        let signals = detect("记一下");
        assert!(signals.is_empty());
        let signals = detect("记一下。");
        assert!(signals.is_empty());
        let signals = detect("Remember:");
        assert!(signals.is_empty());
    }

    #[test]
    fn detect_terminates_tail_on_newline() {
        let signals = detect("记一下: line1\nthis line should NOT be in content");
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].content, "line1");
    }

    #[test]
    fn detect_terminates_tail_on_chinese_full_stop() {
        let signals = detect("我偏好简洁回复。其他规则后说。");
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].content, "简洁回复");
    }

    #[test]
    fn detect_strips_chinese_colon_after_marker() {
        let signals = detect("记一下：xx 是 yy");
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].content, "xx 是 yy");
    }

    #[test]
    fn detect_strips_dash_after_marker() {
        let signals = detect("Remember: - test before push");
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].content, "test before push");
    }

    #[test]
    fn detect_caps_extremely_long_tail() {
        // 1500 chars of repeated content → capped at MAX_CONTENT_CHARS.
        let big_tail: String = "x".repeat(1500);
        let raw = format!("记一下: {}", big_tail);
        let signals = detect(&raw);
        assert_eq!(signals.len(), 1);
        let chars = signals[0].content.chars().count();
        assert!(chars <= MAX_CONTENT_CHARS + 1, "got {chars} chars");
        assert!(signals[0].content.ends_with('…'));
    }

    #[test]
    fn detect_prefers_longest_marker_at_same_position() {
        // "我决定" should win over "决定" when both anchor at
        // position 0.
        let signals = detect("我决定用 cargo install");
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].trigger, "我决定");
    }

    #[test]
    fn self_tag_kind_memory_type_mapping() {
        assert_eq!(SelfTagKind::Remember.memory_type(), "preference");
        assert_eq!(SelfTagKind::Decision.memory_type(), "decision");
        assert_eq!(SelfTagKind::Preference.memory_type(), "preference");
        assert_eq!(SelfTagKind::Prefer.memory_type(), "preference");
    }

    #[test]
    fn detect_unicode_safe_with_mixed_script() {
        // Make sure we don't slice mid-codepoint. The "decision" mark
        // and a multi-byte CJK char in the tail must coexist.
        let signals = detect("决定: 改用 ~/.cargo/bin 替换 target/debug");
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].kind, SelfTagKind::Decision);
        assert!(signals[0].content.contains("替换"));
    }
}
