use crate::lint::{Hit, Rule, Severity};
use crate::visit;

use full_moon::visitors::Visitor;

pub struct GetfenvSetfenv;
pub struct DynamicRequire;
pub struct CoroutineInNative;
pub struct MathHugeComparison;
pub struct VarargInNative;
pub struct StringPatternInNative;
pub struct LoadstringDeopt;
pub struct UntypedParams;
pub struct HeavyApiScript;
pub struct LargeTableLiteral;
pub struct MixedComputationApi;
pub struct GlobalWrite;
pub struct ShadowedBuiltin;
pub struct TableZeroIndex;
pub struct MethodCallDefeatsFastcall;
pub struct SharedGlobalMutation;
pub struct ImportChainTooDeep;
pub struct PcallInNative;
pub struct DynamicTableKeyInNative;

impl Rule for GetfenvSetfenv {
    fn id(&self) -> &'static str { "native::getfenv_setfenv" }
    fn severity(&self) -> Severity { Severity::Error }

    fn check(&self, _source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let mut hits = Vec::new();
        visit::each_call(ast, |call, _ctx| {
            if visit::is_bare_call(call, "getfenv") || visit::is_bare_call(call, "setfenv") {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "getfenv/setfenv disables ALL optimizations for the entire script (GETIMPORT, FASTCALL, DUPCLOSURE, native codegen)".into(),
                });
            }
        });
        hits
    }
}

impl Rule for DynamicRequire {
    fn id(&self) -> &'static str { "native::dynamic_require" }
    fn severity(&self) -> Severity { Severity::Warn }

    fn check(&self, _source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let mut hits = Vec::new();
        visit::each_call(ast, |call, _ctx| {
            if !visit::is_bare_call(call, "require") {
                return;
            }
            if let Some(arg) = visit::nth_arg(call, 0) {
                let s = format!("{arg}");
                let trimmed = s.trim();
                if trimmed.contains('[') {
                    hits.push(Hit {
                        pos: visit::call_pos(call),
                        msg: "dynamic require() with bracket indexing - prevents static analysis and GETIMPORT".into(),
                    });
                }
            }
        });
        hits
    }
}

impl Rule for CoroutineInNative {
    fn id(&self) -> &'static str { "native::coroutine_in_native" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        if !source.contains("--!native") {
            return vec![];
        }

        let mut hits = Vec::new();
        visit::each_call(ast, |call, _ctx| {
            let is_coroutine = visit::is_dot_call(call, "coroutine", "wrap")
                || visit::is_dot_call(call, "coroutine", "create")
                || visit::is_dot_call(call, "coroutine", "yield")
                || visit::is_dot_call(call, "coroutine", "resume");
            if is_coroutine {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "coroutine usage in --!native script - coroutines force interpreter fallback, no native codegen benefit".into(),
                });
            }
        });
        hits
    }
}

impl Rule for MathHugeComparison {
    fn id(&self) -> &'static str { "native::math_huge_comparison" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let mut hits = Vec::new();
        for pos in visit::find_pattern_positions(source, "== math.huge") {
            hits.push(Hit {
                pos,
                msg: "comparing to math.huge - use x ~= x to check for NaN, or x == 1/0 for infinity".into(),
            });
        }
        for pos in visit::find_pattern_positions(source, "~= math.huge") {
            hits.push(Hit {
                pos,
                msg: "comparing to math.huge - use x == x to check for non-NaN, or x ~= 1/0 for finite".into(),
            });
        }
        hits
    }
}

impl Rule for VarargInNative {
    fn id(&self) -> &'static str { "native::vararg_in_native" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        if !source.contains("--!native") {
            return vec![];
        }

        let mut hits = Vec::new();
        visit::each_call(ast, |call, ctx| {
            if ctx.in_loop && visit::is_bare_call(call, "select") {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "select() in loop in --!native script - vararg access prevents some native optimizations".into(),
                });
            }
        });
        hits
    }
}

