//! Regex-based detection rules for SQL injection patterns.
//!
//! Each rule function scans the input SQL and returns a list of `ThreatFinding`s.
//! Rules use regex for pattern matching and never execute the SQL.

use regex::Regex;
use std::sync::LazyLock;

use super::types::{SafetyRule, SafetySeverity, ThreatFinding};

/// Build a `ThreatFinding` from its constituent parts.
fn threat(
    rule: SafetyRule,
    severity: SafetySeverity,
    message: impl Into<String>,
    detail: impl Into<String>,
    location: Option<(usize, usize)>,
    matched_pattern: impl Into<String>,
) -> ThreatFinding {
    ThreatFinding {
        rule,
        severity,
        message: message.into(),
        detail: detail.into(),
        location,
        matched_pattern: matched_pattern.into(),
    }
}

/// Detect multi-statement injection: `;` followed by dangerous statements.
pub fn check_multi_statement(sql: &str) -> Vec<ThreatFinding> {
    static RE: LazyLock<Regex> = LazyLock::new(|| {
        Regex::new(
            r"(?i);\s*(DROP|DELETE|UPDATE|INSERT|GRANT|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|REVOKE|MERGE)\b"
        ).expect("valid regex")
    });

    let mut findings = Vec::new();
    let lower = strip_string_literals(sql);
    for m in RE.find_iter(&lower) {
        findings.push(threat(
            SafetyRule::MultiStatement,
            SafetySeverity::Critical,
            "Multi-statement injection detected",
            "A semicolon followed by a dangerous statement (DROP, DELETE, etc.) \
             was detected. This is a classic SQL injection pattern.",
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }
    findings
}

/// Detect UNION-based injection patterns.
pub fn check_union_injection(sql: &str) -> Vec<ThreatFinding> {
    static RE: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"(?i)\bUNION\s+(ALL\s+)?SELECT\b").expect("valid regex"));

    // Also detect UNION injection with comment obfuscation
    static RE_OBFUSCATED: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"(?i)\bUNION\s*/\*.*?\*/\s*SELECT\b").expect("valid regex"));

    let mut findings = Vec::new();
    let lower = strip_string_literals(sql);

    for m in RE.find_iter(&lower) {
        findings.push(threat(
            SafetyRule::UnionInjection,
            SafetySeverity::High,
            "UNION SELECT injection detected",
            "A UNION SELECT pattern was detected, which is commonly used \
             to extract data from other tables in SQL injection attacks.",
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }

    for m in RE_OBFUSCATED.find_iter(&lower) {
        // Avoid duplicate if the basic regex already matched
        let already_found = findings
            .iter()
            .any(|f| f.location.is_some_and(|(start, _)| start == m.start()));
        if !already_found {
            findings.push(threat(
                SafetyRule::UnionInjection,
                SafetySeverity::Critical,
                "Obfuscated UNION injection detected",
                "A UNION SELECT with comment obfuscation was detected, \
                 indicating an active attempt to bypass filters.",
                Some((m.start(), m.len())),
                m.as_str(),
            ));
        }
    }

    findings
}

/// Detect comment injection: `--` or `/* */` used to truncate query logic.
pub fn check_comment_injection(sql: &str) -> Vec<ThreatFinding> {
    // Detect inline comment that follows a suspicious pattern (closing quote + comment)
    static RE_DOUBLE_DASH: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r#"(?i)('|")\s*--"#).expect("valid regex"));

    // Detect block comment used to bypass or obfuscate
    static RE_BLOCK_COMMENT: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r#"(?i)('|")\s*/\*"#).expect("valid regex"));

    // Detect comment-only payload (entire input is just a comment truncation)
    static RE_TRUNCATION: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"(?i)'\s*;\s*--").expect("valid regex"));

    let mut findings = Vec::new();

    for m in RE_DOUBLE_DASH.find_iter(sql) {
        findings.push(threat(
            SafetyRule::CommentInjection,
            SafetySeverity::High,
            "Comment injection detected (double-dash)",
            "A string literal followed by a double-dash comment was detected. \
             This pattern is used to truncate the rest of a SQL query.",
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }

    for m in RE_BLOCK_COMMENT.find_iter(sql) {
        let already_found = findings
            .iter()
            .any(|f| f.location.is_some_and(|(start, _)| start == m.start()));
        if !already_found {
            findings.push(threat(
                SafetyRule::CommentInjection,
                SafetySeverity::High,
                "Comment injection detected (block comment)",
                "A string literal followed by a block comment was detected. \
                 This pattern is used to alter query logic.",
                Some((m.start(), m.len())),
                m.as_str(),
            ));
        }
    }

    for m in RE_TRUNCATION.find_iter(sql) {
        let already_found = findings
            .iter()
            .any(|f| f.location.is_some_and(|(start, _)| start == m.start()));
        if !already_found {
            findings.push(threat(
                SafetyRule::CommentInjection,
                SafetySeverity::Critical,
                "SQL truncation via comment injection",
                "A classic query truncation pattern (';--) was detected.",
                Some((m.start(), m.len())),
                m.as_str(),
            ));
        }
    }

    findings
}

/// Detect transaction escape: COMMIT, ROLLBACK, SAVEPOINT outside of normal flow.
pub fn check_transaction_escape(sql: &str) -> Vec<ThreatFinding> {
    static RE: LazyLock<Regex> = LazyLock::new(|| {
        Regex::new(r"(?i)\b(COMMIT|ROLLBACK|SAVEPOINT|BEGIN\s+TRANSACTION|START\s+TRANSACTION)\b")
            .expect("valid regex")
    });

    let mut findings = Vec::new();
    let lower = strip_string_literals(sql);
    for m in RE.find_iter(&lower) {
        findings.push(threat(
            SafetyRule::TransactionEscape,
            SafetySeverity::Critical,
            "Transaction control statement detected",
            format!(
                "Transaction control statement '{}' detected. \
                 This can break read-only transaction boundaries and enable data modification.",
                m.as_str().trim()
            ),
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }
    findings
}

/// Detect stacked queries: multiple complete SQL statements separated by `;`.
pub fn check_stacked_queries(sql: &str) -> Vec<ThreatFinding> {
    let stripped = strip_string_literals(sql);
    let statements = count_statements(&stripped);

    let mut findings = Vec::new();
    if statements > 1 {
        findings.push(threat(
            SafetyRule::StackedQueries,
            SafetySeverity::High,
            format!("Stacked queries detected: {statements} statements found"),
            "Multiple SQL statements were detected in a single input. \
             Stacked queries are a common SQL injection technique to execute \
             arbitrary commands.",
            None,
            format!("{statements} statements"),
        ));
    }
    findings
}

/// Detect tautology attacks: OR 1=1, OR 'a'='a', OR TRUE, etc.
pub fn check_tautology_attack(sql: &str) -> Vec<ThreatFinding> {
    // Common numeric tautologies: OR 1=1, OR 2=2, etc. (no backrefs in regex crate)
    static RE_NUMERIC: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"(?i)\bOR\s+(\d+)\s*=\s*(\d+)\b").expect("valid regex"));

    // Common string tautologies: OR 'x'='x', etc.
    static RE_STRING: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"(?i)\bOR\s+'([^']+)'\s*=\s*'([^']+)'").expect("valid regex"));

    static RE_TRUE: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"(?i)\bOR\s+(TRUE|1)\b").expect("valid regex"));

    static RE_NOT_FALSE: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"(?i)\bOR\s+NOT\s+FALSE\b").expect("valid regex"));

    let mut findings = Vec::new();

    // Check numeric tautologies — verify both sides are equal
    for caps in RE_NUMERIC.captures_iter(sql) {
        let full = caps.get(0).expect("full match");
        let left = caps.get(1).expect("left side");
        let right = caps.get(2).expect("right side");
        if left.as_str() == right.as_str() {
            findings.push(threat(
                SafetyRule::TautologyAttack,
                SafetySeverity::High,
                "Tautology attack detected (numeric equality)",
                "An always-true condition like OR 1=1 was detected, \
                 which bypasses authentication and WHERE clause filters.",
                Some((full.start(), full.len())),
                full.as_str(),
            ));
        }
    }

    // Check string tautologies — verify both sides are equal
    for caps in RE_STRING.captures_iter(sql) {
        let full = caps.get(0).expect("full match");
        let left = caps.get(1).expect("left side");
        let right = caps.get(2).expect("right side");
        if left.as_str() == right.as_str() {
            let already_found = findings
                .iter()
                .any(|f| f.location.is_some_and(|(start, _)| start == full.start()));
            if !already_found {
                findings.push(threat(
                    SafetyRule::TautologyAttack,
                    SafetySeverity::High,
                    "Tautology attack detected (string equality)",
                    "An always-true condition like OR 'a'='a' was detected, \
                     which bypasses WHERE clause filters.",
                    Some((full.start(), full.len())),
                    full.as_str(),
                ));
            }
        }
    }

    for m in RE_TRUE.find_iter(sql) {
        let already_found = findings
            .iter()
            .any(|f| f.location.is_some_and(|(start, _)| start == m.start()));
        if !already_found {
            findings.push(threat(
                SafetyRule::TautologyAttack,
                SafetySeverity::High,
                "Tautology attack detected (boolean literal)",
                "An always-true condition 'OR TRUE' or 'OR 1' was detected.",
                Some((m.start(), m.len())),
                m.as_str(),
            ));
        }
    }

    for m in RE_NOT_FALSE.find_iter(sql) {
        let already_found = findings
            .iter()
            .any(|f| f.location.is_some_and(|(start, _)| start == m.start()));
        if !already_found {
            findings.push(threat(
                SafetyRule::TautologyAttack,
                SafetySeverity::High,
                "Tautology attack detected (NOT FALSE)",
                "An always-true condition 'OR NOT FALSE' was detected.",
                Some((m.start(), m.len())),
                m.as_str(),
            ));
        }
    }

    findings
}

