"""Tests for the 13 new checkers derived from Ben's project-sendra bugs.

FRONT004-007, DATE004, CONC002, QUAL014-018, TYPE012-013.
"""

import pytest
from pathlib import Path

# --- Frontend checkers ---
from stablestack.checkers.frontend.nextjs_redirect_url import NextjsRedirectUrlChecker
from stablestack.checkers.frontend.useeffect_side_effect import UseEffectSideEffectChecker
from stablestack.checkers.frontend.stale_time_ui import StaleTimeUiChecker
from stablestack.checkers.frontend.unbounded_ai_output import UnboundedAiOutputChecker

# --- Datetime ---
from stablestack.checkers.datetime_checks.getday_utc_mismatch import GetDayUtcMismatchChecker

# --- Concurrency ---
from stablestack.checkers.concurrency.non_atomic_read_write import NonAtomicReadWriteChecker

# --- Quality ---
from stablestack.checkers.quality.case_insensitive_email import CaseInsensitiveEmailChecker
from stablestack.checkers.quality.double_wrapped_email import DoubleWrappedEmailChecker
from stablestack.checkers.quality.duplicate_external_creation import DuplicateExternalCreationChecker
from stablestack.checkers.quality.hardcoded_model_id import HardcodedModelIdChecker
from stablestack.checkers.quality.bash_syntax_in_sh import BashSyntaxInShChecker

# --- Types ---
from stablestack.checkers.types.dom_selection_null import DomSelectionNullChecker
from stablestack.checkers.types.unvalidated_json_parse import UnvalidatedJsonParseChecker


# ── Local fixtures (avoid "test_file.*" names that should_skip_file rejects) ──

@pytest.fixture
def ts_file(tmp_path: Path):
    def _create(content: str, name: str = "service.ts") -> Path:
        p = tmp_path / name
        p.write_text(content)
        return p
    return _create


@pytest.fixture
def tsx_file(tmp_path: Path):
    def _create(content: str, name: str = "component.tsx") -> Path:
        p = tmp_path / name
        p.write_text(content)
        return p
    return _create


@pytest.fixture
def js_file(tmp_path: Path):
    def _create(content: str, name: str = "service.js") -> Path:
        p = tmp_path / name
        p.write_text(content)
        return p
    return _create


@pytest.fixture
def py_file(tmp_path: Path):
    def _create(content: str, name: str = "service.py") -> Path:
        p = tmp_path / name
        p.write_text(content)
        return p
    return _create


@pytest.fixture
def sh_file(tmp_path: Path):
    def _create(content: str, name: str = "deploy.sh") -> Path:
        p = tmp_path / name
        p.write_text(content)
        return p
    return _create


# ============================================================================
# FRONT004: NextjsRedirectUrlChecker
# ============================================================================

class TestFRONT004:
    def setup_method(self):
        self.checker = NextjsRedirectUrlChecker()

    def test_flags_redirect_with_request_url(self, ts_file):
        code = """\
import { NextResponse } from 'next/server';
export function middleware(request) {
  return NextResponse.redirect(new URL('/dashboard', request.url));
}
"""
        path = ts_file(code, "middleware.ts")
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1
        assert findings[0].rule_id == "FRONT004"

    def test_safe_with_env_var(self, ts_file):
        code = """\
import { NextResponse } from 'next/server';
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
export function middleware(request) {
  return NextResponse.redirect(new URL('/dashboard', request.url));
}
"""
        path = ts_file(code, "middleware.ts")
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_no_redirect_no_finding(self, ts_file):
        code = """\
const url = new URL('/api', request.url);
const data = await fetch(url);
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_skips_test_files(self, ts_file):
        path = ts_file("", "middleware.test.ts")
        assert self.checker.should_skip_file(path) is True

    def test_does_not_skip_normal_files(self, ts_file):
        path = ts_file("", "middleware.ts")
        assert self.checker.should_skip_file(path) is False


# ============================================================================
# FRONT005: UseEffectSideEffectChecker
# ============================================================================

class TestFRONT005:
    def setup_method(self):
        self.checker = UseEffectSideEffectChecker()

    def test_flags_unguarded_post_in_useeffect(self, tsx_file):
        code = """\
import { useEffect } from 'react';

function Component() {
  useEffect(() => {
    fetch('/api/submit', { method: 'POST', body: JSON.stringify({}) });
  }, []);
}
"""
        path = tsx_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1
        assert findings[0].rule_id == "FRONT005"

    def test_safe_with_useref_guard(self, tsx_file):
        code = """\
