//! Rapid triage query engine for USN journal forensic analysis.
//!
//! Provides a structured way to ask forensic questions against resolved USN
//! journal records. Each `TriageQuestion` encapsulates a query that filters
//! records by path patterns, extensions, reason flags, and filename keywords.
//! The engine returns `TriageResult` structs indicating whether evidence was
//! found and which record indices matched.

use regex::Regex;

use ntfs_core::rewind::ResolvedRecord;
use ntfs_core::usn::UsnReason;

pub mod queries;

// ─── Types ──────────────────────────────────────────────────────────────────

/// A forensic triage question with its associated query filter.
#[derive(Debug, Clone)]
pub struct TriageQuestion {
    /// Unique identifier (e.g. `malware_deployed`).
    pub id: &'static str,
    /// Category grouping (e.g. "Breach & Malware").
    pub category: &'static str,
    /// Human-readable question (e.g. "Were executables dropped in suspicious locations?").
    pub question: &'static str,
    /// The query filter to evaluate against records.
    pub query: TriageQuery,
}

/// Filter criteria for a triage question.
#[derive(Debug, Clone, Default)]
pub struct TriageQuery {
    /// Regex patterns matched against the full path (case-insensitive, any match).
    pub path_patterns: Vec<&'static str>,
    /// File extension filter (without dot, e.g. "exe", "dll").
    pub extension_filter: Vec<&'static str>,
    /// Reason flags; record must have at least one flag in common (intersects).
    pub reasons: Option<UsnReason>,
    /// Regex patterns; records matching any of these are excluded.
    pub exclude_patterns: Vec<&'static str>,
    /// Substring filters matched against the filename (case-insensitive, any match).
    pub filename_filter: Vec<&'static str>,
    /// Filter by record source (e.g. "carved", "ghost"). Empty = match all sources.
    pub source_filter: Vec<&'static str>,
}

/// Result of evaluating a single triage question against the record set.
pub struct TriageResult {
    /// Matches the `TriageQuestion::id`.
    pub id: &'static str,
    /// Matches the `TriageQuestion::category`.
    pub category: &'static str,
    /// Matches the `TriageQuestion::question`.
    pub question: &'static str,
    /// Whether any records matched.
    pub has_hits: bool,
    /// Number of matching records.
    pub hit_count: usize,
    /// Indices into the input record slice for matching records.
    pub record_indices: Vec<usize>,
}

// ─── Engine ─────────────────────────────────────────────────────────────────

/// Run all triage questions against the resolved record set.
///
/// Returns one `TriageResult` per question, in the same order as the input.
#[must_use]
pub fn run_triage(questions: &[TriageQuestion], records: &[ResolvedRecord]) -> Vec<TriageResult> {
    questions
        .iter()
        .map(|q| {
            let record_indices: Vec<usize> = records
                .iter()
                .enumerate()
                .filter(|(_, r)| matches_query(&q.query, r))
                .map(|(i, _)| i)
                .collect();
            let hit_count = record_indices.len();
            TriageResult {
                id: q.id,
                category: q.category,
                question: q.question,
                has_hits: hit_count > 0,
                hit_count,
                record_indices,
            }
        })
        .collect()
}

/// Check whether a single resolved record matches the given query.
fn matches_query(query: &TriageQuery, record: &ResolvedRecord) -> bool {
    // An entirely empty query matches nothing (placeholder questions).
    if query.path_patterns.is_empty()
        && query.extension_filter.is_empty()
        && query.reasons.is_none()
        && query.exclude_patterns.is_empty()
        && query.filename_filter.is_empty()
        && query.source_filter.is_empty()
    {
        return false;
    }

    // Source filter: record source must match one of the listed sources.
    if !query.source_filter.is_empty() {
        let source_str = record.source.as_str();
        if !query.source_filter.contains(&source_str) {
            return false;
        }
    }

    // Reason flag check: record must share at least one flag with the query.
    if let Some(reasons) = query.reasons {
        if !record.record.reason.intersects(reasons) {
            return false;
        }
    }

    // Path pattern check: at least one regex must match the full path.
    if !query.path_patterns.is_empty() {
        let path_lower = record.full_path.to_lowercase();
        let any_match = query
            .path_patterns
            .iter()
            .any(|pat| Regex::new(&pat.to_lowercase()).is_ok_and(|re| re.is_match(&path_lower)));
        if !any_match {
            return false;
        }
    }

    // Extension filter: filename must end with one of the listed extensions.
    if !query.extension_filter.is_empty() {
        let name_lower = record.record.filename.to_lowercase();
        let any_ext = query.extension_filter.iter().any(|ext| {
            let dot_ext = format!(".{}", ext.to_lowercase());
            name_lower.ends_with(&dot_ext)
        });
        if !any_ext {
            return false;
        }
    }

    // Filename filter: filename must contain at least one of the keywords.
    if !query.filename_filter.is_empty() {
        let name_lower = record.record.filename.to_lowercase();
        let any_name = query
            .filename_filter
            .iter()
            .any(|kw| name_lower.contains(&kw.to_lowercase()));
        if !any_name {
            return false;
        }
    }

    // Exclude patterns: if any regex matches the full path, exclude the record.
    if !query.exclude_patterns.is_empty() {
        let path_lower = record.full_path.to_lowercase();
        let any_exclude = query
            .exclude_patterns
            .iter()
            .any(|pat| Regex::new(&pat.to_lowercase()).is_ok_and(|re| re.is_match(&path_lower)));
        if any_exclude {
            return false;
        }
    }

    true
}

