//! Pattern database and Aho-Corasick multi-pattern matcher.
//!
//! Traditional regex-alternation approaches scan at ~1-6 MB/s because they
//! build `(?:kw1|kw2|...|kwN)` and run it line-by-line. Kindi replaces that
//! with an Aho-Corasick automaton, which gives us:
//!   - O(n) scanning regardless of pattern count (not O(n*m))
//!   - SIMD-accelerated memchr for the inner loop
//!   - Zero-copy match extraction via byte offsets
//!   - Single pass over the entire file (no line splitting)
//!
//! `PatternDb` owns a flat arena of match specs + the compiled automaton,
//! wrapped in `Arc` for cheap cross-thread sharing.

use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;

use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
use serde::{Deserialize, Serialize};

use crate::error::{Error, Result};

// ---------------------------------------------------------------------------
// MatchSpec: metadata for each pattern
// ---------------------------------------------------------------------------

/// Metadata associated with a matched pattern.
/// Stored in a flat Vec inside PatternDb, indexed by automaton pattern ID.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchSpec {
    pub evidence_type: Box<str>,
    pub language: Box<str>,
    #[serde(default)]
    pub encryption_library: Option<Box<str>>,
    #[serde(default)]
    pub encryption_api_usage: Option<Box<str>>,
    #[serde(default)]
    pub comments: Option<Box<str>>,
}

// ---------------------------------------------------------------------------
// PatternDb: compiled multi-pattern matcher + spec table
// ---------------------------------------------------------------------------

/// A compiled, immutable pattern database.
///
/// Once built, this is cheaply cloneable (via Arc) and safe to share
/// across threads for parallel file scanning.
#[derive(Clone)]
pub struct PatternDb {
    inner: Arc<PatternDbInner>,
}

struct PatternDbInner {
    /// The compiled Aho-Corasick automaton.
    automaton: AhoCorasick,
    /// Spec for pattern i lives at specs[i].
    specs: Vec<MatchSpec>,
    /// The raw pattern strings (for reporting matched_text when boundaries differ).
    patterns: Vec<Box<str>>,
    /// Version of the keyword list that was loaded.
    version: Box<str>,
    /// Which language keys each pattern applies to.
    /// Empty = "all" languages.
    language_filter: Vec<Option<Box<str>>>,
    /// Per-pattern word-boundary requirements: (need_boundary_start, need_boundary_end).
    /// Parsed from \b markers in the keyword file.
    boundaries: Vec<(bool, bool)>,
}

/// A single match found in content.
#[derive(Debug, Serialize)]
pub struct Match {
    pub matched_text: String,
    pub evidence_type: String,
    pub line_number: usize,
    pub file_index_begin: usize,
    pub file_index_end: usize,
    pub line_index_begin: usize,
    pub line_index_end: usize,
    pub line_text: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub encryption_library: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub encryption_api_usage: Option<String>,
    pub context_before: [String; 3],
    pub context_after: [String; 3],
}

impl PatternDb {
    /// Parse a keyword_list.txt or api_definitions.txt pattern file
    /// and compile it into an Aho-Corasick automaton.
    pub fn from_file(path: &Path, ignore_case: bool) -> Result<Self> {
        let content = std::fs::read_to_string(path).map_err(|e| Error::Io {
            path: path.to_owned(),
            source: e,
        })?;
        Self::parse(&content, ignore_case)
    }

    /// Parse from string content with optional whole-word enforcement.
    /// When `whole_words` is true (API mode), all patterns get word boundaries.
    pub fn parse(content: &str, ignore_case: bool) -> Result<Self> {
        Self::parse_with_options(content, ignore_case, false, &[])
    }