import { useEffect, useRef } from 'react';

function Component() {
  const initiated = useRef(false);
  useEffect(() => {
    if (initiated.current) return;
    initiated.current = true;
    fetch('/api/submit', { method: 'POST', body: JSON.stringify({}) });
  }, []);
}
"""
        path = tsx_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_safe_with_abort_controller(self, tsx_file):
        code = """\
import { useEffect } from 'react';

function Component() {
  useEffect(() => {
    const controller = new AbortController();
    fetch('/api/submit', { method: 'POST', signal: controller.signal });
    return () => controller.abort();
  }, []);
}
"""
        path = tsx_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_no_finding_for_get_request(self, tsx_file):
        code = """\
import { useEffect } from 'react';

function Component() {
  useEffect(() => {
    fetch('/api/data');
  }, []);
}
"""
        path = tsx_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_flags_axios_post(self, tsx_file):
        code = """\
import { useEffect } from 'react';
import axios from 'axios';

function Component() {
  useEffect(() => {
    axios.post('/api/submit', { data: 'foo' });
  }, []);
}
"""
        path = tsx_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1


# ============================================================================
# FRONT006: StaleTimeUiChecker
# ============================================================================

class TestFRONT006:
    def setup_method(self):
        self.checker = StaleTimeUiChecker()

    def test_flags_ispast_without_timer(self, tsx_file):
        code = """\
import { isPast } from 'date-fns';

function EventCard({ event }) {
  const ended = isPast(event.endDate);
  return <div>{ended ? 'Ended' : 'Upcoming'}</div>;
}
"""
        path = tsx_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1
        assert findings[0].rule_id == "FRONT006"

    def test_safe_with_setinterval(self, tsx_file):
        code = """\
import { isPast } from 'date-fns';
import { useEffect, useState } from 'react';

function EventCard({ event }) {
  const [, forceUpdate] = useState(0);
  useEffect(() => {
    const id = setInterval(() => forceUpdate(x => x + 1), 60000);
    return () => clearInterval(id);
  }, []);
  const ended = isPast(event.endDate);
  return <div>{ended ? 'Ended' : 'Upcoming'}</div>;
}
"""
        path = tsx_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_only_checks_tsx_jsx(self, ts_file):
        # file_extensions is {".tsx", ".jsx"}, so .ts should be skipped by runner
        assert ".ts" not in self.checker.file_extensions
        assert ".tsx" in self.checker.file_extensions
        assert ".jsx" in self.checker.file_extensions

    def test_flags_isafter(self, tsx_file):
        code = """\
import { isAfter } from 'date-fns';

function Card({ item }) {
  const active = isAfter(item.start, new Date());
  return <div>{active ? 'Active' : 'Inactive'}</div>;
}
"""
        path = tsx_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1


# ============================================================================
# FRONT007: UnboundedAiOutputChecker
# ============================================================================

class TestFRONT007:
    def setup_method(self):
        self.checker = UnboundedAiOutputChecker()

    def test_flags_reasoning_in_jsx(self, tsx_file):
        code = """\
function ResultCard({ result }) {
  return (
    <div>
      <h2>Analysis</h2>
      <p>{result.reasoning}</p>
    </div>
  );
}
"""
        path = tsx_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1
        assert findings[0].rule_id == "FRONT007"

    def test_safe_with_line_clamp(self, tsx_file):
        code = """\
function ResultCard({ result }) {
  return (
    <div>
      <p className="line-clamp-3">{result.reasoning}</p>
    </div>
  );
}
"""
        path = tsx_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_safe_with_slice(self, tsx_file):
        code = """\
function ResultCard({ result }) {
  return (
    <div>
      <p>{result.reasoning.slice(0, 280)}</p>
    </div>
  );
}
"""
        path = tsx_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_flags_summary_in_jsx(self, tsx_file):
        code = """\
function Card({ data }) {
  return (
    <div>
      <p>{data.summary}</p>
    </div>
  );
}
"""
        path = tsx_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1

    def test_no_finding_for_destructuring(self, tsx_file):
        code = """\
function Component() {
  const { summary } = props;
  return <div>hello</div>;
}
"""
        path = tsx_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0


# ============================================================================
# DATE004: GetDayUtcMismatchChecker
# ============================================================================

class TestDATE004:
    def setup_method(self):
        self.checker = GetDayUtcMismatchChecker()

    def test_flags_getday_with_utc_dates(self, ts_file):
        code = """\