impl Rule for StringPatternInNative {
    fn id(&self) -> &'static str { "native::string_pattern_in_native" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        if !source.contains("--!native") {
            return vec![];
        }

        let mut hits = Vec::new();
        visit::each_call(ast, |call, ctx| {
            if !ctx.in_loop {
                return;
            }
            let is_pattern = visit::is_dot_call(call, "string", "match")
                || visit::is_dot_call(call, "string", "gmatch")
                || visit::is_dot_call(call, "string", "gsub")
                || visit::is_dot_call(call, "string", "find")
                || visit::is_method_call(call, "match")
                || visit::is_method_call(call, "gmatch")
                || visit::is_method_call(call, "gsub");
            if is_pattern {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "string pattern matching in hot loop in --!native - pattern functions run in interpreter, not native".into(),
                });
            }
        });
        hits
    }
}

impl Rule for LoadstringDeopt {
    fn id(&self) -> &'static str { "native::loadstring_deopt" }
    fn severity(&self) -> Severity { Severity::Error }

    fn check(&self, _source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let mut hits = Vec::new();
        visit::each_call(ast, |call, _ctx| {
            if visit::is_bare_call(call, "loadstring") {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "loadstring() disables ALL optimizations for the entire script (GETIMPORT, FASTCALL, DUPCLOSURE, native codegen)".into(),
                });
            }
        });
        hits
    }
}

impl Rule for UntypedParams {
    fn id(&self) -> &'static str { "native::untyped_params" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        if !source.contains("--!native") {
            return vec![];
        }

        struct ParamWalker { hits: Vec<Hit> }
        impl Visitor for ParamWalker {
            fn visit_function_body(&mut self, node: &full_moon::ast::FunctionBody) {
                let param_count = node.parameters().iter().count();
                if param_count == 0 {
                    return;
                }
                let untyped = node.type_specifiers().filter(|s| s.is_none()).count();
                if untyped > 0 && untyped == param_count {
                    let (open, _) = node.parameters_parentheses().tokens();
                    let pos = open.start_position().bytes();
                    self.hits.push(Hit {
                        pos,
                        msg: "function params without type annotations in --!native - prevents specialization (esp. Vector3 SIMD)".into(),
                    });
                }
            }
        }

        let mut w = ParamWalker { hits: Vec::new() };
        w.visit_ast(ast);
        w.hits
    }
}

impl Rule for HeavyApiScript {
    fn id(&self) -> &'static str { "native::heavy_api_script" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        if !source.contains("--!native") {
            return vec![];
        }

        let mut api_calls = 0u32;
        let mut total_calls = 0u32;
        visit::each_call(ast, |call, _ctx| {
            total_calls += 1;
            if visit::is_method_call(call, "GetService")
                || visit::is_method_call(call, "FindFirstChild")
                || visit::is_method_call(call, "WaitForChild")
                || visit::is_method_call(call, "Clone")
                || visit::is_method_call(call, "Destroy")
                || visit::is_method_call(call, "Connect")
                || visit::is_method_call(call, "Fire")
                || visit::is_method_call(call, "FireServer")
                || visit::is_method_call(call, "FireClient")
                || visit::is_method_call(call, "InvokeServer")
                || visit::is_method_call(call, "SetAttribute")
                || visit::is_method_call(call, "GetAttribute")
                || visit::is_method_call(call, "GetChildren")
                || visit::is_method_call(call, "GetDescendants")
                || visit::is_dot_call(call, "Instance", "new")
            {
                api_calls += 1;
            }
        });

        if total_calls >= 5 && api_calls as f64 / total_calls as f64 > 0.7 {
            return vec![Hit {
                pos: 0,
                msg: "--!native on API-heavy script - native codegen benefits computation, not Roblox API bridge calls".into(),
            }];
        }
        vec![]
    }
}