/// Detect time-based blind injection: SLEEP(), BENCHMARK(), WAITFOR, pg_sleep().
pub fn check_sleep_attack(sql: &str) -> Vec<ThreatFinding> {
    static RE: LazyLock<Regex> = LazyLock::new(|| {
        Regex::new(
            r"(?i)\b(SLEEP|BENCHMARK|PG_SLEEP|DBMS_LOCK\.SLEEP)\s*\(|(?i)\bWAITFOR\s+(DELAY|TIME)\b"
        )
        .expect("valid regex")
    });

    let mut findings = Vec::new();
    let lower = strip_string_literals(sql);
    for m in RE.find_iter(&lower) {
        findings.push(threat(
            SafetyRule::SleepAttack,
            SafetySeverity::Critical,
            "Time-based blind injection detected",
            format!(
                "Time-delay function '{}' detected. This is used in blind SQL injection \
                 to infer data by measuring response times.",
                m.as_str().trim()
            ),
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }
    findings
}

/// Detect information schema probing: queries against system catalogs.
pub fn check_info_schema_probe(sql: &str) -> Vec<ThreatFinding> {
    static RE: LazyLock<Regex> = LazyLock::new(|| {
        Regex::new(
            r"(?i)\b(INFORMATION_SCHEMA|PG_CATALOG|PG_TABLES|PG_COLUMNS|SYS\.TABLES|SYS\.COLUMNS|SYS\.OBJECTS|SYSOBJECTS|SYSCOLUMNS|ALL_TABLES|ALL_TAB_COLUMNS|DBA_TABLES|USER_TABLES|MYSQL\.USER|SQLITE_MASTER|SQLITE_SCHEMA)\b"
        )
        .expect("valid regex")
    });

    // Snowflake-specific
    static RE_SNOWFLAKE: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"(?i)\bSYSTEM\$\w+").expect("valid regex"));

    // Databricks / general probing
    static RE_PROBE: LazyLock<Regex> = LazyLock::new(|| {
        Regex::new(r"(?i)\b(SHOW\s+TABLES|SHOW\s+DATABASES|SHOW\s+COLUMNS|DESCRIBE\s+TABLE|DESC\s+TABLE)\b").expect("valid regex")
    });

    let mut findings = Vec::new();
    let lower = strip_string_literals(sql);

    for m in RE.find_iter(&lower) {
        findings.push(threat(
            SafetyRule::InfoSchemaProbe,
            SafetySeverity::High,
            "Information schema probing detected",
            format!(
                "Access to system catalog '{}' detected. This is used to enumerate \
                 database structure for further exploitation.",
                m.as_str().trim()
            ),
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }

    for m in RE_SNOWFLAKE.find_iter(&lower) {
        findings.push(threat(
            SafetyRule::InfoSchemaProbe,
            SafetySeverity::High,
            "Snowflake system function probing detected",
            format!(
                "Snowflake SYSTEM$ function '{}' detected. These functions expose \
                 internal system information.",
                m.as_str().trim()
            ),
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }

    for m in RE_PROBE.find_iter(&lower) {
        findings.push(threat(
            SafetyRule::InfoSchemaProbe,
            SafetySeverity::Medium,
            "Database schema probing detected",
            format!(
                "Schema enumeration command '{}' detected. This may be used to \
                 discover database structure.",
                m.as_str().trim()
            ),
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }

    findings
}

/// Detect string concatenation in predicates.
pub fn check_string_concat_predicate(sql: &str) -> Vec<ThreatFinding> {
    // Detect || concatenation in WHERE context
    static RE_PIPE: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"(?i)WHERE\s+.*?'\s*\|\|\s*'").expect("valid regex"));

    // Detect + concatenation in WHERE context
    static RE_PLUS: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"(?i)WHERE\s+.*?'\s*\+\s*'").expect("valid regex"));

    // Detect CONCAT() in WHERE context
    static RE_CONCAT: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"(?i)WHERE\s+.*?\bCONCAT\s*\(").expect("valid regex"));

    let mut findings = Vec::new();

    for m in RE_PIPE.find_iter(sql) {
        findings.push(threat(
            SafetyRule::StringConcatPredicate,
            SafetySeverity::Medium,
            "String concatenation in predicate (||)",
            "String concatenation using || in a WHERE clause was detected. \
             This can be used to construct dynamic values that bypass filters.",
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }

    for m in RE_PLUS.find_iter(sql) {
        findings.push(threat(
            SafetyRule::StringConcatPredicate,
            SafetySeverity::Medium,
            "String concatenation in predicate (+)",
            "String concatenation using + in a WHERE clause was detected. \
             This can be used to construct dynamic values.",
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }

    for m in RE_CONCAT.find_iter(sql) {
        findings.push(threat(
            SafetyRule::StringConcatPredicate,
            SafetySeverity::Medium,
            "CONCAT() in predicate",
            "CONCAT() function in a WHERE clause was detected. \
             This can be used to construct dynamic filter values.",
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }

    findings
}

/// Detect hex/char encoding bypass attempts.
pub fn check_encoding_bypass(sql: &str) -> Vec<ThreatFinding> {
    // CHAR() / CHR() functions used to build strings
    static RE_CHAR: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"(?i)\b(CHAR|CHR)\s*\(\s*\d+").expect("valid regex"));

    // Hex literals: 0x followed by hex digits
    static RE_HEX: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"(?i)\b0x[0-9a-f]{2,}\b").expect("valid regex"));

    // URL-encoded patterns (%27 = ', %3B = ;, etc.)
    static RE_URL_ENCODED: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"(?i)%27|%23|%3B|%2D%2D|%00").expect("valid regex"));

    let mut findings = Vec::new();
    let stripped = strip_string_literals(sql);

    for m in RE_CHAR.find_iter(&stripped) {
        findings.push(threat(
            SafetyRule::EncodingBypass,
            SafetySeverity::High,
            "Character encoding bypass detected",
            format!(
                "CHAR/CHR function '{}' detected. This is used to build malicious \
                 strings character-by-character to bypass input filters.",
                m.as_str()
            ),
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }

    for m in RE_HEX.find_iter(&stripped) {
        findings.push(threat(
            SafetyRule::EncodingBypass,
            SafetySeverity::Medium,
            "Hex encoding detected",
            format!(
                "Hex-encoded value '{}' detected. Hex encoding can be used to \
                 represent SQL keywords or string delimiters.",
                m.as_str()
            ),
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }

    for m in RE_URL_ENCODED.find_iter(sql) {
        findings.push(threat(
            SafetyRule::EncodingBypass,
            SafetySeverity::High,
            "URL-encoded SQL injection pattern detected",
            format!(
                "URL-encoded character '{}' detected. Common in web-based \
                 SQL injection attacks.",
                m.as_str()
            ),
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }

    findings
}

/// Detect dbt Jinja template injection patterns in raw SQL.
pub fn check_jinja_injection(sql: &str) -> Vec<ThreatFinding> {
    static RE_JINJA_EXPR: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"\{\{.*?\}\}").expect("valid regex"));

    static RE_JINJA_STMT: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"\{%.*?%\}").expect("valid regex"));

    let mut findings = Vec::new();

    for m in RE_JINJA_EXPR.find_iter(sql) {
        findings.push(threat(
            SafetyRule::EncodingBypass,
            SafetySeverity::High,
            "Jinja template expression in SQL",
            format!(
                "Jinja expression '{}' detected in raw SQL. Unprocessed Jinja templates \
                 in SQL can lead to template injection attacks.",
                m.as_str()
            ),
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }

    for m in RE_JINJA_STMT.find_iter(sql) {
        findings.push(threat(
            SafetyRule::EncodingBypass,
            SafetySeverity::Critical,
            "Jinja template statement in SQL",
            format!(
                "Jinja statement '{}' detected in raw SQL. This can execute \
                 arbitrary template logic.",
                m.as_str()
            ),
            Some((m.start(), m.len())),
            m.as_str(),
        ));
    }

    findings
}

/// Strip string literals from SQL to avoid false positives from string content.
/// Replaces content between single quotes with spaces of the same length.
pub(super) fn strip_string_literals(sql: &str) -> String {
    let mut result = String::with_capacity(sql.len());
    let mut in_string = false;
    let mut chars = sql.chars().peekable();

    while let Some(c) = chars.next() {
        if c == '\'' && !in_string {
            in_string = true;
            result.push(' ');
        } else if c == '\'' && in_string {
            // Check for escaped quote ('')
            if chars.peek() == Some(&'\'') {
                chars.next();
                result.push(' ');
                result.push(' ');
            } else {
                in_string = false;
                result.push(' ');
            }
        } else if in_string {
            result.push(' ');
        } else {
            result.push(c);
        }
    }

    result
}

/// Count the number of SQL statements by splitting on `;` outside of strings.
/// Ignores trailing semicolons and empty statements.
fn count_statements(stripped_sql: &str) -> usize {
    stripped_sql
        .split(';')
        .filter(|s| {
            let trimmed = s.trim();
            !trimmed.is_empty()
        })
        .count()
}

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

    // =========================================================================
    // strip_string_literals tests
    // =========================================================================

    #[test]
    fn test_strip_string_literals_basic() {
        let result = strip_string_literals("SELECT * FROM users WHERE name = 'admin'");
        assert!(!result.contains("admin"));
    }

    #[test]
    fn test_strip_string_literals_escaped_quote() {
        let result = strip_string_literals("SELECT * FROM users WHERE name = 'it''s'");
        assert!(!result.contains("it"));
    }

    #[test]
    fn test_strip_string_literals_no_strings() {
        let sql = "SELECT id FROM users WHERE id = 1";
        assert_eq!(strip_string_literals(sql), sql);
    }

    // =========================================================================
    // count_statements tests
    // =========================================================================

    #[test]
    fn test_count_statements_single() {
        assert_eq!(count_statements("SELECT 1"), 1);
    }

    #[test]
    fn test_count_statements_with_trailing_semi() {
        assert_eq!(count_statements("SELECT 1;"), 1);
    }

    #[test]
    fn test_count_statements_multiple() {
        assert_eq!(count_statements("SELECT 1; SELECT 2"), 2);
    }

    #[test]
    fn test_count_statements_empty() {
        assert_eq!(count_statements(""), 0);
    }

    // =========================================================================
    // Multi-statement injection tests
    // =========================================================================

    #[test]
    fn test_multi_statement_drop_table() {
        let findings = check_multi_statement("SELECT 1; DROP TABLE users");
        assert!(!findings.is_empty());
        assert_eq!(findings[0].rule, SafetyRule::MultiStatement);
        assert_eq!(findings[0].severity, SafetySeverity::Critical);
    }

    #[test]
    fn test_multi_statement_delete() {
        let findings = check_multi_statement("SELECT 1; DELETE FROM users");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_multi_statement_update() {
        let findings = check_multi_statement("SELECT 1; UPDATE users SET role='admin'");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_multi_statement_insert() {
        let findings = check_multi_statement("SELECT 1; INSERT INTO users VALUES (1)");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_multi_statement_grant() {
        let findings = check_multi_statement("SELECT 1; GRANT ALL ON users TO public");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_multi_statement_alter() {
        let findings = check_multi_statement("SELECT 1; ALTER TABLE users ADD col INT");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_multi_statement_create() {
        let findings = check_multi_statement("SELECT 1; CREATE TABLE evil(id INT)");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_multi_statement_truncate() {
        let findings = check_multi_statement("SELECT 1; TRUNCATE TABLE users");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_multi_statement_exec() {
        let findings = check_multi_statement("SELECT 1; EXEC xp_cmdshell 'dir'");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_multi_statement_execute() {
        let findings = check_multi_statement("SELECT 1; EXECUTE sp_configure");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_multi_statement_clean_select() {
        let findings = check_multi_statement("SELECT id, name FROM users WHERE id = 1");
        assert!(findings.is_empty());
    }

    #[test]
    fn test_multi_statement_semicolon_in_string() {
        // Semicolons inside string literals should NOT trigger
        let findings = check_multi_statement("SELECT * FROM t WHERE val = '; DROP TABLE x'");
        assert!(findings.is_empty());
    }

    #[test]
    fn test_multi_statement_revoke() {
        let findings = check_multi_statement("SELECT 1; REVOKE ALL ON users FROM public");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_multi_statement_merge() {
        let findings =
            check_multi_statement("SELECT 1; MERGE INTO target USING source ON t.id = s.id");
        assert!(!findings.is_empty());
    }

    // =========================================================================
    // UNION injection tests
    // =========================================================================

    #[test]
    fn test_union_select() {
        let findings =
            check_union_injection("SELECT id FROM users UNION SELECT password FROM admin");
        assert!(!findings.is_empty());
        assert_eq!(findings[0].rule, SafetyRule::UnionInjection);
    }

    #[test]
    fn test_union_all_select() {
        let findings = check_union_injection("SELECT 1 UNION ALL SELECT 2");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_union_obfuscated_with_comment() {
        let findings = check_union_injection("SELECT 1 UNION/**/SELECT 2");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_union_case_insensitive() {
        let findings = check_union_injection("select 1 union select 2");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_no_union_injection_in_clean_sql() {
        let findings = check_union_injection("SELECT id FROM users WHERE name = 'test'");
        assert!(findings.is_empty());
    }

    // =========================================================================
    // Comment injection tests
    // =========================================================================

    #[test]
    fn test_comment_injection_double_dash() {
        let findings = check_comment_injection("SELECT * FROM users WHERE name = 'admin' --");
        assert!(!findings.is_empty());
        assert_eq!(findings[0].rule, SafetyRule::CommentInjection);
    }

    #[test]
    fn test_comment_injection_block() {
        let findings =
            check_comment_injection("SELECT * FROM users WHERE name = 'admin' /* rest */");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_comment_injection_truncation() {
        let findings = check_comment_injection("SELECT * FROM users WHERE name = ''; --");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_no_comment_injection_clean() {
        let findings = check_comment_injection("SELECT id FROM users WHERE id = 1");
        assert!(findings.is_empty());
    }

    // =========================================================================
    // Transaction escape tests
    // =========================================================================

    #[test]
    fn test_transaction_commit() {
        let findings = check_transaction_escape("SELECT 1; COMMIT");
        assert!(!findings.is_empty());
        assert_eq!(findings[0].rule, SafetyRule::TransactionEscape);
        assert_eq!(findings[0].severity, SafetySeverity::Critical);
    }

    #[test]
    fn test_transaction_rollback() {
        let findings = check_transaction_escape("ROLLBACK");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_transaction_savepoint() {
        let findings = check_transaction_escape("SAVEPOINT sp1");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_transaction_begin() {
        let findings = check_transaction_escape("BEGIN TRANSACTION");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_transaction_start() {
        let findings = check_transaction_escape("START TRANSACTION");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_no_transaction_escape_clean() {
        let findings = check_transaction_escape("SELECT id FROM users");
        assert!(findings.is_empty());
    }

    #[test]
    fn test_transaction_in_string_literal() {
        let findings = check_transaction_escape("SELECT * FROM t WHERE val = 'COMMIT'");
        assert!(findings.is_empty());
    }

    // =========================================================================
    // Stacked queries tests
    // =========================================================================

    #[test]
    fn test_stacked_queries_two() {
        let findings = check_stacked_queries("SELECT 1; SELECT 2");
        assert!(!findings.is_empty());
        assert_eq!(findings[0].rule, SafetyRule::StackedQueries);
    }

    #[test]
    fn test_stacked_queries_three() {
        let findings = check_stacked_queries("SELECT 1; SELECT 2; SELECT 3");
        assert!(!findings.is_empty());
        assert!(findings[0].message.contains("3"));
    }

    #[test]
    fn test_no_stacked_queries_single() {
        let findings = check_stacked_queries("SELECT * FROM users WHERE id = 1");
        assert!(findings.is_empty());
    }

    #[test]
    fn test_no_stacked_queries_trailing_semi() {
        let findings = check_stacked_queries("SELECT 1;");
        assert!(findings.is_empty());
    }

    // =========================================================================
    // Tautology attack tests
    // =========================================================================

    #[test]
    fn test_tautology_or_1_equals_1() {
        let findings = check_tautology_attack("SELECT * FROM users WHERE id = 1 OR 1=1");
        assert!(!findings.is_empty());
        assert_eq!(findings[0].rule, SafetyRule::TautologyAttack);
    }

    #[test]
    fn test_tautology_or_string_equals() {
        let findings = check_tautology_attack("SELECT * FROM users WHERE name = 'x' OR 'a'='a'");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_tautology_or_true() {
        let findings = check_tautology_attack("SELECT * FROM users WHERE id = 1 OR TRUE");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_tautology_or_1() {
        let findings = check_tautology_attack("SELECT * FROM users WHERE id = 1 OR 1");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_tautology_or_not_false() {
        let findings = check_tautology_attack("SELECT * FROM users WHERE id = 1 OR NOT FALSE");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_tautology_mixed_case() {
        let findings = check_tautology_attack("SELECT * FROM users WHERE id = 1 or 1=1");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_no_tautology_normal_or() {
        let findings = check_tautology_attack("SELECT * FROM users WHERE id = 1 OR id = 2");
        assert!(findings.is_empty());
    }

    #[test]
    fn test_tautology_2_equals_2() {
        let findings = check_tautology_attack("SELECT * FROM users WHERE 1=0 OR 2=2");
        assert!(!findings.is_empty());
    }

    // =========================================================================
    // Sleep/benchmark attack tests
    // =========================================================================

    #[test]
    fn test_sleep_mysql() {
        let findings = check_sleep_attack("SELECT * FROM users WHERE id = 1 AND SLEEP(5)");
        assert!(!findings.is_empty());
        assert_eq!(findings[0].rule, SafetyRule::SleepAttack);
        assert_eq!(findings[0].severity, SafetySeverity::Critical);
    }

    #[test]
    fn test_sleep_benchmark() {
        let findings = check_sleep_attack("SELECT BENCHMARK(1000000, SHA1('test'))");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_sleep_pg_sleep() {
        let findings = check_sleep_attack("SELECT pg_sleep(10)");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_sleep_waitfor_delay() {
        let findings = check_sleep_attack("SELECT 1; WAITFOR DELAY '00:00:05'");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_sleep_waitfor_time() {
        let findings = check_sleep_attack("WAITFOR TIME '23:00:00'");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_sleep_dbms_lock() {
        let findings = check_sleep_attack("SELECT DBMS_LOCK.SLEEP(10) FROM dual");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_no_sleep_in_clean_sql() {
        let findings = check_sleep_attack("SELECT id, sleep_duration FROM logs");
        assert!(findings.is_empty());
    }

    #[test]
    fn test_sleep_in_string_literal() {
        let findings = check_sleep_attack("SELECT * FROM t WHERE val = 'SLEEP(5)'");
        assert!(findings.is_empty());
    }

    // =========================================================================
    // Information schema probe tests
    // =========================================================================

    #[test]
    fn test_info_schema_basic() {
        let findings = check_info_schema_probe("SELECT * FROM information_schema.tables");
        assert!(!findings.is_empty());
        assert_eq!(findings[0].rule, SafetyRule::InfoSchemaProbe);
    }

    #[test]
    fn test_info_schema_pg_catalog() {
        let findings = check_info_schema_probe("SELECT * FROM pg_catalog.pg_tables");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_info_schema_sys_tables() {
        let findings = check_info_schema_probe("SELECT * FROM sys.tables");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_info_schema_sqlite_master() {
        let findings = check_info_schema_probe("SELECT * FROM sqlite_master");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_info_schema_mysql_user() {
        let findings = check_info_schema_probe("SELECT * FROM mysql.user");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_info_schema_oracle_all_tables() {
        let findings = check_info_schema_probe("SELECT * FROM all_tables");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_info_schema_snowflake_system() {
        let findings = check_info_schema_probe("SELECT SYSTEM$CURRENT_USER()");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_info_schema_show_tables() {
        let findings = check_info_schema_probe("SHOW TABLES");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_info_schema_show_databases() {
        let findings = check_info_schema_probe("SHOW DATABASES");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_info_schema_describe_table() {
        let findings = check_info_schema_probe("DESCRIBE TABLE users");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_no_info_schema_clean() {
        let findings = check_info_schema_probe("SELECT id FROM users WHERE id = 1");
        assert!(findings.is_empty());
    }

    #[test]
    fn test_info_schema_in_string_literal() {
        let findings = check_info_schema_probe("SELECT * FROM t WHERE val = 'information_schema'");
        assert!(findings.is_empty());
    }

    // =========================================================================
    // String concatenation in predicate tests
    // =========================================================================

    #[test]
    fn test_concat_pipe_in_where() {
        let findings = check_string_concat_predicate("SELECT * FROM users WHERE name = 'a' || 'b'");
        assert!(!findings.is_empty());
        assert_eq!(findings[0].rule, SafetyRule::StringConcatPredicate);
    }

    #[test]
    fn test_concat_plus_in_where() {
        let findings = check_string_concat_predicate("SELECT * FROM users WHERE name = 'a' + 'b'");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_concat_function_in_where() {
        let findings =
            check_string_concat_predicate("SELECT * FROM users WHERE name = CONCAT('a', 'b')");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_no_concat_in_select() {
        let findings = check_string_concat_predicate("SELECT CONCAT(first, last) FROM users");
        assert!(findings.is_empty());
    }

    #[test]
    fn test_no_concat_normal_where() {
        let findings =
            check_string_concat_predicate("SELECT * FROM users WHERE id = 1 AND name = 'test'");
        assert!(findings.is_empty());
    }

    // =========================================================================
    // Encoding bypass tests
    // =========================================================================

    #[test]
    fn test_encoding_char_function() {
        let findings = check_encoding_bypass("SELECT CHAR(65)");
        assert!(!findings.is_empty());
        assert_eq!(findings[0].rule, SafetyRule::EncodingBypass);
    }

    #[test]
    fn test_encoding_chr_function() {
        let findings = check_encoding_bypass("SELECT CHR(65)");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_encoding_hex_literal() {
        let findings = check_encoding_bypass("SELECT * FROM users WHERE id = 0x61646D696E");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_encoding_url_encoded_quote() {
        let findings = check_encoding_bypass("SELECT * FROM users WHERE name = %27admin%27");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_encoding_url_encoded_semicolon() {
        let findings = check_encoding_bypass("SELECT 1%3BDROP TABLE users");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_encoding_url_encoded_dash() {
        let findings = check_encoding_bypass("SELECT * FROM users%2D%2D");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_encoding_url_null_byte() {
        let findings = check_encoding_bypass("SELECT * FROM users%00");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_no_encoding_clean() {
        let findings = check_encoding_bypass("SELECT id FROM users WHERE id = 1");
        assert!(findings.is_empty());
    }

    #[test]
    fn test_encoding_char_in_string_literal() {
        let findings = check_encoding_bypass("SELECT * FROM t WHERE val = 'CHAR(65)'");
        assert!(findings.is_empty());
    }

    // =========================================================================
    // Jinja injection tests
    // =========================================================================

    #[test]
    fn test_jinja_expression() {
        let findings = check_jinja_injection("SELECT * FROM {{ ref('users') }}");
        assert!(!findings.is_empty());
    }

    #[test]
    fn test_jinja_statement() {
        let findings = check_jinja_injection("{% if true %}SELECT 1{% endif %}");
        assert!(!findings.is_empty());
        assert_eq!(findings[0].severity, SafetySeverity::Critical);
    }

    #[test]
    fn test_no_jinja_clean() {
        let findings = check_jinja_injection("SELECT id FROM users WHERE id = 1");
        assert!(findings.is_empty());
    }
}