const d = new Date("2024-01-15T00:00:00Z");
const day = d.getDay();
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1
        assert findings[0].rule_id == "DATE004"
        assert "getUTCDay" in findings[0].fix_hint

    def test_safe_getutcday(self, ts_file):
        code = """\
const d = new Date("2024-01-15T00:00:00Z");
const day = d.getUTCDay();
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_no_finding_without_utc_context(self, ts_file):
        code = """\
const d = new Date();
const day = d.getDay();
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_flags_gethours_with_toisostring(self, ts_file):
        code = """\
const iso = date.toISOString();
const hours = date.getHours();
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1

    def test_safe_local_intent(self, ts_file):
        code = """\
const d = new Date("2024-01-15T00:00:00Z");
const localDay = d.getDay();
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_flags_getmonth(self, ts_file):
        code = """\
const d = new Date("2024-01-15T00:00:00Z");
const month = d.getMonth();
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1


# ============================================================================
# CONC002: NonAtomicReadWriteChecker
# ============================================================================

class TestCONC002:
    def setup_method(self):
        self.checker = NonAtomicReadWriteChecker()

    def test_flags_ts_read_status_check_write(self, ts_file):
        code = """\
async function processOrder(id: string) {
  const order = await db.order.findUnique({ where: { id } });
  if (order.status === "PENDING") {
    await db.order.update({ where: { id }, data: { status: "DONE" } });
  }
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1
        assert findings[0].rule_id == "CONC002"

    def test_safe_with_transaction(self, ts_file):
        code = """\
async function processOrder(id: string) {
  await prisma.$transaction(async (tx) => {
    const order = await tx.order.findUnique({ where: { id } });
    if (order.status === "PENDING") {
      await tx.order.update({ where: { id }, data: { status: "DONE" } });
    }
  });
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_safe_atomic_update(self, ts_file):
        code = """\
async function processOrder(id: string) {
  await db.order.updateMany({
    where: { id, status: "PENDING" },
    data: { status: "DONE" },
  });
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_flags_python_read_then_write(self, py_file):
        code = """\
def process_order(order_id):
    record = session.query(Order).filter_by(id=order_id).first()
    if record.status == "new":
        record.status = "processing"
        session.commit()
"""
        path = py_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1

    def test_no_finding_without_status_check(self, ts_file):
        code = """\
async function getAndLog(id: string) {
  const record = await db.item.findUnique({ where: { id } });
  console.log(record);
  await db.log.create({ data: { itemId: id, action: "viewed" } });
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0


# ============================================================================
# QUAL014: CaseInsensitiveEmailChecker
# ============================================================================

class TestQUAL014:
    def setup_method(self):
        self.checker = CaseInsensitiveEmailChecker()

    def test_flags_prisma_email_exact(self, ts_file):
        code = """\
async function findUser(userEmail: string) {
  const user = await prisma.user.findUnique({
    where: { email: userEmail },
  });
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1
        assert findings[0].rule_id == "QUAL014"

    def test_safe_with_tolowercase(self, ts_file):
        code = """\
async function findUser(userEmail: string) {
  const user = await prisma.user.findUnique({
    where: { email: userEmail.toLowerCase() },
  });
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        # The line with .toLowerCase() should not be flagged
        flagged_lines = [f.line for f in findings]
        assert 3 not in flagged_lines  # Line 3 has .toLowerCase()

    def test_safe_with_insensitive_mode(self, ts_file):
        code = """\
async function findUser(userEmail: string) {
  const user = await prisma.user.findFirst({
    where: { email: { equals: userEmail, mode: 'insensitive' } },
  });
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_flags_js_comparison(self, ts_file):
        code = """\
function checkEmail(email, input) {
  if (email === input) {
    console.log("match");
  }
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1

    def test_flags_python_comparison(self, py_file):
        code = """\
def check_email(email, user_input):
    if email == user_input:
        print("match")
"""
        path = py_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1

    def test_safe_python_lower(self, py_file):
        code = """\
def check_email(email, user_input):
    if email.lower() == user_input.lower():
        print("match")
"""
        path = py_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_flags_django_filter(self, py_file):
        code = """\
def find_user(addr):
    return User.objects.filter(email=addr)
"""
        path = py_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1

    def test_safe_django_iexact(self, py_file):
        code = """\
def find_user(addr):
    return User.objects.filter(email__iexact=addr)
"""
        path = py_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0


# ============================================================================
# QUAL015: DoubleWrappedEmailChecker
# ============================================================================

class TestQUAL015:
    def setup_method(self):
        self.checker = DoubleWrappedEmailChecker()

    def test_flags_js_template_wrap(self, ts_file):
        code = """\
function formatSender(name, email) {
  return `${name} <${email}>`;
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1
        assert findings[0].rule_id == "QUAL015"

    def test_safe_with_includes_check(self, ts_file):
        code = """\
function formatSender(name, email) {
  if (email.includes('<')) {
    return email;
  }
  return `${name} <${email}>`;
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_flags_python_fstring_wrap(self, py_file):
        code = """\
def format_sender(name, email):
    return f"{name} <{email}>"
"""
        path = py_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1

    def test_safe_python_with_in_check(self, py_file):
        code = """\
def format_sender(name, email):
    if '<' in email:
        return email
    return f"{name} <{email}>"
"""
        path = py_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0


# ============================================================================
# QUAL016: DuplicateExternalCreationChecker
# ============================================================================

class TestQUAL016:
    def setup_method(self):
        self.checker = DuplicateExternalCreationChecker()

    def test_flags_google_outlook_in_promise_all(self, ts_file):
        code = """\
async function syncCalendar(data) {
  await Promise.all([
    createGoogleEvent(data),
    createOutlookEvent(data),
  ]);
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1
        assert findings[0].rule_id == "QUAL016"

    def test_no_finding_single_service(self, ts_file):
        code = """\
async function syncCalendar(data) {
  await Promise.all([
    createGoogleEvent(data),
    sendNotification(data),
  ]);
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_flags_duplicate_create_calls(self, ts_file):
        code = """\
async function process(data) {
  await Promise.all([
    createEvent(data.google),
    createEvent(data.outlook),
  ]);
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1


# ============================================================================
# QUAL017: HardcodedModelIdChecker
# ============================================================================

class TestQUAL017:
    def setup_method(self):
        self.checker = HardcodedModelIdChecker()

    def test_flags_inline_gpt_model(self, ts_file):
        code = """\
const response = await openai.chat.completions.create({
  model: "gpt-4o",
  messages: [{ role: "user", content: "Hello" }],
});
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1
        assert findings[0].rule_id == "QUAL017"

    def test_flags_inline_claude_model(self, py_file):
        code = """\
response = anthropic.messages.create(
    model="claude-3-opus-20240229",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Hello"}],
)
"""
        path = py_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1

    def test_safe_const_assignment(self, ts_file):
        code = """\
const MODEL = "gpt-4o";
const response = await openai.chat.completions.create({
  model: MODEL,
  messages: [],
});
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_safe_env_fallback(self, ts_file):
        code = """\
const MODEL = process.env.AI_MODEL || "gpt-4o";
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_skips_config_files(self, ts_file):
        path = ts_file("", "config.ts")
        assert self.checker.should_skip_file(path) is True

    def test_skips_constants_files(self, ts_file):
        path = ts_file("", "constants.ts")
        assert self.checker.should_skip_file(path) is True

    def test_safe_python_constant(self, py_file):
        code = """\
MODEL_ID = "claude-3-opus-20240229"
"""
        path = py_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0


# ============================================================================
# QUAL018: BashSyntaxInShChecker
# ============================================================================

class TestQUAL018:
    def setup_method(self):
        self.checker = BashSyntaxInShChecker()

    def test_flags_double_brackets_in_sh(self, sh_file):
        code = """\
#!/bin/sh
if [[ $x == "foo" ]]; then
  echo "match"
fi
"""
        path = sh_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1
        assert findings[0].rule_id == "QUAL018"

    def test_flags_here_string(self, sh_file):
        code = """\
#!/bin/sh
read -r var <<< "$input"
"""
        path = sh_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1

    def test_flags_source_command(self, sh_file):
        code = """\
#!/bin/sh
source /etc/profile
"""
        path = sh_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1

    def test_safe_in_bash_script(self, sh_file):
        code = """\
#!/bin/bash
if [[ $x == "foo" ]]; then
  echo "match"
fi
"""
        path = sh_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_safe_posix_brackets(self, sh_file):
        code = """\
#!/bin/sh
if [ "$x" = "foo" ]; then
  echo "match"
fi
"""
        path = sh_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_flags_function_keyword(self, sh_file):
        code = """\
#!/bin/sh
function cleanup {
  rm -f /tmp/lockfile
}
"""
        path = sh_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1

    def test_flags_pattern_substitution(self, sh_file):
        code = """\
#!/bin/sh
clean=${dirty//old/new}
"""
        path = sh_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1

    def test_no_finding_without_shebang(self, sh_file):
        code = """\
if [[ $x == "foo" ]]; then
  echo "match"
fi
"""
        path = sh_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0


# ============================================================================
# TYPE012: DomSelectionNullChecker
# ============================================================================

class TestTYPE012:
    def setup_method(self):
        self.checker = DomSelectionNullChecker()

    def test_flags_arithmetic_on_selectionstart(self, ts_file):
        code = """\
function handleInput(input: HTMLInputElement) {
  const pos = input.selectionStart + 1;
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1
        assert findings[0].rule_id == "TYPE012"

    def test_flags_direct_assignment(self, ts_file):
        code = """\
function getPos(input: HTMLInputElement) {
  const pos = input.selectionStart;
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1

    def test_safe_with_nullish_coalescing(self, ts_file):
        code = """\
function handleInput(input: HTMLInputElement) {
  const pos = input.selectionStart ?? 0;
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_safe_with_null_check(self, ts_file):
        code = """\
function handleInput(input: HTMLInputElement) {
  if (input.selectionStart !== null) {
    const pos = input.selectionStart + 1;
  }
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_safe_with_optional_chaining(self, ts_file):
        code = """\
function handleInput(input: HTMLInputElement) {
  const pos = input?.selectionStart;
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0


# ============================================================================
# TYPE013: UnvalidatedJsonParseChecker
# ============================================================================

class TestTYPE013:
    def setup_method(self):
        self.checker = UnvalidatedJsonParseChecker()

    def test_flags_property_access_on_json_parse(self, ts_file):
        code = """\
function handle(body: string) {
  const data = JSON.parse(body);
  console.log(data.user.name);
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) >= 1
        assert findings[0].rule_id == "TYPE013"

    def test_safe_with_zod(self, ts_file):
        code = """\
import { z } from 'zod';

const schema = z.object({ user: z.object({ name: z.string() }) });

function handle(body: string) {
  const data = schema.parse(JSON.parse(body));
  console.log(data.user.name);
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_safe_with_type_assertion(self, ts_file):
        code = """\
interface UserResponse { user: { name: string } }

function handle(body: string) {
  const data = JSON.parse(body) as UserResponse;
  console.log(data.user.name);
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_no_finding_without_property_access(self, ts_file):
        code = """\
function handle(body: string) {
  const data = JSON.parse(body);
  return data;
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0

    def test_safe_with_try_catch(self, ts_file):
        code = """\
function handle(body: string) {
  try {
    const data = JSON.parse(body);
    console.log(data.user.name);
  } catch (e) {
    console.error(e);
  }
}
"""
        path = ts_file(code)
        findings = self.checker.check_file(path, code)
        assert len(findings) == 0


# ============================================================================
# Rule ID and metadata sanity checks
# ============================================================================

class TestCheckerMetadata:
    """Verify all 13 checkers have correct rule_ids and are importable."""

    def test_all_rule_ids(self):
        checkers = [
            (NextjsRedirectUrlChecker, "FRONT004"),
            (UseEffectSideEffectChecker, "FRONT005"),
            (StaleTimeUiChecker, "FRONT006"),
            (UnboundedAiOutputChecker, "FRONT007"),
            (GetDayUtcMismatchChecker, "DATE004"),
            (NonAtomicReadWriteChecker, "CONC002"),
            (CaseInsensitiveEmailChecker, "QUAL014"),
            (DoubleWrappedEmailChecker, "QUAL015"),
            (DuplicateExternalCreationChecker, "QUAL016"),
            (HardcodedModelIdChecker, "QUAL017"),
            (BashSyntaxInShChecker, "QUAL018"),
            (DomSelectionNullChecker, "TYPE012"),
            (UnvalidatedJsonParseChecker, "TYPE013"),
        ]
        for cls, expected_id in checkers:
            instance = cls()
            assert instance.rule_id == expected_id, f"{cls.__name__} has wrong rule_id"
            assert instance.rule_name, f"{cls.__name__} missing rule_name"
            assert instance.description, f"{cls.__name__} missing description"
            assert instance.why, f"{cls.__name__} missing why"