impl Rule for LargeTableLiteral {
    fn id(&self) -> &'static str { "native::large_table_literal" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        if !source.contains("--!native") {
            return vec![];
        }

        struct TableWalker { hits: Vec<Hit> }
        impl Visitor for TableWalker {
            fn visit_table_constructor(&mut self, node: &full_moon::ast::TableConstructor) {
                let count = node.fields().into_iter().count();
                if count > 50 {
                    let (open, _) = node.braces().tokens();
                    let pos = open.start_position().bytes();
                    self.hits.push(Hit {
                        pos,
                        msg: format!("table literal with {count} entries in --!native - wastes native compilation memory on table-creation code"),
                    });
                }
            }
        }

        let mut w = TableWalker { hits: Vec::new() };
        w.visit_ast(ast);
        w.hits
    }
}

impl Rule for MixedComputationApi {
    fn id(&self) -> &'static str { "native::mixed_computation_api" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        if !source.contains("--!native") {
            return vec![];
        }

        struct FuncAnalyzer {
            hits: Vec<Hit>,
        }

        impl Visitor for FuncAnalyzer {
            fn visit_local_function(&mut self, node: &full_moon::ast::LocalFunction) {
                let body = format!("{}", node.body());
                let mut math_ops = 0u32;
                let mut api_ops = 0u32;

                let math_patterns = ["math.", "Vector3", "CFrame", "* ", "+ ", "- ", "/ "];
                let api_patterns = [":GetService(", ":FindFirstChild(", ":WaitForChild(",
                    ":Clone(", ":Destroy(", ":Connect(", ":Fire(", "Instance.new(",
                    ":SetAttribute(", ":GetAttribute(", ":GetChildren(", ":GetDescendants("];

                for p in &math_patterns {
                    math_ops += body.matches(p).count() as u32;
                }
                for p in &api_patterns {
                    api_ops += body.matches(p).count() as u32;
                }

                let total = math_ops + api_ops;
                if total >= 6 && math_ops >= 3 && api_ops >= 3 {
                    let name = visit::tok_text(node.name());
                    let pos = node.name().start_position().bytes();
                    self.hits.push(Hit {
                        pos,
                        msg: format!("function '{name}' mixes computation and API calls in --!native - split so native only compiles computation"),
                    });
                }
            }
        }

        let mut analyzer = FuncAnalyzer { hits: Vec::new() };
        analyzer.visit_ast(ast);
        analyzer.hits
    }
}

impl Rule for GlobalWrite {
    fn id(&self) -> &'static str { "native::global_write" }
    fn severity(&self) -> Severity { Severity::Error }

    fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let mut hits = Vec::new();
        for pos in visit::find_pattern_positions(source, "_G.") {
            let after = &source[pos + 3..];
            if after.contains(" = ") || after.starts_with(|c: char| c.is_alphanumeric()) {
                let line_end = after.find('\n').unwrap_or(after.len());
                let line = &after[..line_end];
                if line.contains(" = ") {
                    hits.push(Hit {
                        pos,
                        msg: "_G write disables safeenv - all GETIMPORT, FASTCALL, and native optimizations are disabled for this script".into(),
                    });
                }
            }
        }
        for pos in visit::find_pattern_positions(source, "_G[") {
            hits.push(Hit {
                pos,
                msg: "_G access disables safeenv - all GETIMPORT, FASTCALL, and native optimizations are disabled for this script".into(),
            });
        }
        hits
    }
}

impl Rule for ShadowedBuiltin {
    fn id(&self) -> &'static str { "native::shadowed_builtin" }
    fn severity(&self) -> Severity { Severity::Warn }

    fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let builtins = [
            "math", "string", "table", "bit32", "buffer", "coroutine", "debug",
            "os", "utf8", "vector", "task",
            "pairs", "ipairs", "next", "select", "type", "typeof",
            "tonumber", "tostring", "error", "assert", "pcall", "xpcall",
            "print", "warn", "require", "setmetatable", "getmetatable",
            "rawget", "rawset", "rawequal", "rawlen", "unpack",
        ];
        let mut hits = Vec::new();
        for builtin in &builtins {
            let pattern = format!("local {builtin} = ");
            for pos in visit::find_pattern_positions(source, &pattern) {
                let after = &source[pos + pattern.len()..];
                let value_end = after.find('\n').unwrap_or(after.len());
                let value = after[..value_end].trim();
                if value == *builtin {
                    continue;
                }
                hits.push(Hit {
                    pos,
                    msg: format!("shadowing builtin '{builtin}' - breaks FASTCALL/GETIMPORT optimizations for '{builtin}' in this scope"),
                });
            }
        }
        hits
    }
}

