//! Injection / exfiltration guard for memory entries.
//!
//! Constructor Pattern: scan logic only; pattern definitions live in
//! `injection_patterns.rs`.
//!
//! ## Wire-points (3 paths protected, P2.1.b lock 2026-04-28)
//!
//! 1. `ingest::insert_event` — REAL memory writes from agent JSONL
//!    transcripts. Each event message is scanned before it is persisted
//!    into the `events` table. Block-tier hits short-circuit insertion.
//! 2. `kei-pet::memory::record_interaction` — user-facing pet
//!    conversation memory. Uses a substring/char-only sibling guard
//!    (`kei_pet::injection_check`) to avoid a regex dep bump on the pet
//!    crate. Block-tier coverage mirrors this module's prompt-override
//!    + invisible-unicode + PEM-marker rules.
//! 3. `cmd_backlog --add` — RULE 0.14 audit-CRUD. Backlog items are
//!    rendered into self-audit reports verbatim; malicious content
//!    survives that path the same way it would survive insert_event.
//!
//! All three paths use the same `Severity::Block` semantics: a hit
//! results in early-return / persistence-skip, with the finding logged.
//!
//! ## Rationale
//!
//! Memory entries are injected verbatim into the system prompt. Any
//! prompt-override fragment, role-prefix, ChatML tag, invisible bidi
//! codepoint, hardcoded credential, or large base64 attestation blob
//! survives that injection and becomes effective text the model reads.
//! The scan treats these as untrusted input and rejects them.
//!
//! ## Bypass
//!
//! `KEI_MEMORY_SKIP_GUARD=1` skips the scan after logging an explicit
//! warning to stderr. Intended for one-off recovery — never the default.

use crate::injection_patterns::{regex_patterns, substring_patterns, Severity, INVISIBLE_CHARS};

/// One pattern hit. Severity drives whether the call site rejects.
#[derive(Debug, Clone)]
pub struct InjectionFinding {
    pub pattern: String,
    pub line: usize,
    pub severity: Severity,
    pub source: String,
    pub snippet: String,
}

impl std::fmt::Display for InjectionFinding {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "[{:?}] {} ({}) at line {}: {}",
            self.severity, self.pattern, self.source, self.line, self.snippet
        )
    }
}

impl std::error::Error for InjectionFinding {}

/// Scan `content` for prompt-injection / secret-leak patterns.
///
/// Returns `Ok(())` when the content is clean OR contains only
/// `Warn`-level findings. Returns `Err(InjectionFinding)` on the first
/// `Block`-level hit. Use `scan_all` if all findings are wanted.
pub fn scan(content: &str) -> Result<(), InjectionFinding> {
    if std::env::var("KEI_MEMORY_SKIP_GUARD").as_deref() == Ok("1") {
        eprintln!(
            "kei-memory: WARNING — injection guard bypassed via \
             KEI_MEMORY_SKIP_GUARD=1 (RULE 0.4 audit-trail)"
        );
        return Ok(());
    }
    if let Some(f) = check_invisible(content) {
        return Err(f);
    }
    for f in scan_regex(content) {
        if f.severity == Severity::Block {
            return Err(f);
        }
    }
    for f in scan_substring(content) {
        if f.severity == Severity::Block {
            return Err(f);
        }
    }
    Ok(())
}

/// Like `scan` but returns every finding (Block + Warn). Useful for
/// diagnostics / triage tools that want the full picture.
#[allow(dead_code)]
pub fn scan_all(content: &str) -> Vec<InjectionFinding> {
    let mut out = Vec::new();
    if let Some(f) = check_invisible(content) {
        out.push(f);
    }
    out.extend(scan_regex(content));
    out.extend(scan_substring(content));
    out
}

fn check_invisible(content: &str) -> Option<InjectionFinding> {
    for (idx, line) in content.lines().enumerate() {
        for ch in line.chars() {
            if INVISIBLE_CHARS.contains(&ch) {
                return Some(InjectionFinding {
                    pattern: "invisible_unicode".to_string(),
                    line: idx + 1,
                    severity: Severity::Block,
                    source: "unicode:bidi".to_string(),
                    snippet: format!("U+{:04X}", ch as u32),
                });
            }
        }
    }
    None
}

fn scan_regex(content: &str) -> Vec<InjectionFinding> {
    let mut out = Vec::new();
    for p in regex_patterns() {
        if let Some(m) = p.re.find(content) {
            out.push(InjectionFinding {
                pattern: p.id.to_string(),
                line: line_of_offset(content, m.start()),
                severity: p.severity,
                source: p.source.to_string(),
                snippet: truncate(m.as_str(), 60),
            });
        }
    }
    out
}

fn scan_substring(content: &str) -> Vec<InjectionFinding> {
    let lower = content.to_lowercase();
    let mut out = Vec::new();
    for p in substring_patterns() {
        if p.needles.iter().all(|n| lower.contains(n)) {
            let needle = p.needles[0];
            let line = lower
                .find(needle)
                .map(|off| line_of_offset(&lower, off))
                .unwrap_or(1);
            out.push(InjectionFinding {
                pattern: p.id.to_string(),
                line,
                severity: p.severity,
                source: p.source.to_string(),
                snippet: needle.to_string(),
            });
        }
    }
    out
}

fn line_of_offset(content: &str, byte_off: usize) -> usize {
    content[..byte_off.min(content.len())].matches('\n').count() + 1
}

fn truncate(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        return s.to_string();
    }
    let mut out: String = s.chars().take(max).collect();
    out.push('…');
    out
}

// Tests moved to tests/injection_guard_unit.rs (Constructor Pattern: src
// stays under 200 LOC; integration tests reach via kei_memory::injection_guard).