    /// Parse with full options: whole_words mode (for API finder) and evidence type filtering.
    pub fn parse_with_options(
        content: &str,
        ignore_case: bool,
        whole_words: bool,
        ignore_evidence_types: &[String],
    ) -> Result<Self> {
        let mut specs = Vec::new();
        let mut patterns = Vec::new();
        let mut language_filter = Vec::new();
        let mut boundaries = Vec::new();
        let mut version = String::new();
        let mut current_spec: Option<MatchSpec> = None;
        let mut in_version = false;

        for (line_no, raw_line) in content.lines().enumerate() {
            let line = raw_line.trim();

            if line.is_empty() || line.starts_with('#') {
                continue;
            }

            // Version section
            if line == "[keyword_list_version]" {
                in_version = true;
                continue;
            }
            if in_version {
                version = line.to_string();
                in_version = false;
                continue;
            }

            // Section header: [{"evidence_type": "...", ...}]
            if line.starts_with('[') && line.ends_with(']') {
                let json_str = &line[1..line.len() - 1];
                let parsed: HashMap<String, String> =
                    serde_json::from_str(json_str).map_err(|_| Error::PatternParse {
                        line: line_no + 1,
                        message: format!("invalid JSON in section header: {json_str}"),
                    })?;

                let evidence_type = parsed
                    .get("evidence_type")
                    .cloned()
                    .unwrap_or_default();
                let language = parsed
                    .get("language")
                    .cloned()
                    .unwrap_or_else(|| "all".to_string());

                current_spec = Some(MatchSpec {
                    evidence_type: evidence_type.into(),
                    language: language.into(),
                    encryption_library: parsed.get("encryption_library").map(|s| s.clone().into()),
                    encryption_api_usage: parsed
                        .get("encryption_api_usage")
                        .map(|s| s.clone().into()),
                    comments: parsed.get("comments").map(|s| s.clone().into()),
                });
                continue;
            }

            // Keyword line (indented, quoted)
            if let Some(ref spec) = current_spec {
                // Skip evidence types the user wants to ignore
                if ignore_evidence_types
                    .iter()
                    .any(|t| t.as_str() == &*spec.evidence_type)
                {
                    continue;
                }

                let raw = line.trim_matches('"');

                // Detect \b word-boundary markers before stripping
                let has_start_boundary = raw.starts_with("\\b") || whole_words;
                let has_end_boundary = raw.ends_with("\\b") || whole_words;

                // Strip \b markers for Aho-Corasick (substring match)
                let keyword = raw.replace("\\b", "");

                if keyword.is_empty() {
                    continue;
                }

                let lang = if &*spec.language == "all" || &*spec.language == "source" {
                    None
                } else {
                    Some(spec.language.clone())
                };

                specs.push(spec.clone());
                patterns.push(keyword.into());
                language_filter.push(lang);
                boundaries.push((has_start_boundary, has_end_boundary));
            }
        }

        // Build the Aho-Corasick automaton
        let automaton = AhoCorasickBuilder::new()
            .ascii_case_insensitive(ignore_case)
            .match_kind(MatchKind::LeftmostLongest)
            .build(patterns.iter().map(|p: &Box<str>| -> &str { p }))
            .map_err(|e| Error::Other(format!("automaton build failed: {e}")))?;

        Ok(Self {
            inner: Arc::new(PatternDbInner {
                automaton,
                specs,
                patterns,
                version: version.into(),
                language_filter,
                boundaries,
            }),
        })
    }

    /// Parse an API definitions file (forces whole-word matching on all patterns).
    pub fn from_api_file(path: &Path, ignore_case: bool, ignore_evidence_types: &[String]) -> Result<Self> {
        let content = std::fs::read_to_string(path).map_err(|e| Error::Io {
            path: path.to_owned(),
            source: e,
        })?;
        Self::parse_with_options(&content, ignore_case, true, ignore_evidence_types)
    }

    /// Return the keyword list version.
    pub fn version(&self) -> &str {
        &self.inner.version
    }

    /// Return the number of patterns.
    pub fn pattern_count(&self) -> usize {
        self.inner.patterns.len()
    }

    /// Quick check: does the content contain any match at all?
    pub fn has_match(&self, content: &[u8], language_key: &str) -> bool {
        for mat in self.inner.automaton.find_iter(content) {
            let pid = mat.pattern().as_usize();
            let Some(lang) = self.inner.language_filter.get(pid) else {
                continue;
            };
            if lang.is_none() || lang.as_deref() == Some(language_key) {
                return true;
            }
        }
        false
    }