// ─── Tests ──────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::DateTime;
    use ntfs_core::usn::{FileAttributes, UsnRecord};

    /// Helper to create a `ResolvedRecord` for testing (defaults to Allocated source).
    fn make_resolved(full_path: &str, filename: &str, reason: UsnReason) -> ResolvedRecord {
        ResolvedRecord {
            record: UsnRecord {
                mft_entry: 100,
                mft_sequence: 1,
                parent_mft_entry: 5,
                parent_mft_sequence: 5,
                usn: 1000,
                timestamp: DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
                reason,
                filename: filename.to_string(),
                file_attributes: FileAttributes::ARCHIVE,
                source_info: 0,
                security_id: 0,
                major_version: 2,
            },
            full_path: full_path.to_string(),
            parent_path: ".".to_string(),
            source: ntfs_core::rewind::RecordSource::Allocated,
        }
    }

    #[test]
    fn test_malware_query_matches_exe_in_system32() {
        let records = vec![make_resolved(
            r".\Windows\System32\evil.exe",
            "evil.exe",
            UsnReason::FILE_CREATE,
        )];

        let questions = vec![TriageQuestion {
            id: "malware_deployed",
            category: "Breach & Malware",
            question: "Were executables dropped in suspicious locations?",
            query: TriageQuery {
                path_patterns: vec![r"System32", r"SysWOW64", r"Temp", r"AppData"],
                extension_filter: vec!["exe", "dll"],
                reasons: Some(UsnReason::FILE_CREATE),
                ..Default::default()
            },
        }];

        let results = run_triage(&questions, &records);
        assert_eq!(results.len(), 1);
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 1);
        assert_eq!(results[0].record_indices, vec![0]);
    }

    #[test]
    fn test_query_excludes_by_pattern() {
        let records = vec![
            make_resolved(
                r".\Windows\System32\legit.dll",
                "legit.dll",
                UsnReason::FILE_CREATE,
            ),
            make_resolved(
                r".\Users\admin\AppData\Local\Temp\dropper.exe",
                "dropper.exe",
                UsnReason::FILE_CREATE,
            ),
        ];

        let questions = vec![TriageQuestion {
            id: "test_exclude",
            category: "Test",
            question: "Test exclusion",
            query: TriageQuery {
                path_patterns: vec![r"System32", r"Temp", r"AppData"],
                extension_filter: vec!["exe", "dll"],
                reasons: Some(UsnReason::FILE_CREATE),
                exclude_patterns: vec![r"Windows"],
                ..Default::default()
            },
        }];

        let results = run_triage(&questions, &records);
        assert_eq!(results.len(), 1);
        // Only the Temp record should match; the Windows one is excluded.
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 1);
        assert_eq!(results[0].record_indices, vec![1]);
    }

    #[test]
    fn test_query_no_hits_returns_empty() {
        let records = vec![make_resolved(
            r".\Documents\report.pdf",
            "report.pdf",
            UsnReason::DATA_EXTEND,
        )];

        let questions = vec![TriageQuestion {
            id: "no_hits",
            category: "Test",
            question: "Should find nothing",
            query: TriageQuery {
                path_patterns: vec![r"System32"],
                extension_filter: vec!["exe"],
                reasons: Some(UsnReason::FILE_CREATE),
                ..Default::default()
            },
        }];

        let results = run_triage(&questions, &records);
        assert_eq!(results.len(), 1);
        assert!(!results[0].has_hits);
        assert_eq!(results[0].hit_count, 0);
        assert!(results[0].record_indices.is_empty());
    }

    #[test]
    fn test_filename_filter_matches() {
        let records = vec![
            make_resolved(
                r".\Windows\System32\mimikatz.exe",
                "mimikatz.exe",
                UsnReason::FILE_CREATE,
            ),
            make_resolved(
                r".\Users\admin\Desktop\notes.txt",
                "notes.txt",
                UsnReason::DATA_EXTEND,
            ),
        ];

        let questions = vec![TriageQuestion {
            id: "cred_access",
            category: "Credential Access",
            question: "Were credential tools used?",
            query: TriageQuery {
                filename_filter: vec!["mimikatz", "procdump", "lsass"],
                ..Default::default()
            },
        }];

        let results = run_triage(&questions, &records);
        assert_eq!(results.len(), 1);
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 1);
        assert_eq!(results[0].record_indices, vec![0]);
    }

    // ─── Tests for source filtering ───────────────────────────────────────

    fn make_resolved_with_source(
        full_path: &str,
        filename: &str,
        reason: UsnReason,
        source: ntfs_core::rewind::RecordSource,
    ) -> ResolvedRecord {
        let mut r = make_resolved(full_path, filename, reason);
        r.source = source;
        r
    }

    #[test]
    fn test_source_filter_matches_carved_only() {
        use ntfs_core::rewind::RecordSource;

        let records = vec![
            make_resolved_with_source(
                r".\Users\admin\secret.docx",
                "secret.docx",
                UsnReason::FILE_CREATE,
                RecordSource::Allocated,
            ),
            make_resolved_with_source(
                r".\Users\admin\deleted.exe",
                "deleted.exe",
                UsnReason::FILE_CREATE,
                RecordSource::Carved,
            ),
            make_resolved_with_source(
                r".\Users\admin\ghost.dll",
                "ghost.dll",
                UsnReason::FILE_CREATE,
                RecordSource::Ghost,
            ),
        ];

        let questions = vec![TriageQuestion {
            id: "carved_only",
            category: "Test",
            question: "Only carved records?",
            query: TriageQuery {
                source_filter: vec!["entry-carved"],
                ..Default::default()
            },
        }];

        let results = run_triage(&questions, &records);
        assert_eq!(results[0].hit_count, 1);
        assert_eq!(results[0].record_indices, vec![1]);
    }

    #[test]
    fn test_source_filter_matches_carved_and_ghost() {
        use ntfs_core::rewind::RecordSource;

        let records = vec![
            make_resolved_with_source(
                r".\allocated.txt",
                "allocated.txt",
                UsnReason::FILE_CREATE,
                RecordSource::Allocated,
            ),
            make_resolved_with_source(
                r".\carved.exe",
                "carved.exe",
                UsnReason::FILE_CREATE,
                RecordSource::Carved,
            ),
            make_resolved_with_source(
                r".\ghost.dll",
                "ghost.dll",
                UsnReason::FILE_CREATE,
                RecordSource::Ghost,
            ),
        ];

        let questions = vec![TriageQuestion {
            id: "recovered",
            category: "Test",
            question: "All recovered?",
            query: TriageQuery {
                source_filter: vec!["entry-carved", "ghost"],
                ..Default::default()
            },
        }];

        let results = run_triage(&questions, &records);
        assert_eq!(results[0].hit_count, 2);
        assert_eq!(results[0].record_indices, vec![1, 2]);
    }

    #[test]
    fn test_empty_source_filter_matches_all() {
        use ntfs_core::rewind::RecordSource;

        let records = vec![
            make_resolved_with_source(
                r".\a.txt",
                "a.txt",
                UsnReason::FILE_CREATE,
                RecordSource::Allocated,
            ),
            make_resolved_with_source(
                r".\b.txt",
                "b.txt",
                UsnReason::FILE_CREATE,
                RecordSource::Carved,
            ),
        ];

        // source_filter is empty but reasons is set, so this is NOT a
        // placeholder query — the empty source filter should not restrict
        // which sources match.
        let questions = vec![TriageQuestion {
            id: "all",
            category: "Test",
            question: "All records?",
            query: TriageQuery {
                reasons: Some(UsnReason::FILE_CREATE),
                source_filter: vec![], // empty = match all
                ..Default::default()
            },
        }];

        let results = run_triage(&questions, &records);
        assert_eq!(results[0].hit_count, 2);
    }

    #[test]
    fn test_recovered_evidence_query_uses_source_filter() {
        use ntfs_core::rewind::RecordSource;

        let questions = crate::triage::queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "recovered_evidence")
            .expect("missing recovered_evidence");

        // Verify the query actually has a source filter
        assert!(!q.query.source_filter.is_empty());

        let records = vec![
            make_resolved_with_source(
                r".\normal.txt",
                "normal.txt",
                UsnReason::FILE_CREATE,
                RecordSource::Allocated,
            ),
            make_resolved_with_source(
                r".\recovered.exe",
                "recovered.exe",
                UsnReason::FILE_CREATE,
                RecordSource::Carved,
            ),
            make_resolved_with_source(
                r".\ghost.dll",
                "ghost.dll",
                UsnReason::FILE_CREATE,
                RecordSource::Ghost,
            ),
        ];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 2);
    }

    // ─── Tests for expanded triage question set ────────────────────────────

    #[test]
    fn test_builtin_questions_returns_12() {
        let questions = crate::triage::queries::builtin_questions();
        assert_eq!(questions.len(), 12);
    }

    #[test]
    fn test_builtin_has_execution_evidence_question() {
        let questions = crate::triage::queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "execution_evidence");
        assert!(q.is_some());
    }

    #[test]
    fn test_prefetch_creation_proves_execution() {
        let questions = crate::triage::queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "execution_evidence")
            .expect("missing execution_evidence");

        let records = vec![
            make_resolved(
                r".\Windows\Prefetch\COREUPDATE.EXE-A1B2C3D4.pf",
                "COREUPDATE.EXE-A1B2C3D4.pf",
                UsnReason::FILE_CREATE,
            ),
            make_resolved(
                r".\Windows\Prefetch\SVCHOST.EXE-12345678.pf",
                "SVCHOST.EXE-12345678.pf",
                UsnReason::FILE_CREATE,
            ),
        ];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 2);
    }

    #[test]
    fn test_data_staging_detects_archive_in_user_dir() {
        let questions = crate::triage::queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "data_staging")
            .expect("missing data_staging");

        let records = vec![
            make_resolved(
                r".\Users\admin\Desktop\exfil.zip",
                "exfil.zip",
                UsnReason::FILE_CREATE,
            ),
            make_resolved(
                r".\Program Files\7zip\7z.dll",
                "7z.dll",
                UsnReason::FILE_CREATE,
            ),
        ];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 1);
        assert_eq!(results[0].record_indices, vec![0]);
    }

    #[test]
    fn test_credential_access_matches_sam_by_path() {
        let questions = crate::triage::queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "credential_access")
            .expect("missing credential_access");

        let records = vec![
            // Real SAM hive path — should match
            make_resolved(r".\Windows\System32\config\SAM", "SAM", UsnReason::CLOSE),
            // Random file named "sam" — should NOT match
            make_resolved(
                r".\Users\sam\Documents\report.docx",
                "report.docx",
                UsnReason::CLOSE,
            ),
        ];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 1);
    }

    #[test]
    fn test_evidence_destruction_detects_evtx_deletion() {
        let questions = crate::triage::queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "evidence_destruction")
            .expect("missing evidence_destruction");

        let records = vec![
            make_resolved(
                r".\Windows\System32\winevt\Logs\Security.evtx",
                "Security.evtx",
                UsnReason::FILE_DELETE,
            ),
            make_resolved(
                r".\Windows\Prefetch\MIMIKATZ.EXE-AABBCCDD.pf",
                "MIMIKATZ.EXE-AABBCCDD.pf",
                UsnReason::FILE_DELETE,
            ),
        ];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 2);
    }

    #[test]
    fn test_file_disguise_detects_ads_operations() {
        let questions = crate::triage::queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "file_disguise")
            .expect("missing file_disguise");

        let records = vec![
            make_resolved(
                r".\Users\admin\document.docx",
                "document.docx",
                UsnReason::NAMED_DATA_EXTEND,
            ),
            make_resolved(
                r".\Users\admin\normal.txt",
                "normal.txt",
                UsnReason::DATA_EXTEND,
            ),
        ];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 1);
    }

    #[test]
    fn test_initial_access_detects_exe_in_downloads() {
        let questions = crate::triage::queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "initial_access")
            .expect("missing initial_access");

        let records = vec![
            make_resolved(
                r".\Users\admin\Downloads\payload.exe",
                "payload.exe",
                UsnReason::FILE_CREATE,
            ),
            make_resolved(
                r".\Windows\System32\cmd.exe",
                "cmd.exe",
                UsnReason::FILE_CREATE,
            ),
        ];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 1);
    }

    #[test]
    fn test_malware_deployed_detects_exe_in_programdata() {
        let questions = crate::triage::queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "malware_deployed")
            .expect("missing malware_deployed");

        let records = vec![
            make_resolved(
                r".\ProgramData\evil.exe",
                "evil.exe",
                UsnReason::FILE_CREATE,
            ),
            make_resolved(
                r".\Documents\readme.txt",
                "readme.txt",
                UsnReason::FILE_CREATE,
            ),
        ];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 1);
        assert_eq!(results[0].record_indices, vec![0]);
    }

    #[test]
    fn test_sensitive_data_detects_xlsx_access() {
        let questions = crate::triage::queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "sensitive_data")
            .expect("missing sensitive_data");

        let records = vec![
            make_resolved(
                r".\Users\admin\Documents\financials.xlsx",
                "financials.xlsx",
                UsnReason::DATA_EXTEND | UsnReason::CLOSE,
            ),
            make_resolved(
                r".\Windows\ProgramData\config.xml",
                "config.xml",
                UsnReason::DATA_EXTEND,
            ),
        ];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 1);
    }

    #[test]
    fn test_persistence_detects_exe_in_startup() {
        let questions = crate::triage::queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "persistence")
            .expect("missing persistence");

        let records = vec![
            make_resolved(
                r".\Users\admin\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\backdoor.exe",
                "backdoor.exe",
                UsnReason::FILE_CREATE,
            ),
            make_resolved(
                r".\Users\admin\Desktop\normal.exe",
                "normal.exe",
                UsnReason::FILE_CREATE,
            ),
        ];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 1);
        assert_eq!(results[0].record_indices, vec![0]);
    }

    #[test]
    fn test_lateral_movement_detects_psexec() {
        let questions = crate::triage::queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "lateral_movement")
            .expect("missing lateral_movement");

        let records = vec![
            make_resolved(
                r".\Windows\System32\psexec.exe",
                "psexec.exe",
                UsnReason::FILE_CREATE,
            ),
            make_resolved(
                r".\Windows\System32\notepad.exe",
                "notepad.exe",
                UsnReason::FILE_CREATE,
            ),
        ];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 1);
        assert_eq!(results[0].record_indices, vec![0]);
    }

    #[test]
    fn test_timestomping_detects_basic_info_change_on_exe() {
        let questions = crate::triage::queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "timestomping")
            .expect("missing timestomping");

        let records = vec![
            make_resolved(
                r".\Users\admin\Temp\payload.exe",
                "payload.exe",
                UsnReason::BASIC_INFO_CHANGE,
            ),
            make_resolved(
                r".\Windows\WinSxS\something.exe",
                "something.exe",
                UsnReason::BASIC_INFO_CHANGE,
            ),
        ];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
        assert_eq!(results[0].hit_count, 1);
        assert_eq!(results[0].record_indices, vec![0]);
    }

    #[test]
    fn test_source_filter_ghost_only() {
        use ntfs_core::rewind::RecordSource;

        let records = vec![
            make_resolved_with_source(
                r".\file_a.exe",
                "file_a.exe",
                UsnReason::FILE_CREATE,
                RecordSource::Allocated,
            ),
            make_resolved_with_source(
                r".\file_b.exe",
                "file_b.exe",
                UsnReason::FILE_CREATE,
                RecordSource::Carved,
            ),
            make_resolved_with_source(
                r".\file_c.exe",
                "file_c.exe",
                UsnReason::FILE_CREATE,
                RecordSource::Ghost,
            ),
        ];

        let questions = vec![TriageQuestion {
            id: "ghost_only",
            category: "Test",
            question: "Only ghost records?",
            query: TriageQuery {
                source_filter: vec!["ghost"],
                ..Default::default()
            },
        }];

        let results = run_triage(&questions, &records);
        assert_eq!(results[0].hit_count, 1);
        assert_eq!(results[0].record_indices, vec![2]);
    }

    #[test]
    fn test_record_source_as_str() {
        use ntfs_core::rewind::RecordSource;
        assert_eq!(RecordSource::Allocated.as_str(), "allocated");
        assert_eq!(RecordSource::Carved.as_str(), "entry-carved");
        assert_eq!(RecordSource::Ghost.as_str(), "ghost");
    }

    // ─── Edge case tests ─────────────────────────────────────────────────

    #[test]
    fn test_run_triage_with_empty_records() {
        let questions = crate::triage::queries::builtin_questions();
        let results = run_triage(&questions, &[]);
        assert_eq!(results.len(), 12);
        for r in &results {
            assert!(!r.has_hits);
            assert_eq!(r.hit_count, 0);
            assert!(r.record_indices.is_empty());
        }
    }

    #[test]
    fn test_run_triage_with_empty_questions() {
        let records = vec![make_resolved(
            r".\test.exe",
            "test.exe",
            UsnReason::FILE_CREATE,
        )];
        let results = run_triage(&[], &records);
        assert!(results.is_empty());
    }

    #[test]
    fn test_matches_query_empty_query_matches_nothing() {
        let records = vec![make_resolved(
            r".\test.exe",
            "test.exe",
            UsnReason::FILE_CREATE,
        )];
        let questions = vec![TriageQuestion {
            id: "empty",
            category: "Test",
            question: "Empty query?",
            query: TriageQuery::default(),
        }];
        let results = run_triage(&questions, &records);
        assert!(!results[0].has_hits);
        assert_eq!(results[0].hit_count, 0);
    }

    #[test]
    fn test_matches_query_reasons_only() {
        let records = vec![
            make_resolved(r".\anything.txt", "anything.txt", UsnReason::FILE_DELETE),
            make_resolved(r".\other.txt", "other.txt", UsnReason::FILE_CREATE),
        ];
        let questions = vec![TriageQuestion {
            id: "reasons_only",
            category: "Test",
            question: "Only reason filter?",
            query: TriageQuery {
                reasons: Some(UsnReason::FILE_DELETE),
                ..Default::default()
            },
        }];
        let results = run_triage(&questions, &records);
        assert_eq!(results[0].hit_count, 1);
        assert_eq!(results[0].record_indices, vec![0]);
    }

    #[test]
    fn test_source_filter_case_sensitivity() {
        use ntfs_core::rewind::RecordSource;
        let records = vec![make_resolved_with_source(
            r".\test.exe",
            "test.exe",
            UsnReason::FILE_CREATE,
            RecordSource::Carved,
        )];
        let questions = vec![TriageQuestion {
            id: "case_test",
            category: "Test",
            question: "Case sensitive?",
            query: TriageQuery {
                source_filter: vec!["Carved"], // uppercase C - should NOT match
                ..Default::default()
            },
        }];
        let results = run_triage(&questions, &records);
        assert_eq!(results[0].hit_count, 0);
    }

    #[test]
    fn test_multiple_questions_independent_results() {
        let records = vec![
            make_resolved(
                r".\Windows\Prefetch\CMD.EXE-12345678.pf",
                "CMD.EXE-12345678.pf",
                UsnReason::FILE_CREATE,
            ),
            make_resolved(
                r".\Users\admin\Downloads\payload.exe",
                "payload.exe",
                UsnReason::FILE_CREATE,
            ),
        ];
        let questions = crate::triage::queries::builtin_questions();
        let results = run_triage(&questions, &records);

        // execution_evidence should match the prefetch file
        let exec = results
            .iter()
            .find(|r| r.id == "execution_evidence")
            .unwrap();
        assert!(exec.has_hits);

        // initial_access should match the Downloads file
        let init = results.iter().find(|r| r.id == "initial_access").unwrap();
        assert!(init.has_hits);

        // data_staging should match nothing (no archive extensions)
        let staging = results.iter().find(|r| r.id == "data_staging").unwrap();
        assert!(!staging.has_hits);
    }

    // ─── Noise reduction tests ──────────────────────────────────────────

    #[test]
    fn test_credential_access_excludes_config_systemprofile() {
        // \config\systemprofile\ is NOT the SAM/SECURITY/SYSTEM hive —
        // it's just the SYSTEM account's user profile folder.
        let records = vec![
            make_resolved(
                r".\Windows\System32\config\systemprofile\AppData\Local\Microsoft\foo.dat",
                "foo.dat",
                UsnReason::DATA_TRUNCATION,
            ),
            // Real SAM hive access should still match
            make_resolved(
                r".\Windows\System32\config\SAM",
                "SAM",
                UsnReason::DATA_OVERWRITE,
            ),
            make_resolved(
                r".\Windows\System32\config\SAM.LOG1",
                "SAM.LOG1",
                UsnReason::DATA_OVERWRITE,
            ),
            make_resolved(
                r".\Windows\System32\config\SYSTEM",
                "SYSTEM",
                UsnReason::DATA_OVERWRITE,
            ),
            make_resolved(
                r".\Windows\System32\config\SECURITY",
                "SECURITY",
                UsnReason::DATA_OVERWRITE,
            ),
        ];

        let questions = queries::builtin_questions();
        let results = run_triage(&questions, &records);
        let cred = results
            .iter()
            .find(|r| r.id == "credential_access")
            .unwrap();

        // systemprofile record should NOT match
        assert!(!cred.record_indices.contains(&0));
        // Real hive records SHOULD match
        assert!(cred.record_indices.contains(&1));
        assert!(cred.record_indices.contains(&2));
        assert!(cred.record_indices.contains(&3));
        assert!(cred.record_indices.contains(&4));
    }

    #[test]
    fn test_timestomping_excludes_windowsapps() {
        // Windows Store app updates generate BASIC_INFO_CHANGE on DLLs
        // in Program Files\WindowsApps — this is normal, not timestomping.
        let records = vec![
            make_resolved(
                r".\Program Files\WindowsApps\Microsoft.Windows.Photos_2020\BendRealityNode.dll",
                "BendRealityNode.dll",
                UsnReason::BASIC_INFO_CHANGE,
            ),
            // Real timestomping should still match
            make_resolved(
                r".\Users\Admin\Downloads\payload.exe",
                "payload.exe",
                UsnReason::BASIC_INFO_CHANGE,
            ),
        ];

        let questions = queries::builtin_questions();
        let results = run_triage(&questions, &records);
        let ts = results.iter().find(|r| r.id == "timestomping").unwrap();

        assert!(!ts.record_indices.contains(&0));
        assert!(ts.record_indices.contains(&1));
    }

    #[test]
    fn test_sensitive_data_excludes_store_packages() {
        // Edge/Store temp files in AppData\Local\Packages\Microsoft.*
        // are not user-sensitive data.
        let records = vec![
            make_resolved(
                r".\Users\Admin\AppData\Local\Packages\Microsoft.MicrosoftEdge_8we\temp.txt",
                "temp.txt",
                UsnReason::FILE_CREATE | UsnReason::CLOSE,
            ),
            make_resolved(
                r".\Users\Admin\AppData\Local\Packages\Microsoft.Windows.Search_cw5\cache.txt",
                "cache.txt",
                UsnReason::FILE_CREATE | UsnReason::CLOSE,
            ),
            // Real sensitive data should still match
            make_resolved(
                r".\Users\Admin\Documents\passwords.xlsx",
                "passwords.xlsx",
                UsnReason::DATA_EXTEND | UsnReason::CLOSE,
            ),
        ];

        let questions = queries::builtin_questions();
        let results = run_triage(&questions, &records);
        let sd = results.iter().find(|r| r.id == "sensitive_data").unwrap();

        assert!(!sd.record_indices.contains(&0));
        assert!(!sd.record_indices.contains(&1));
        assert!(sd.record_indices.contains(&2));
    }

    // ─── FN reduction: expanded reason flags ─────────────────────────────

    #[test]
    fn test_malware_deployed_catches_rename_new_name() {
        // Malware often appears via RENAME_NEW_NAME when files are moved
        // into suspicious directories (e.g. temp extraction, sideloading).
        let questions = queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "malware_deployed")
            .unwrap();

        let records = vec![make_resolved(
            r".\Users\admin\AppData\Local\Temp\dropper.exe",
            "dropper.exe",
            UsnReason::RENAME_NEW_NAME,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
    }

    #[test]
    fn test_malware_deployed_catches_security_change() {
        // SECURITY_CHANGE on executables in suspicious dirs can indicate
        // permission escalation or ownership transfer after drop.
        let questions = queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "malware_deployed")
            .unwrap();

        let records = vec![make_resolved(
            r".\ProgramData\implant.dll",
            "implant.dll",
            UsnReason::SECURITY_CHANGE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
    }

    #[test]
    fn test_data_staging_catches_rename_new_name() {
        // Archives moved to staging directories via rename.
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "data_staging").unwrap();

        let records = vec![make_resolved(
            r".\Users\admin\Desktop\exfil.zip",
            "exfil.zip",
            UsnReason::RENAME_NEW_NAME,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
    }

    #[test]
    fn test_data_staging_catches_file_delete() {
        // Archive deleted after exfiltration — still relevant staging evidence.
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "data_staging").unwrap();

        let records = vec![make_resolved(
            r".\Users\admin\Downloads\loot.rar",
            "loot.rar",
            UsnReason::FILE_DELETE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
    }

    #[test]
    fn test_sensitive_data_catches_file_create() {
        // Sensitive files being created (e.g. copied/extracted) is relevant.
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "sensitive_data").unwrap();

        let records = vec![make_resolved(
            r".\Users\admin\Documents\secrets.docx",
            "secrets.docx",
            UsnReason::FILE_CREATE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
    }

    #[test]
    fn test_sensitive_data_catches_rename_new_name() {
        // Sensitive files moved to new location (staging for exfil).
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "sensitive_data").unwrap();

        let records = vec![make_resolved(
            r".\Users\admin\Desktop\financials.xlsx",
            "financials.xlsx",
            UsnReason::RENAME_NEW_NAME,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
    }

    #[test]
    fn test_sensitive_data_catches_file_delete() {
        // Sensitive file deletion — potential evidence destruction.
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "sensitive_data").unwrap();

        let records = vec![make_resolved(
            r".\Users\admin\Documents\passwords.kdbx",
            "passwords.kdbx",
            UsnReason::FILE_DELETE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
    }

    #[test]
    fn test_sensitive_data_catches_zip_and_lnk() {
        // Archives and shortcuts to sensitive files are relevant indicators.
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "sensitive_data").unwrap();

        let records = vec![
            make_resolved(
                r".\Users\admin\Desktop\data.zip",
                "data.zip",
                UsnReason::FILE_CREATE,
            ),
            make_resolved(
                r".\Users\admin\Recent\secret.lnk",
                "secret.lnk",
                UsnReason::FILE_CREATE,
            ),
        ];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert_eq!(results[0].hit_count, 2);
    }

    #[test]
    fn test_evidence_destruction_catches_data_overwrite() {
        // Overwriting log files is evidence destruction.
        let questions = queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "evidence_destruction")
            .unwrap();

        let records = vec![make_resolved(
            r".\Windows\System32\winevt\Logs\Security.evtx",
            "Security.evtx",
            UsnReason::DATA_OVERWRITE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
    }

    #[test]
    fn test_evidence_destruction_catches_file_create_in_prefetch() {
        // Prefetch file recreation (after deletion) indicates evidence tampering.
        let questions = queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "evidence_destruction")
            .unwrap();

        let records = vec![make_resolved(
            r".\Windows\Prefetch\SDELETE.EXE-AABBCCDD.pf",
            "SDELETE.EXE-AABBCCDD.pf",
            UsnReason::FILE_CREATE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
    }

    #[test]
    fn test_initial_access_catches_rename_new_name() {
        // Files moved into Downloads/Temp via rename (e.g. browser download complete).
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "initial_access").unwrap();

        let records = vec![make_resolved(
            r".\Users\admin\Downloads\exploit.exe",
            "exploit.exe",
            UsnReason::RENAME_NEW_NAME,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
    }

    // ─── FP reduction: exclusion patterns ──────────────────────────────

    #[test]
    fn test_malware_deployed_excludes_onedrive() {
        // OneDrive sync creates executables in AppData — not malware.
        let questions = queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "malware_deployed")
            .unwrap();

        let records = vec![make_resolved(
            r".\Users\admin\AppData\Local\Microsoft\OneDrive\21.150.0725.0001\FileSyncShell64.dll",
            "FileSyncShell64.dll",
            UsnReason::FILE_CREATE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(!results[0].has_hits);
    }

    #[test]
    fn test_malware_deployed_excludes_native_images() {
        // .NET NativeImages assemblies under AppData are system-generated, not malware.
        let questions = queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "malware_deployed")
            .unwrap();

        let records = vec![make_resolved(
            r".\Users\admin\AppData\Local\assembly\NativeImages_v4.0.30319_64\System.Core\abc123\System.Core.ni.dll",
            "System.Core.ni.dll",
            UsnReason::FILE_CREATE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(!results[0].has_hits);
    }

    #[test]
    fn test_malware_deployed_excludes_packages() {
        // Windows Store app packages in AppData\Local\Packages.
        let questions = queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "malware_deployed")
            .unwrap();

        let records = vec![make_resolved(
            r".\Users\admin\AppData\Local\Packages\Microsoft.Windows.Photos_8we\app.exe",
            "app.exe",
            UsnReason::FILE_CREATE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(!results[0].has_hits);
    }

    #[test]
    fn test_initial_access_excludes_js_extension() {
        // .js files cause too many FPs from system scripts.
        // The initial_access query should not include js.
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "initial_access").unwrap();

        assert!(!q.query.extension_filter.contains(&"js"));
    }

    #[test]
    fn test_initial_access_excludes_onedrive() {
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "initial_access").unwrap();

        let records = vec![make_resolved(
            r".\Users\admin\AppData\Local\Microsoft\OneDrive\21.150\OneDriveStandaloneUpdater.exe",
            "OneDriveStandaloneUpdater.exe",
            UsnReason::FILE_CREATE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(!results[0].has_hits);
    }

    #[test]
    fn test_initial_access_excludes_packages() {
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "initial_access").unwrap();

        let records = vec![make_resolved(
            r".\Users\admin\AppData\Local\Packages\Microsoft.MicrosoftEdge_8we\AC\script.hta",
            "script.hta",
            UsnReason::FILE_CREATE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(!results[0].has_hits);
    }

    #[test]
    fn test_file_disguise_excludes_assembly() {
        // .NET assembly ADS operations are system noise.
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "file_disguise").unwrap();

        let records = vec![make_resolved(
            r".\Windows\assembly\NativeImages_v4.0.30319_64\System.Xml\foo.dll",
            "foo.dll",
            UsnReason::NAMED_DATA_EXTEND,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(!results[0].has_hits);
    }

    #[test]
    fn test_file_disguise_excludes_windowsapps() {
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "file_disguise").unwrap();

        let records = vec![make_resolved(
            r".\Program Files\WindowsApps\Microsoft.Windows.Photos_2020\PhotosApp.dll",
            "PhotosApp.dll",
            UsnReason::NAMED_DATA_EXTEND,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(!results[0].has_hits);
    }

    #[test]
    fn test_file_disguise_excludes_program_files() {
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "file_disguise").unwrap();

        let records = vec![make_resolved(
            r".\Program Files\SomeApp\helper.dll",
            "helper.dll",
            UsnReason::NAMED_DATA_OVERWRITE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(!results[0].has_hits);
    }

    #[test]
    fn test_file_disguise_excludes_software_distribution() {
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "file_disguise").unwrap();

        let records = vec![make_resolved(
            r".\Windows\SoftwareDistribution\Download\abc123\update.exe",
            "update.exe",
            UsnReason::NAMED_DATA_EXTEND,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(!results[0].has_hits);
    }

    #[test]
    fn test_file_disguise_still_catches_user_ads() {
        // ADS in user directories should still be flagged.
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "file_disguise").unwrap();

        let records = vec![make_resolved(
            r".\Users\admin\Documents\resume.docx",
            "resume.docx",
            UsnReason::NAMED_DATA_EXTEND,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(results[0].has_hits);
    }

    #[test]
    fn test_persistence_excludes_start_menu_non_startup() {
        // "Start Menu" (not Startup) matches too broadly — normal shortcuts.
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "persistence").unwrap();

        // Verify "Start Menu" is NOT in path_patterns (only "Startup" should be)
        let has_start_menu = q.query.path_patterns.contains(&"Start Menu");
        assert!(!has_start_menu);
    }

    #[test]
    fn test_timestomping_excludes_windows_temp() {
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "timestomping").unwrap();

        let records = vec![make_resolved(
            r".\Windows\Temp\setup_patch.exe",
            "setup_patch.exe",
            UsnReason::BASIC_INFO_CHANGE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(!results[0].has_hits);
    }

    #[test]
    fn test_timestomping_excludes_software_distribution() {
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "timestomping").unwrap();

        let records = vec![make_resolved(
            r".\Windows\SoftwareDistribution\Download\abc\update.exe",
            "update.exe",
            UsnReason::BASIC_INFO_CHANGE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(!results[0].has_hits);
    }

    #[test]
    fn test_sensitive_data_excludes_appdata() {
        // AppData contains app caches with .txt/.csv etc — not user-sensitive.
        let questions = queries::builtin_questions();
        let q = questions.iter().find(|q| q.id == "sensitive_data").unwrap();

        let records = vec![make_resolved(
            r".\Users\admin\AppData\Local\SomeApp\cache.csv",
            "cache.csv",
            UsnReason::DATA_EXTEND | UsnReason::CLOSE,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(!results[0].has_hits);
    }

    #[test]
    fn test_evidence_destruction_excludes_data_truncation() {
        // DATA_TRUNCATION on .pf files is normal prefetch churn, not evidence destruction.
        // The query should use DATA_OVERWRITE instead to reduce FPs.
        let questions = queries::builtin_questions();
        let q = questions
            .iter()
            .find(|q| q.id == "evidence_destruction")
            .unwrap();

        let records = vec![make_resolved(
            r".\Windows\Prefetch\SVCHOST.EXE-12345678.pf",
            "SVCHOST.EXE-12345678.pf",
            UsnReason::DATA_TRUNCATION,
        )];

        let results = run_triage(std::slice::from_ref(q), &records);
        assert!(!results[0].has_hits);
    }

    #[test]
    fn test_evidence_destruction_excludes_windows_update_logs() {
        // Windows Update log rotation is normal, not evidence destruction.
        let records = vec![
            make_resolved(
                r".\Windows\Logs\WindowsUpdate\WindowsUpdate.20200918.log",
                "WindowsUpdate.20200918.log",
                UsnReason::FILE_DELETE | UsnReason::CLOSE,
            ),
            // Real evidence destruction: security event log deletion
            make_resolved(
                r".\Windows\System32\winevt\Logs\Security.evtx",
                "Security.evtx",
                UsnReason::FILE_DELETE,
            ),
        ];

        let questions = queries::builtin_questions();
        let results = run_triage(&questions, &records);
        let ed = results
            .iter()
            .find(|r| r.id == "evidence_destruction")
            .unwrap();

        assert!(!ed.record_indices.contains(&0));
        assert!(ed.record_indices.contains(&1));
    }
}