impl Rule for TableZeroIndex {
    fn id(&self) -> &'static str { "native::table_zero_index" }
    fn severity(&self) -> Severity { Severity::Warn }

    fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let mut hits = Vec::new();
        for pos in visit::find_pattern_positions(source, "[0]") {
            let before = &source[..pos];
            let before_char = before.trim_end().chars().last().unwrap_or(' ');
            if before_char.is_alphanumeric() || before_char == '_' || before_char == ')' || before_char == ']' {
                hits.push(Hit {
                    pos,
                    msg: "t[0] - Luau arrays are 1-based, index 0 is in the hash part (slower) and skipped by ipairs/# operator".into(),
                });
            }
        }
        hits
    }
}

impl Rule for MethodCallDefeatsFastcall {
    fn id(&self) -> &'static str { "native::method_call_defeats_fastcall" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, _source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let fastcall_methods = ["byte", "char", "sub", "len"];
        let mut hits = Vec::new();
        visit::each_call(ast, |call, ctx| {
            if !ctx.in_loop { return; }
            for method in &fastcall_methods {
                if visit::is_method_call(call, method) {
                    let pos = visit::call_pos(call);
                    hits.push(Hit {
                        pos,
                        msg: format!(":{}() in loop defeats FASTCALL - use string.{}() dot syntax for the fast builtin path", method, method),
                    });
                }
            }
        });
        hits
    }
}

impl Rule for SharedGlobalMutation {
    fn id(&self) -> &'static str { "native::shared_global_mutation" }
    fn severity(&self) -> Severity { Severity::Error }

    fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let mut hits = Vec::new();
        for pos in visit::find_pattern_positions(source, "shared.") {
            let after = &source[pos + 7..];
            if after.contains('=') {
                let line_end = after.find('\n').unwrap_or(after.len());
                let line = &after[..line_end];
                if line.contains(" = ") || line.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
                    let before_eq = line.find(" = ");
                    let before_eq2 = line.find("=");
                    let eq_pos = before_eq.or(before_eq2);
                    if let Some(ep) = eq_pos {
                        if !line[..ep].contains("=") || before_eq.is_some() {
                            hits.push(Hit {
                                pos,
                                msg: "writing to shared.* disables GETIMPORT, FASTCALL, and DUPCLOSURE optimizations for the entire script - use a module instead".into(),
                            });
                        }
                    }
                }
            }
        }
        for pos in visit::find_pattern_positions(source, "shared[") {
            let after = &source[pos..];
            let line_end = after.find('\n').unwrap_or(after.len());
            let line = &after[..line_end];
            if line.contains("] =") {
                hits.push(Hit {
                    pos,
                    msg: "writing to shared[] disables GETIMPORT, FASTCALL, and DUPCLOSURE optimizations for the entire script - use a module instead".into(),
                });
            }
        }
        hits
    }
}

impl Rule for ImportChainTooDeep {
    fn id(&self) -> &'static str { "native::import_chain_too_deep" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
        let mut hits = Vec::new();
        let loop_depth = build_loop_depth_map(source);
        let line_starts = line_start_offsets(source);
        let lines: Vec<&str> = source.lines().collect();
        for (i, line) in lines.iter().enumerate() {
            let trimmed = line.trim();
            if i < loop_depth.len() && loop_depth[i] > 0 {
                let dot_count = trimmed.matches('.').count();
                if dot_count >= 4 {
                    let pos = if i < line_starts.len() { line_starts[i] } else { 0 };
                    hits.push(Hit {
                        pos,
                        msg: "deep property chain (4+ dots) in loop - GETIMPORT only caches 3 levels, cache intermediate results in locals".into(),
                    });
                }
            }
        }
        hits
    }
}