    /// Full search: find all matches with line context.
    ///
    /// This is the hot path. Single pass over content via Aho-Corasick,
    /// then we compute line numbers and context by binary-searching
    /// a precomputed line-offset index.
    pub fn search(&self, content: &str, language_key: &str) -> Vec<Match> {
        let bytes = content.as_bytes();
        let inner = &*self.inner;

        // Precompute line start offsets for O(log n) line-number lookup
        let line_starts = LineIndex::new(bytes);

        let mut results = Vec::new();

        for mat in inner.automaton.find_iter(bytes) {
            let pid = mat.pattern().as_usize();

            // Bounds-checked lookups -- the automaton built these vecs so indices
            // should always be valid, but defense in depth costs nothing here
            // (this is not the inner loop -- the automaton is).
            let Some(lang) = inner.language_filter.get(pid) else { continue };
            let Some(&(need_start, need_end)) = inner.boundaries.get(pid) else { continue };
            let Some(spec) = inner.specs.get(pid) else { continue };

            // Language filter
            if lang.is_some() && lang.as_deref() != Some(language_key) {
                continue;
            }

            // Word-boundary enforcement (post-match filter)
            let start = mat.start();
            let end = mat.end();

            if need_start && start > 0 && is_word_char(bytes[start - 1]) {
                continue;
            }
            if need_end && end < bytes.len() && is_word_char(bytes[end]) {
                continue;
            }

            // Safe string slicing -- Aho-Corasick guarantees start..end are within
            // the byte array, and since content is &str (valid UTF-8) these are
            // valid char boundaries. Use get() as a safety net.
            let Some(matched_text) = content.get(start..end) else { continue };

            let line_no = line_starts.line_of(start);
            let line_range = line_starts.line_range(line_no);
            let clamped_end = line_range.end.min(content.len());
            let line_text = content.get(line_range.start..clamped_end)
                .unwrap_or("")
                .trim_end_matches('\n');
            let line_start_offset = line_range.start;

            results.push(Match {
                matched_text: matched_text.to_string(),
                evidence_type: spec.evidence_type.to_string(),
                line_number: line_no + 1, // 1-indexed
                file_index_begin: start,
                file_index_end: end,
                line_index_begin: start - line_start_offset,
                line_index_end: end - line_start_offset,
                line_text: line_text.to_string(),
                encryption_library: spec.encryption_library.as_deref().map(str::to_string),
                encryption_api_usage: spec.encryption_api_usage.as_deref().map(str::to_string),
                context_before: [
                    if line_no >= 3 { line_starts.line_text(content, line_no - 3) } else { String::new() },
                    if line_no >= 2 { line_starts.line_text(content, line_no - 2) } else { String::new() },
                    if line_no >= 1 { line_starts.line_text(content, line_no - 1) } else { String::new() },
                ],
                context_after: [
                    line_starts.line_text(content, line_no + 1),
                    line_starts.line_text(content, line_no + 2),
                    line_starts.line_text(content, line_no + 3),
                ],
            });
        }

        results
    }
}

// ---------------------------------------------------------------------------
// Word boundary helper
// ---------------------------------------------------------------------------

/// Check if a byte is a "word character" for \b boundary matching.
/// Matches Python's \b behavior: [a-zA-Z0-9_].
#[inline]
fn is_word_char(b: u8) -> bool {
    b.is_ascii_alphanumeric() || b == b'_'
}

// ---------------------------------------------------------------------------
// LineIndex: precomputed line-start offsets for O(log n) lookup
// ---------------------------------------------------------------------------

/// Precomputed byte offsets of each line start, enabling O(log n) line number
/// lookup from any byte offset. This replaces the Python approach of splitting
/// content into lines and iterating.
struct LineIndex {
    /// offsets[i] = byte offset where line i begins. offsets[0] = 0 always.
    offsets: Vec<usize>,
    /// Total length of the content in bytes.
    len: usize,
}

impl LineIndex {
    fn new(bytes: &[u8]) -> Self {
        let mut offsets = vec![0usize];
        for (i, &b) in bytes.iter().enumerate() {
            if b == b'\n' {
                offsets.push(i + 1);
            }
        }
        Self {
            offsets,
            len: bytes.len(),
        }
    }

    /// Which line (0-indexed) contains the given byte offset?
    fn line_of(&self, offset: usize) -> usize {
        self.offsets.partition_point(|&o| o <= offset).saturating_sub(1)
    }

    /// Byte range of line `n` (0-indexed).
    fn line_range(&self, n: usize) -> std::ops::Range<usize> {
        if n >= self.offsets.len() {
            return self.len..self.len;
        }
        let start = self.offsets[n];
        let end = if n + 1 < self.offsets.len() {
            self.offsets[n + 1]
        } else {
            self.len
        };
        start..end
    }

    /// Get trimmed text of line `n`, or empty string if out of bounds.
    fn line_text(&self, content: &str, n: usize) -> String {
        if n >= self.offsets.len() {
            return String::new();
        }
        let range = self.line_range(n);
        let end = range.end.min(content.len());
        content.get(range.start..end)
            .unwrap_or("")
            .trim_end_matches('\n')
            .to_string()
    }
}

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

    const SAMPLE_DB: &str = r#"
# Test keyword list

[keyword_list_version]
    6