fn line_start_offsets(source: &str) -> Vec<usize> {
    let mut starts = vec![0];
    for (i, b) in source.bytes().enumerate() {
        if b == b'\n' { starts.push(i + 1); }
    }
    starts
}

fn build_loop_depth_map(source: &str) -> Vec<u32> {
    let mut depth: u32 = 0;
    let mut depths = Vec::new();
    for line in source.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with("for ") || trimmed.starts_with("while ") || trimmed.starts_with("repeat") {
            depth += 1;
        }
        depths.push(depth);
        if trimmed == "end" || trimmed.starts_with("end ") || trimmed.starts_with("until ") || trimmed == "until" {
            depth = depth.saturating_sub(1);
        }
    }
    depths
}

impl Rule for PcallInNative {
    fn id(&self) -> &'static str { "native::pcall_in_native" }
    fn severity(&self) -> Severity { Severity::Warn }

    fn check(&self, source: &str, ast: &full_moon::ast::Ast) -> Vec<Hit> {
        if !source.starts_with("--!native") {
            return vec![];
        }
        let mut hits = Vec::new();
        visit::each_call(ast, |call, ctx| {
            if ctx.in_loop && (visit::is_bare_call(call, "pcall") || visit::is_bare_call(call, "xpcall")) {
                hits.push(Hit {
                    pos: visit::call_pos(call),
                    msg: "pcall/xpcall in loop in --!native script forces interpreter fallback for the protected call - move error handling outside the loop".into(),
                });
            }
        });
        hits
    }
}

impl Rule for DynamicTableKeyInNative {
    fn id(&self) -> &'static str { "native::dynamic_table_key_in_native" }
    fn severity(&self) -> Severity { Severity::Allow }

    fn check(&self, source: &str, _ast: &full_moon::ast::Ast) -> Vec<Hit> {
        if !source.starts_with("--!native") {
            return vec![];
        }
        let mut hits = Vec::new();
        let loop_depth = build_loop_depth_map(source);
        let line_starts = line_start_offsets(source);
        for pos in visit::find_pattern_positions(source, "[") {
            if pos == 0 { continue; }
            let before = source[..pos].as_bytes();
            let prev = before[before.len() - 1];
            if prev == b'=' || prev == b',' || prev == b'{' || prev == b'(' {
                continue;
            }
            let line = line_starts.partition_point(|&s| s <= pos).saturating_sub(1);
            if line >= loop_depth.len() || loop_depth[line] == 0 {
                continue;
            }
            let after = &source[pos + 1..];
            if let Some(close) = after.find(']') {
                let key = after[..close].trim();
                if !key.starts_with('"') && !key.starts_with('\'') && key.parse::<f64>().is_err() {
                    let line_start = line_starts[line];
                    let line_text = &source[line_start..source[line_start..].find('\n').map(|p| line_start + p).unwrap_or(source.len())];
                    if line_text.contains("t[") || line_text.contains("][") {
                        hits.push(Hit {
                            pos,
                            msg: "dynamic table key t[var] in loop in --!native defeats inline caching - GETTABLEKS (constant key) is much faster".into(),
                        });
                    }
                }
            }
        }
        hits
    }
}

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

    fn parse(src: &str) -> full_moon::ast::Ast {
        full_moon::parse(src).unwrap()
    }

    #[test]
    fn loadstring_detected() {
        let src = "local f = loadstring(code)";
        let ast = parse(src);
        let hits = LoadstringDeopt.check(src, &ast);
        assert_eq!(hits.len(), 1);
        assert!(hits[0].msg.contains("loadstring"));
    }

    #[test]
    fn loadstring_not_method() {
        let src = "local f = obj.loadstring(code)";
        let ast = parse(src);
        let hits = LoadstringDeopt.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn untyped_params_in_native() {
        let src = "--!native\nlocal function foo(x, y)\nend";
        let ast = parse(src);
        let hits = UntypedParams.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn typed_params_not_flagged() {
        let src = "--!native\nlocal function foo(x: number, y: number)\nend";
        let ast = parse(src);
        let hits = UntypedParams.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn untyped_params_no_native_not_flagged() {
        let src = "local function foo(x, y)\nend";
        let ast = parse(src);
        let hits = UntypedParams.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn heavy_api_script_detected() {
        let src = "--!native\ngame:GetService(\"A\")\ngame:GetService(\"B\")\ngame:GetService(\"C\")\ngame:GetService(\"D\")\ngame:GetService(\"E\")";
        let ast = parse(src);
        let hits = HeavyApiScript.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn computation_script_not_flagged() {
        let src = "--!native\nlocal x = math.sqrt(a)\nlocal y = math.abs(b)\nlocal z = a + b * c";
        let ast = parse(src);
        let hits = HeavyApiScript.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn mixed_computation_api_detected() {
        let src = "--!native\nlocal function update()\n  local x = math.sqrt(a) + b * c - d / e\n  game:GetService(\"A\")\n  obj:FindFirstChild(\"B\")\n  obj:Clone()\nend";
        let ast = parse(src);
        let hits = MixedComputationApi.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn pure_computation_not_flagged() {
        let src = "--!native\nlocal function compute()\n  local x = math.sqrt(a) + b * c\n  return x\nend";
        let ast = parse(src);
        let hits = MixedComputationApi.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn no_native_not_flagged() {
        let src = "local function update()\n  local x = math.sqrt(a) + b * c - d / e\n  game:GetService(\"A\")\n  obj:FindFirstChild(\"B\")\n  obj:Clone()\nend";
        let ast = parse(src);
        let hits = MixedComputationApi.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn global_write_detected() {
        let src = "_G.myValue = 42";
        let ast = parse(src);
        let hits = GlobalWrite.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn global_read_ok() {
        let src = "local x = _G.myValue";
        let ast = parse(src);
        let hits = GlobalWrite.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn shadowed_builtin_detected() {
        let src = "local math = require(mathLib)";
        let ast = parse(src);
        let hits = ShadowedBuiltin.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn local_math_equals_math_ok() {
        let src = "local math = math";
        let ast = parse(src);
        let hits = ShadowedBuiltin.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn table_zero_index_detected() {
        let src = "local x = t[0]";
        let ast = parse(src);
        let hits = TableZeroIndex.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn table_one_index_ok() {
        let src = "local x = t[1]";
        let ast = parse(src);
        let hits = TableZeroIndex.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn method_call_defeats_fastcall_detected() {
        let src = "for i = 1, 10 do\n  local b = s:byte(i)\nend";
        let ast = parse(src);
        let hits = MethodCallDefeatsFastcall.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn dot_call_fastcall_ok() {
        let src = "for i = 1, 10 do\n  local b = string.byte(s, i)\nend";
        let ast = parse(src);
        let hits = MethodCallDefeatsFastcall.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn shared_global_mutation_detected() {
        let src = "shared.GameState = \"running\"";
        let ast = parse(src);
        let hits = SharedGlobalMutation.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn shared_read_ok() {
        let src = "local state = shared.GameState";
        let ast = parse(src);
        let hits = SharedGlobalMutation.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn import_chain_in_loop_detected() {
        let src = "for _, p in parts do\n  local x = game.Workspace.Model.Part.Position.X\nend";
        let ast = parse(src);
        let hits = ImportChainTooDeep.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn short_chain_ok() {
        let src = "for _, p in parts do\n  local x = game.Workspace.Model\nend";
        let ast = parse(src);
        let hits = ImportChainTooDeep.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }

    #[test]
    fn pcall_in_native_loop_detected() {
        let src = "--!native\nfor i = 1, 10 do\n  pcall(doWork)\nend";
        let ast = parse(src);
        let hits = PcallInNative.check(src, &ast);
        assert_eq!(hits.len(), 1);
    }

    #[test]
    fn pcall_in_non_native_ok() {
        let src = "for i = 1, 10 do\n  pcall(doWork)\nend";
        let ast = parse(src);
        let hits = PcallInNative.check(src, &ast);
        assert_eq!(hits.len(), 0);
    }
}