[{"evidence_type": "algorithm/asymmetric/RSA", "language": "all"}]
    "\bRSA_"
    "RSAPrivateKey"
    "RSAPublicKey"

[{"evidence_type": "algorithm/hash/SHA256", "language": "all"}]
    "SHA256"
    "SHA-256"

[{"evidence_type": "algorithm/symmetric/AES", "language": "c"}]
    "AES_encrypt"
    "AES_decrypt"
"#;

    /// Mirror of the Python test_keyword_list.conf
    const BOUNDARY_DB: &str = r#"
[keyword_list_version]
    1

[{"evidence_type": "keyword_any", "language": "all"}]
    "lorem"

[{"evidence_type": "keyword_boundary_begin", "language": "all"}]
    "\bipsum"

[{"evidence_type": "keyword_boundary_end", "language": "all"}]
    "dolor\b"

[{"evidence_type": "keyword_boundary_all", "language": "all"}]
    "\bsit\b"
"#;

    /// Content from testpkg3/test
    const TESTPKG3_CONTENT: &str = "testtestloremtesttest\ntest\ntest\ntesttest IPSUM test\ntest\ntest\ntest dolor\ntest";

    // -----------------------------------------------------------------------
    // Original pattern tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_parse_and_count() {
        let db = PatternDb::parse(SAMPLE_DB, false).unwrap();
        assert_eq!(db.pattern_count(), 7);
        assert_eq!(db.version(), "6");
    }

    #[test]
    fn test_search_finds_matches() {
        let db = PatternDb::parse(SAMPLE_DB, false).unwrap();
        let content = "Using RSAPublicKey for authentication and SHA256 for hashing";
        let matches = db.search(content, "all");
        assert_eq!(matches.len(), 2);
        assert_eq!(matches[0].matched_text, "RSAPublicKey");
        assert_eq!(matches[0].evidence_type, "algorithm/asymmetric/RSA");
        assert_eq!(matches[1].matched_text, "SHA256");
        assert_eq!(matches[1].evidence_type, "algorithm/hash/SHA256");
    }

    #[test]
    fn test_language_filter() {
        let db = PatternDb::parse(SAMPLE_DB, false).unwrap();
        let content = "Call AES_encrypt here and SHA256 there";

        let c_matches = db.search(content, "c");
        assert_eq!(c_matches.len(), 2);

        let py_matches = db.search(content, "python");
        assert_eq!(py_matches.len(), 1);
        assert_eq!(py_matches[0].matched_text, "SHA256");
    }

    #[test]
    fn test_case_insensitive() {
        let db = PatternDb::parse(SAMPLE_DB, true).unwrap();
        let content = "using sha256 and rsapublickey";
        let matches = db.search(content, "all");
        assert_eq!(matches.len(), 2);
    }

    #[test]
    fn test_has_match_quick() {
        let db = PatternDb::parse(SAMPLE_DB, false).unwrap();
        assert!(db.has_match(b"foo RSAPublicKey bar", "all"));
        assert!(!db.has_match(b"nothing crypto here", "all"));
    }

    #[test]
    fn test_line_index() {
        let content = "line zero\nline one\nline two\n";
        let idx = LineIndex::new(content.as_bytes());
        assert_eq!(idx.line_of(0), 0);
        assert_eq!(idx.line_of(5), 0);
        assert_eq!(idx.line_of(10), 1);
        assert_eq!(idx.line_of(19), 2);
        assert_eq!(idx.line_text(content, 0), "line zero");
        assert_eq!(idx.line_text(content, 1), "line one");
        assert_eq!(idx.line_text(content, 2), "line two");
        assert_eq!(idx.line_text(content, 99), "");
    }

    #[test]
    fn test_context_lines() {
        let db = PatternDb::parse(SAMPLE_DB, false).unwrap();
        let content = "line 0\nline 1\nline 2\nSHA256 here\nline 4\nline 5\nline 6\n";
        let matches = db.search(content, "all");
        assert_eq!(matches.len(), 1);
        let m = &matches[0];
        assert_eq!(m.line_number, 4);
        assert_eq!(m.context_before[0], "line 0");
        assert_eq!(m.context_before[1], "line 1");
        assert_eq!(m.context_before[2], "line 2");
        assert_eq!(m.context_after[0], "line 4");
        assert_eq!(m.context_after[1], "line 5");
        assert_eq!(m.context_after[2], "line 6");
    }

    // -----------------------------------------------------------------------
    // Word boundary tests (mirrors Python test_match_boundary)
    // -----------------------------------------------------------------------

    /// Helper: count matches of a given evidence_type
    fn count_by_type(matches: &[Match], evidence_type: &str) -> usize {
        matches.iter().filter(|m| m.evidence_type == evidence_type).count()
    }

    #[test]
    fn test_word_boundary_no_boundary() {
        // "lorem" has no \b markers -> should match everywhere
        let db = PatternDb::parse(BOUNDARY_DB, true).unwrap();
        // "lorem" appears in: "lorem", "zlorem", "loremz", "z_lorem", etc.
        let content = "lorem zlorem loremz z_lorem";
        let matches = db.search(content, "all");
        // All 4 contain "lorem" as substring
        assert_eq!(count_by_type(&matches, "keyword_any"), 4);
    }

    #[test]
    fn test_word_boundary_start() {
        // "\bipsum" requires word boundary at start
        let db = PatternDb::parse(BOUNDARY_DB, true).unwrap();

        // Should match: ipsum, ipsum)z, ipsum.z (non-word before)
        // Should NOT match: zipsum, z_ipsum (word char before)
        let cases = [
            ("ipsum", true),
            (" ipsum", true),
            ("(ipsum", true),
            (".ipsum", true),
            ("-ipsum", true),
            ("zipsum", false), // z is word char
            ("z_ipsum", false), // _ is word char
        ];

        for (input, should_match) in cases {
            let matches = db.search(input, "all");
            let found = count_by_type(&matches, "keyword_boundary_begin");
            assert_eq!(
                found > 0,
                should_match,
                "\\bipsum on '{}': expected match={}, got {}",
                input, should_match, found
            );
        }
    }

    #[test]
    fn test_word_boundary_end() {
        // "dolor\b" requires word boundary at end
        let db = PatternDb::parse(BOUNDARY_DB, true).unwrap();

        let cases = [
            ("dolor", true),
            ("dolor ", true),
            ("dolor)", true),
            ("dolor.", true),
            ("dolorz", false), // z is word char
            ("dolor_z", false), // _ is word char
        ];

        for (input, should_match) in cases {
            let matches = db.search(input, "all");
            let found = count_by_type(&matches, "keyword_boundary_end");
            assert_eq!(
                found > 0,
                should_match,
                "dolor\\b on '{}': expected match={}, got {}",
                input, should_match, found
            );
        }
    }

    #[test]
    fn test_word_boundary_both() {
        // "\bsit\b" requires word boundary at both ends
        let db = PatternDb::parse(BOUNDARY_DB, true).unwrap();

        let cases = [
            ("sit", true),
            (" sit ", true),
            ("(sit)", true),
            (".sit.", true),
            ("-sit-", true),
            ("zsit", false),    // word char before
            ("sitz", false),    // word char after
            ("z_sit", false),   // _ is word char
            ("sit_z", false),   // _ is word char
            ("z_sit_z", false), // both
        ];

        for (input, should_match) in cases {
            let matches = db.search(input, "all");
            let found = count_by_type(&matches, "keyword_boundary_all");
            assert_eq!(
                found > 0,
                should_match,
                "\\bsit\\b on '{}': expected match={}, got {}",
                input, should_match, found
            );
        }
    }

    // -----------------------------------------------------------------------
    // testpkg3 match detail tests (mirrors Python test_matched_text, etc.)
    // -----------------------------------------------------------------------

    #[test]
    fn test_matched_text() {
        let db = PatternDb::parse(BOUNDARY_DB, true).unwrap();
        let matches = db.search(TESTPKG3_CONTENT, "all");
        assert_eq!(matches.len(), 3);

        for m in &matches {
            match m.evidence_type.as_str() {
                "keyword_any" => assert_eq!(m.matched_text, "lorem"),
                "keyword_boundary_begin" => assert_eq!(m.matched_text, "IPSUM"),
                "keyword_boundary_end" => assert_eq!(m.matched_text, "dolor"),
                _ => panic!("unexpected evidence type: {}", m.evidence_type),
            }
        }
    }

    #[test]
    fn test_line_number_detail() {
        let db = PatternDb::parse(BOUNDARY_DB, true).unwrap();
        let matches = db.search(TESTPKG3_CONTENT, "all");
        assert_eq!(matches.len(), 3);

        for m in &matches {
            match m.evidence_type.as_str() {
                "keyword_any" => assert_eq!(m.line_number, 1),
                "keyword_boundary_begin" => assert_eq!(m.line_number, 4),
                "keyword_boundary_end" => assert_eq!(m.line_number, 7),
                _ => panic!("unexpected evidence type"),
            }
        }
    }

    #[test]
    fn test_line_text_detail() {
        let db = PatternDb::parse(BOUNDARY_DB, true).unwrap();
        let matches = db.search(TESTPKG3_CONTENT, "all");

        for m in &matches {
            match m.evidence_type.as_str() {
                "keyword_any" => assert_eq!(m.line_text, "testtestloremtesttest"),
                "keyword_boundary_begin" => assert_eq!(m.line_text, "testtest IPSUM test"),
                "keyword_boundary_end" => assert_eq!(m.line_text, "test dolor"),
                _ => panic!("unexpected evidence type"),
            }
        }
    }

    #[test]
    fn test_file_index_detail() {
        let db = PatternDb::parse(BOUNDARY_DB, true).unwrap();
        let matches = db.search(TESTPKG3_CONTENT, "all");

        for m in &matches {
            match m.evidence_type.as_str() {
                "keyword_any" => {
                    assert_eq!(m.file_index_begin, 8);
                    assert_eq!(m.file_index_end, 13);
                }
                "keyword_boundary_begin" => {
                    assert_eq!(m.file_index_begin, 41);
                    assert_eq!(m.file_index_end, 46);
                }
                "keyword_boundary_end" => {
                    assert_eq!(m.file_index_begin, 67);
                    assert_eq!(m.file_index_end, 72);
                }
                _ => panic!("unexpected evidence type"),
            }
        }
    }

    #[test]
    fn test_line_index_detail() {
        let db = PatternDb::parse(BOUNDARY_DB, true).unwrap();
        let matches = db.search(TESTPKG3_CONTENT, "all");

        for m in &matches {
            match m.evidence_type.as_str() {
                "keyword_any" => {
                    assert_eq!(m.line_index_begin, 8);
                    assert_eq!(m.line_index_end, 13);
                }
                "keyword_boundary_begin" => {
                    assert_eq!(m.line_index_begin, 9);
                    assert_eq!(m.line_index_end, 14);
                }
                "keyword_boundary_end" => {
                    assert_eq!(m.line_index_begin, 5);
                    assert_eq!(m.line_index_end, 10);
                }
                _ => panic!("unexpected evidence type"),
            }
        }
    }

    #[test]
    fn test_no_matches() {
        let db = PatternDb::parse(BOUNDARY_DB, false).unwrap();
        let content = "nothing interesting here at all";
        let matches = db.search(content, "all");
        assert!(matches.is_empty());
    }

    // -----------------------------------------------------------------------
    // Evidence type filtering
    // -----------------------------------------------------------------------

    #[test]
    fn test_ignore_evidence_types() {
        let db = PatternDb::parse_with_options(
            BOUNDARY_DB,
            true,
            false,
            &["keyword_any".to_string(), "keyword_boundary_end".to_string()],
        )
        .unwrap();

        let content = "lorem ipsum dolor sit";
        let matches = db.search(content, "all");

        // keyword_any (lorem) and keyword_boundary_end (dolor) should be filtered out
        assert_eq!(count_by_type(&matches, "keyword_any"), 0);
        assert_eq!(count_by_type(&matches, "keyword_boundary_end"), 0);
        // keyword_boundary_begin (ipsum) should remain
        assert_eq!(count_by_type(&matches, "keyword_boundary_begin"), 1);
        // keyword_boundary_all (sit) should remain
        assert_eq!(count_by_type(&matches, "keyword_boundary_all"), 1);
    }

    // -----------------------------------------------------------------------
    // API whole-word mode
    // -----------------------------------------------------------------------

    #[test]
    fn test_api_whole_word_mode() {
        let db_content = r#"
[keyword_list_version]
    1

[{"evidence_type": "api_call", "language": "all"}]
    "SHA256_Init"
"#;
        // whole_words=true forces \b on both sides
        let db = PatternDb::parse_with_options(db_content, false, true, &[]).unwrap();

        // Should match: standalone call
        let m1 = db.search("call SHA256_Init(ctx);", "all");
        assert_eq!(m1.len(), 1);

        // Should NOT match: embedded in longer identifier
        let m2 = db.search("call xSHA256_Inity(ctx);", "all");
        assert_eq!(m2.len(), 0);

        // Without whole_words, both should match
        let db_sub = PatternDb::parse_with_options(db_content, false, false, &[]).unwrap();
        let m3 = db_sub.search("call xSHA256_Inity(ctx);", "all");
        assert_eq!(m3.len(), 1); // substring match
    }
}
