"""Smoke tests and CLI tests for smellcheck."""

from __future__ import annotations

import json
import os
import subprocess
import sys
import textwrap
import xml.etree.ElementTree as ET
from pathlib import Path

from smellcheck import __version__
from smellcheck.detector import (
    _DEFAULT_CACHE_DIR,
    _MAX_EXTENDS_CHAIN_DEPTH,
    _RULE_DESCRIPTIONS,
    _RULE_EXAMPLES,
    _RULE_REGISTRY,
    _VALID_FAMILIES,
    _VALID_SCOPES,
    _cache_key,
    _clear_cache,
    _config_hash,
    _deserialize_file_data,
    _deserialize_finding,
    _escape_workflow_data,
    _escape_workflow_value,
    _fingerprint,
    _get_changed_files,
    _is_suppressed,
    _load_baseline,
    _merge_smellcheck_configs,
    _parse_args,
    _parse_block_directives,
    _read_cache,
    _resolve_code,
    _resolve_extends,
    _serialize_file_data,
    _serialize_finding,
    _write_cache,
    _REFACTORING_PHASES,
    _RULE_TO_PHASE,
    _compute_plan,
    _format_plan_json,
    _format_plan_text,
    _group_findings_by_phase,
    _read_env_config,
    FileData,
    Finding,
    load_config,
    print_findings,
    scan_file,
    scan_path,
    scan_paths,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _write_py(tmp_path: Path, code: str, name: str = "sample.py") -> Path:
    p = tmp_path / name
    p.write_text(textwrap.dedent(code), encoding="utf-8")
    return p


def _run_cli(
    *args: str, cwd: Path | None = None, env: dict[str, str] | None = None
) -> subprocess.CompletedProcess[str]:
    # Strip any pre-existing SMELLCHECK_* vars to prevent leaking into subprocess tests
    clean_env = {k: v for k, v in os.environ.items() if not k.startswith("SMELLCHECK_")}
    if env:
        clean_env.update(env)
    return subprocess.run(
        [sys.executable, "-m", "smellcheck", *args],
        capture_output=True,
        text=True,
        cwd=cwd,
        env=clean_env,
    )


# ---------------------------------------------------------------------------
# Basic import / version
# ---------------------------------------------------------------------------


def test_version_is_string():
    assert isinstance(__version__, str)
    assert len(__version__) > 0


# ---------------------------------------------------------------------------
# Scanning
# ---------------------------------------------------------------------------


def test_empty_file_no_findings(tmp_path):
    p = _write_py(tmp_path, "")
    findings = scan_path(p)
    assert findings == []


def test_mutable_default_detected(tmp_path):
    p = _write_py(
        tmp_path,
        """\
        def foo(x=[]):
            return x
    """,
    )
    findings = scan_path(p)
    patterns = [f.pattern for f in findings]
    assert "SC701" in patterns


def test_scan_directory(tmp_path):
    _write_py(tmp_path, "x = 1\n", name="a.py")
    _write_py(tmp_path, "y = 2\n", name="b.py")
    findings = scan_path(tmp_path)
    # No crashes, may or may not have findings
    assert isinstance(findings, list)


def test_multiple_paths(tmp_path):
    p1 = _write_py(tmp_path, "def foo(x=[]): pass\n", name="a.py")
    p2 = _write_py(tmp_path, "def bar(y={}): pass\n", name="b.py")
    findings = scan_paths([p1, p2])
    patterns = [f.pattern for f in findings]
    assert patterns.count("SC701") >= 2


# ---------------------------------------------------------------------------
# CLI argument parsing
# ---------------------------------------------------------------------------


def test_parse_args_format_github(tmp_path):
    p = _write_py(tmp_path, "x = 1\n")
    paths, fmt, sev, fail_on, select, ignore, scope = _parse_args(
        [str(p), "--format", "github"]
    )
    assert fmt == "github"
    assert paths == [p.resolve()]


def test_parse_args_fail_on(tmp_path):
    p = _write_py(tmp_path, "x = 1\n")
    paths, fmt, sev, fail_on, select, ignore, scope = _parse_args(
        [str(p), "--fail-on", "warning"]
    )
    assert fail_on == "warning"


def test_parse_args_multiple_paths(tmp_path):
    p1 = _write_py(tmp_path, "x = 1\n", name="a.py")
    p2 = _write_py(tmp_path, "y = 2\n", name="b.py")
    paths, *_ = _parse_args([str(p1), str(p2)])
    assert len(paths) == 2


def test_json_deprecated_alias(tmp_path):
    p = _write_py(tmp_path, "x = 1\n")
    paths, fmt, *_ = _parse_args([str(p), "--json"])
    assert fmt == "json"


# ---------------------------------------------------------------------------
# Output formats
# ---------------------------------------------------------------------------


def test_json_output_format(tmp_path, capsys):
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="json")
    out = capsys.readouterr().out
    data = json.loads(out)
    assert isinstance(data, list)
    assert any(d["pattern"] == "SC701" for d in data)


def test_github_output_format(tmp_path, capsys):
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="github")
    out = capsys.readouterr().out
    assert "::error " in out or "::warning " in out or "::notice " in out
    assert "file=" in out
    assert "line=" in out


# ---------------------------------------------------------------------------
# SARIF output format
# ---------------------------------------------------------------------------


def test_sarif_valid_structure(tmp_path, capsys):
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="sarif")
    out = capsys.readouterr().out
    data = json.loads(out)
    assert data["version"] == "2.1.0"
    assert "$schema" in data
    assert "sarif-schema-2.1.0" in data["$schema"]
    assert len(data["runs"]) == 1


def test_sarif_tool_driver(tmp_path, capsys):
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="sarif")
    data = json.loads(capsys.readouterr().out)
    driver = data["runs"][0]["tool"]["driver"]
    assert driver["name"] == "smellcheck"
    assert "version" in driver
    assert "informationUri" in driver
    assert isinstance(driver["rules"], list)


def test_sarif_results_have_required_fields(tmp_path, capsys):
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="sarif")
    data = json.loads(capsys.readouterr().out)
    results = data["runs"][0]["results"]
    assert len(results) > 0
    r = results[0]
    assert "ruleId" in r
    assert "level" in r
    assert "message" in r and "text" in r["message"]
    loc = r["locations"][0]["physicalLocation"]
    assert "artifactLocation" in loc
    assert "region" in loc
    assert loc["region"]["startLine"] > 0


def test_sarif_rules_populated(tmp_path, capsys):
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="sarif")
    data = json.loads(capsys.readouterr().out)
    rules = data["runs"][0]["tool"]["driver"]["rules"]
    rule_ids = [r["id"] for r in rules]
    assert "SC701" in rule_ids


def test_sarif_rules_have_help_metadata(tmp_path, capsys):
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="sarif")
    data = json.loads(capsys.readouterr().out)
    rules = data["runs"][0]["tool"]["driver"]["rules"]
    sc701 = [r for r in rules if r["id"] == "SC701"]
    assert len(sc701) == 1
    rule = sc701[0]
    # fullDescription present and non-empty
    assert "fullDescription" in rule
    assert len(rule["fullDescription"]["text"]) > 0
    # help present with text and markdown
    assert "help" in rule
    assert "text" in rule["help"]
    assert "markdown" in rule["help"]
    assert "## " in rule["help"]["markdown"]  # contains heading
    assert "refactoring guide" in rule["help"]["markdown"]
    # helpUri present and points to reference
    assert "helpUri" in rule
    assert "references/idioms.md" in rule["helpUri"]


def test_sarif_severity_mapping(tmp_path, capsys):
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="sarif")
    data = json.loads(capsys.readouterr().out)
    results = data["runs"][0]["results"]
    sc701 = [r for r in results if r["ruleId"] == "SC701"]
    assert len(sc701) > 0
    # SC701 is error-level → SARIF "error"
    assert sc701[0]["level"] == "error"


def test_sarif_relative_paths(tmp_path, capsys):
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="sarif")
    data = json.loads(capsys.readouterr().out)
    results = data["runs"][0]["results"]
    uri = results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
    assert not uri.startswith("/")


def test_sarif_partial_fingerprints(tmp_path, capsys):
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="sarif")
    data = json.loads(capsys.readouterr().out)
    results = data["runs"][0]["results"]
    assert "partialFingerprints" in results[0]
    fp = results[0]["partialFingerprints"]["primaryLocationLineHash"]
    assert isinstance(fp, str) and len(fp) > 0


def test_sarif_empty_findings(capsys):
    print_findings([], output_format="sarif")
    data = json.loads(capsys.readouterr().out)
    assert data["version"] == "2.1.0"
    assert data["runs"][0]["results"] == []
    assert data["runs"][0]["tool"]["driver"]["rules"] == []


def test_cli_format_sarif(tmp_path):
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    result = _run_cli(str(p), "--format", "sarif")
    data = json.loads(result.stdout)
    assert data["version"] == "2.1.0"


# ---------------------------------------------------------------------------
# Inline suppression (# noqa)
# ---------------------------------------------------------------------------


def test_noqa_suppression(tmp_path):
    p = _write_py(tmp_path, "def foo(x=[]):  # noqa: SC701\n    pass\n")
    findings = scan_paths([p])
    patterns = [f.pattern for f in findings]
    assert "SC701" not in patterns


def test_noqa_all(tmp_path):
    p = _write_py(tmp_path, "def foo(x=[]):  # noqa\n    pass\n")
    findings = scan_paths([p])
    # All findings on line 1 should be suppressed
    line1_findings = [f for f in findings if f.line == 1]
    assert line1_findings == []


# ---------------------------------------------------------------------------
# Config support
# ---------------------------------------------------------------------------


def test_config_ignore(tmp_path):
    _write_py(tmp_path, "def foo(x=[]): pass\n")
    pyproject = tmp_path / "pyproject.toml"
    pyproject.write_text('[tool.smellcheck]\nignore = ["SC701"]\n', encoding="utf-8")
    config = load_config(tmp_path)
    findings = scan_paths([tmp_path], config=config)
    patterns = [f.pattern for f in findings]
    assert "SC701" not in patterns


def test_config_select(tmp_path):
    _write_py(tmp_path, "def foo(x=[]): pass\n")
    pyproject = tmp_path / "pyproject.toml"
    pyproject.write_text('[tool.smellcheck]\nselect = ["SC701"]\n', encoding="utf-8")
    config = load_config(tmp_path)
    findings = scan_paths([tmp_path], config=config)
    assert all(f.pattern == "SC701" for f in findings)
    assert len(findings) >= 1


# ---------------------------------------------------------------------------
# Exit codes
# ---------------------------------------------------------------------------


def test_fail_on_exit_code(tmp_path):
    _write_py(tmp_path, "def foo(x=[]): pass\n")  # #057 is severity=error
    # --fail-on error (default) should exit 1
    result = _run_cli(str(tmp_path), "--fail-on", "error")
    assert result.returncode == 1

    # --fail-on with severity above all findings should exit 0
    # Write a file that only produces info-level findings
    clean = _write_py(tmp_path, "result = 42\n", name="clean.py")
    result2 = _run_cli(str(clean), "--fail-on", "error")
    assert result2.returncode == 0


# ---------------------------------------------------------------------------
# CLI integration
# ---------------------------------------------------------------------------


def test_cli_version():
    result = _run_cli("--version")
    assert result.returncode == 0
    assert "smellcheck" in result.stdout


def test_cli_help():
    result = _run_cli("--help")
    assert result.returncode == 0
    assert "--format" in result.stdout
    assert "--fail-on" in result.stdout
    assert "--scope" in result.stdout


# ---------------------------------------------------------------------------
# Unified SC code naming system (issue #4)
# ---------------------------------------------------------------------------


def test_rule_registry_complete():
    """Registry has 60 entries with valid families and scopes."""
    assert len(_RULE_REGISTRY) == 60
    for key, rd in _RULE_REGISTRY.items():
        assert key.startswith("SC"), f"Key {key!r} must start with 'SC'"
        assert key == rd.rule_id, f"Key {key!r} must match rule_id {rd.rule_id!r}"
        assert rd.family in _VALID_FAMILIES, f"Invalid family {rd.family!r} for {key}"
        assert rd.scope in _VALID_SCOPES, f"Invalid scope {rd.scope!r} for {key}"
        assert rd.default_severity in {"info", "warning", "error"}, (
            f"Invalid severity {rd.default_severity!r} for {key}"
        )


def test_rule_id_populated(tmp_path):
    """Mutable default triggers SC701 with scope file."""
    p = _write_py(tmp_path, "def foo(x=[]):\n    return x\n")
    findings = scan_path(p)
    f701 = [f for f in findings if f.pattern == "SC701"]
    assert len(f701) >= 1
    assert f701[0].scope == "file"


def test_resolve_code_formats():
    """_resolve_code handles SC codes only."""
    assert _resolve_code("SC701") == {"SC701"}
    assert _resolve_code("SC210") == {"SC210"}
    assert _resolve_code("SC801") == {"SC801"}
    assert _resolve_code("SC503") == {"SC503"}
    assert _resolve_code("sc701") == {"SC701"}  # case-insensitive
    assert _resolve_code("NONEXISTENT") == set()
    assert _resolve_code("057") == set()  # legacy bare numbers no longer resolve
    assert _resolve_code("#057") == set()  # legacy #-prefix no longer resolves
    assert _resolve_code("CC") == set()  # legacy alpha codes no longer resolve


def test_noqa_with_sc_rule_id(tmp_path):
    """``# noqa: SC701`` suppresses SC701 findings."""
    p = _write_py(tmp_path, "def foo(x=[]):  # noqa: SC701\n    pass\n")
    findings = scan_paths([p])
    patterns = [f.pattern for f in findings]
    assert "SC701" not in patterns


def test_config_select_sc_code(tmp_path):
    """select = ["SC701"] in config keeps only SC701 findings."""
    _write_py(tmp_path, "def foo(x=[]): pass\n")
    pyproject = tmp_path / "pyproject.toml"
    pyproject.write_text('[tool.smellcheck]\nselect = ["SC701"]\n', encoding="utf-8")
    config = load_config(tmp_path)
    findings = scan_paths([tmp_path], config=config)
    assert all(f.pattern == "SC701" for f in findings)
    assert len(findings) >= 1


def test_config_ignore_sc_code(tmp_path):
    """ignore = ["SC701"] in config removes SC701 findings."""
    _write_py(tmp_path, "def foo(x=[]): pass\n")
    pyproject = tmp_path / "pyproject.toml"
    pyproject.write_text('[tool.smellcheck]\nignore = ["SC701"]\n', encoding="utf-8")
    config = load_config(tmp_path)
    findings = scan_paths([tmp_path], config=config)
    patterns = [f.pattern for f in findings]
    assert "SC701" not in patterns


def test_scope_filter_cli(tmp_path):
    """--scope file excludes cross-file and metric findings."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    result = _run_cli(str(p), "--scope", "file", "--format", "json")
    data = json.loads(result.stdout)
    # All findings should have scope == "file"
    assert all(d.get("scope") == "file" for d in data)


def test_json_includes_scope(tmp_path):
    """JSON output includes scope field; pattern is the SC code."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="json")
    f701 = [f for f in findings if f.pattern == "SC701"]
    assert len(f701) >= 1
    from dataclasses import asdict

    d = asdict(f701[0])
    assert "scope" in d
    assert d["pattern"] == "SC701"
    assert d["scope"] == "file"


# ---------------------------------------------------------------------------
# Baseline workflow (issue #5)
# ---------------------------------------------------------------------------


def test_generate_baseline_json_structure(tmp_path):
    """--generate-baseline outputs valid JSON with version/generated/findings keys."""
    _write_py(tmp_path, "def foo(x=[]): pass\n")
    result = _run_cli(str(tmp_path), "--generate-baseline", cwd=tmp_path)
    assert result.returncode == 0
    data = json.loads(result.stdout)
    assert "version" in data
    assert "generated" in data
    assert "findings" in data
    assert isinstance(data["findings"], list)
    assert len(data["findings"]) >= 1
    entry = data["findings"][0]
    for key in ("fingerprint", "file", "pattern", "line", "name"):
        assert key in entry, f"Missing key {key!r} in baseline entry"


def test_baseline_suppresses_existing_findings(tmp_path):
    """Generate baseline, run with --baseline, same code -> empty output."""
    _write_py(tmp_path, "def foo(x=[]): pass\n")
    # Generate baseline
    gen = _run_cli(str(tmp_path), "--generate-baseline", cwd=tmp_path)
    assert gen.returncode == 0
    bl = tmp_path / ".smellcheck-baseline.json"
    bl.write_text(gen.stdout, encoding="utf-8")
    # Run with baseline
    result = _run_cli(
        str(tmp_path), "--baseline", str(bl), "--format", "json", cwd=tmp_path
    )
    data = json.loads(result.stdout)
    assert data == []
    assert "suppressed" in result.stderr


def test_baseline_reports_new_findings(tmp_path):
    """Baseline from file A, add file B -> B's findings appear."""
    _write_py(tmp_path, "def foo(x=[]): pass\n", name="a.py")
    # Generate baseline from a.py only
    gen = _run_cli(str(tmp_path / "a.py"), "--generate-baseline", cwd=tmp_path)
    bl = tmp_path / "baseline.json"
    bl.write_text(gen.stdout, encoding="utf-8")
    # Add file B with its own finding
    _write_py(tmp_path, "def bar(y={}): pass\n", name="b.py")
    # Run on whole directory with baseline
    result = _run_cli(
        str(tmp_path), "--baseline", str(bl), "--format", "json", cwd=tmp_path
    )
    data = json.loads(result.stdout)
    # b.py findings should appear (new), a.py findings suppressed
    files = {d["file"] for d in data}
    assert any("b.py" in f for f in files)


def test_baseline_ignores_disappeared_findings(tmp_path):
    """Baseline with smell, fix code, run -> no crash, no findings."""
    _write_py(tmp_path, "def foo(x=[]): pass\n")
    gen = _run_cli(str(tmp_path), "--generate-baseline", cwd=tmp_path)
    bl = tmp_path / "baseline.json"
    bl.write_text(gen.stdout, encoding="utf-8")
    # Fix the code (remove smell)
    _write_py(tmp_path, "def foo(x=None): pass\n")
    result = _run_cli(
        str(tmp_path), "--baseline", str(bl), "--format", "json", cwd=tmp_path
    )
    data = json.loads(result.stdout)
    assert data == []


def test_fingerprint_resilient_to_line_shift(tmp_path):
    """Same finding at different line numbers -> same fingerprint."""
    from smellcheck.detector import Finding

    f1 = Finding(
        file=str(tmp_path / "a.py"),
        line=5,
        pattern="SC701",
        name="Replace Mutable Default Arguments",
        severity="error",
        message="`foo` has mutable default argument `[]`",
        category="idioms",
    )
    f2 = Finding(
        file=str(tmp_path / "a.py"),
        line=42,
        pattern="SC701",
        name="Replace Mutable Default Arguments",
        severity="error",
        message="`foo` has mutable default argument `[]`",
        category="idioms",
    )
    assert _fingerprint(f1, tmp_path) == _fingerprint(f2, tmp_path)


def test_generate_baseline_and_baseline_mutually_exclusive(tmp_path):
    """Both flags -> returncode 1, 'mutually exclusive' in stderr."""
    _write_py(tmp_path, "x = 1\n")
    bl = tmp_path / "bl.json"
    bl.write_text('{"findings": []}', encoding="utf-8")
    result = _run_cli(
        str(tmp_path),
        "--generate-baseline",
        "--baseline",
        str(bl),
        cwd=tmp_path,
    )
    assert result.returncode == 1
    assert "mutually exclusive" in result.stderr


def test_baseline_config_support(tmp_path):
    """baseline = "..." in pyproject.toml honored without CLI flag."""
    _write_py(tmp_path, "def foo(x=[]): pass\n")
    # Generate baseline
    gen = _run_cli(str(tmp_path), "--generate-baseline", cwd=tmp_path)
    bl = tmp_path / ".smellcheck-baseline.json"
    bl.write_text(gen.stdout, encoding="utf-8")
    # Configure via pyproject.toml
    pyproject = tmp_path / "pyproject.toml"
    pyproject.write_text(
        f'[tool.smellcheck]\nbaseline = "{bl}"\n',
        encoding="utf-8",
    )
    result = _run_cli(str(tmp_path), "--format", "json", cwd=tmp_path)
    data = json.loads(result.stdout)
    assert data == []
    assert "suppressed" in result.stderr


# ---------------------------------------------------------------------------
# Blocking calls in async functions (#071 / SC703)
# ---------------------------------------------------------------------------


def test_blocking_sleep_in_async(tmp_path):
    p = _write_py(
        tmp_path,
        """\
        import time
        async def handler():
            time.sleep(1)
    """,
    )
    findings = scan_path(p)
    patterns = [f.pattern for f in findings]
    assert "SC703" in patterns
    msg = next(f.message for f in findings if f.pattern == "SC703")
    assert "time.sleep()" in msg


def test_blocking_requests_in_async(tmp_path):
    p = _write_py(
        tmp_path,
        """\
        import requests
        async def fetch():
            requests.get("http://example.com")
    """,
    )
    findings = scan_path(p)
    patterns = [f.pattern for f in findings]
    assert "SC703" in patterns


def test_blocking_open_in_async(tmp_path):
    p = _write_py(
        tmp_path,
        """\
        async def read_file():
            f = open("data.txt")
    """,
    )
    findings = scan_path(p)
    patterns = [f.pattern for f in findings]
    assert "SC703" in patterns


def test_blocking_subprocess_in_async(tmp_path):
    p = _write_py(
        tmp_path,
        """\
        import subprocess
        async def run_cmd():
            subprocess.run(["ls"])
    """,
    )
    findings = scan_path(p)
    patterns = [f.pattern for f in findings]
    assert "SC703" in patterns


def test_blocking_os_path_in_async(tmp_path):
    p = _write_py(
        tmp_path,
        """\
        import os.path
        async def check():
            os.path.exists("/tmp/x")
    """,
    )
    findings = scan_path(p)
    patterns = [f.pattern for f in findings]
    assert "SC703" in patterns


def test_blocking_input_in_async(tmp_path):
    p = _write_py(
        tmp_path,
        """\
        async def prompt():
            input("Enter: ")
    """,
    )
    findings = scan_path(p)
    patterns = [f.pattern for f in findings]
    assert "SC703" in patterns


def test_no_flag_sync_function(tmp_path):
    p = _write_py(
        tmp_path,
        """\
        import time
        def handler():
            time.sleep(1)
    """,
    )
    findings = scan_path(p)
    patterns = [f.pattern for f in findings]
    assert "SC703" not in patterns


def test_no_flag_nested_def_in_async(tmp_path):
    p = _write_py(
        tmp_path,
        """\
        import time
        async def handler():
            def inner():
                time.sleep(1)
            inner()
    """,
    )
    findings = scan_path(p)
    blocking = [f for f in findings if f.pattern == "SC703"]
    assert blocking == []


def test_no_flag_asyncio_to_thread(tmp_path):
    p = _write_py(
        tmp_path,
        """\
        import asyncio
        import time
        async def handler():
            await asyncio.to_thread(time.sleep, 1)
    """,
    )
    findings = scan_path(p)
    blocking = [f for f in findings if f.pattern == "SC703"]
    assert blocking == []


def test_no_flag_run_in_executor(tmp_path):
    p = _write_py(
        tmp_path,
        """\
        import asyncio
        import time
        async def handler():
            loop = asyncio.get_event_loop()
            loop.run_in_executor(None, time.sleep, 1)
    """,
    )
    findings = scan_path(p)
    blocking = [f for f in findings if f.pattern == "SC703"]
    assert blocking == []


def test_multiple_blocking_calls(tmp_path):
    p = _write_py(
        tmp_path,
        """\
        import time
        import subprocess
        async def handler():
            time.sleep(1)
            subprocess.run(["ls"])
    """,
    )
    findings = scan_path(p)
    blocking = [f for f in findings if f.pattern == "SC703"]
    assert len(blocking) >= 2


def test_offloaded_and_standalone_blocking_call(tmp_path):
    """Offloading one call should not suppress a separate standalone blocking call."""
    p = _write_py(
        tmp_path,
        """\
        import asyncio
        import time
        async def handler():
            await asyncio.to_thread(time.sleep, 1)
            time.sleep(5)  # standalone -- should still be flagged
    """,
    )
    findings = scan_path(p)
    blocking = [f for f in findings if f.pattern == "SC703"]
    assert len(blocking) == 1
    assert "time.sleep" in blocking[0].message




# ---------------------------------------------------------------------------
# SC704: Sync I/O imports in async modules
# ---------------------------------------------------------------------------


def test_sc704_sync_import_in_async_module(tmp_path):
    """Flag top-level sync I/O import in a module with async functions."""
    p = _write_py(
        tmp_path,
        """\
        import requests
        async def fetch(url):
            return requests.get(url)
    """,
    )
    findings = scan_path(p)
    sc704 = [f for f in findings if f.pattern == "SC704"]
    assert len(sc704) == 1
    assert "requests" in sc704[0].message
    assert sc704[0].severity == "warning"


def test_sc704_no_flag_without_async(tmp_path):
    """No flag when the module has no async functions."""
    p = _write_py(
        tmp_path,
        """\
        import requests
        def fetch(url):
            return requests.get(url)
    """,
    )
    findings = scan_path(p)
    sc704 = [f for f in findings if f.pattern == "SC704"]
    assert sc704 == []


def test_sc704_no_flag_scoped_import(tmp_path):
    """No flag when sync library is imported inside a function body."""
    p = _write_py(
        tmp_path,
        """\
        async def fetch(url):
            import requests
            return requests.get(url)
    """,
    )
    findings = scan_path(p)
    sc704 = [f for f in findings if f.pattern == "SC704"]
    assert sc704 == []


def test_sc704_from_import(tmp_path):
    """Flag from urllib.request import urlopen in async module."""
    p = _write_py(
        tmp_path,
        """\
        from urllib.request import urlopen
        async def fetch(url):
            return urlopen(url)
    """,
    )
    findings = scan_path(p)
    sc704 = [f for f in findings if f.pattern == "SC704"]
    assert len(sc704) == 1
    assert "urllib.request" in sc704[0].message


def test_sc704_multiple_sync_imports(tmp_path):
    """Flag each top-level sync I/O import separately."""
    p = _write_py(
        tmp_path,
        """\
        import requests
        import smtplib
        async def handler():
            pass
    """,
    )
    findings = scan_path(p)
    sc704 = [f for f in findings if f.pattern == "SC704"]
    assert len(sc704) == 2


def test_sc704_async_method_in_class(tmp_path):
    """Flag when async def is inside a class."""
    p = _write_py(
        tmp_path,
        """\
        import requests
        class Service:
            async def fetch(self, url):
                return requests.get(url)
    """,
    )
    findings = scan_path(p)
    sc704 = [f for f in findings if f.pattern == "SC704"]
    assert len(sc704) == 1


# ---------------------------------------------------------------------------
# SC705: asyncio.to_thread tech debt hint
# ---------------------------------------------------------------------------


def test_sc705_to_thread_time_sleep(tmp_path):
    """Hint when to_thread wraps time.sleep (has asyncio.sleep alternative)."""
    p = _write_py(
        tmp_path,
        """\
        import asyncio
        import time
        async def handler():
            await asyncio.to_thread(time.sleep, 1)
    """,
    )
    findings = scan_path(p)
    sc705 = [f for f in findings if f.pattern == "SC705"]
    assert len(sc705) == 1
    assert "asyncio.sleep()" in sc705[0].message
    assert sc705[0].severity == "info"


def test_sc705_to_thread_requests(tmp_path):
    """Hint when to_thread wraps requests.get (has httpx alternative)."""
    p = _write_py(
        tmp_path,
        """\
        import asyncio
        import requests
        async def fetch():
            await asyncio.to_thread(requests.get, "http://example.com")
    """,
    )
    findings = scan_path(p)
    sc705 = [f for f in findings if f.pattern == "SC705"]
    assert len(sc705) == 1
    assert "httpx.AsyncClient" in sc705[0].message


def test_sc705_to_thread_open(tmp_path):
    """Hint when to_thread wraps open (has aiofiles alternative)."""
    p = _write_py(
        tmp_path,
        """\
        import asyncio
        async def read_file():
            await asyncio.to_thread(open, "data.txt")
    """,
    )
    findings = scan_path(p)
    sc705 = [f for f in findings if f.pattern == "SC705"]
    assert len(sc705) == 1
    assert "aiofiles.open()" in sc705[0].message


def test_sc705_to_thread_subprocess(tmp_path):
    """Hint when to_thread wraps subprocess.run (has asyncio alternative)."""
    p = _write_py(
        tmp_path,
        """\
        import asyncio
        import subprocess
        async def run_cmd():
            await asyncio.to_thread(subprocess.run, ["ls"])
    """,
    )
    findings = scan_path(p)
    sc705 = [f for f in findings if f.pattern == "SC705"]
    assert len(sc705) == 1
    assert "asyncio.create_subprocess_exec()" in sc705[0].message


def test_sc705_no_hint_for_os_walk(tmp_path):
    """No hint when to_thread wraps os.walk (alternative is asyncio.to_thread itself)."""
    p = _write_py(
        tmp_path,
        """\
        import asyncio
        import os
        async def list_files():
            await asyncio.to_thread(os.walk, "/tmp")
    """,
    )
    findings = scan_path(p)
    sc705 = [f for f in findings if f.pattern == "SC705"]
    assert sc705 == []


# ---------------------------------------------------------------------------
# SC706: Conflicting concurrency libraries
# ---------------------------------------------------------------------------


def test_sc706_requirements_txt_conflict(tmp_path):
    """Flag conflicting libs in requirements.txt."""
    req = tmp_path / "requirements.txt"
    req.write_text("gevent>=1.0\nuvicorn\naiohttp\n", encoding="utf-8")
    _write_py(tmp_path, "x = 1\n")
    findings = scan_path(tmp_path)
    sc706 = [f for f in findings if f.pattern == "SC706"]
    assert len(sc706) >= 2  # gevent+uvicorn, gevent+aiohttp
    msgs = " ".join(f.message for f in sc706)
    assert "gevent" in msgs
    assert "uvicorn" in msgs or "aiohttp" in msgs


def test_sc706_pyproject_toml_conflict(tmp_path):
    """Flag conflicting libs in pyproject.toml."""
    pyproject = tmp_path / "pyproject.toml"
    pyproject.write_text(
        '[project]\ndependencies = ["eventlet", "httpx>=0.24"]\n',
        encoding="utf-8",
    )
    _write_py(tmp_path, "x = 1\n")
    findings = scan_path(tmp_path)
    sc706 = [f for f in findings if f.pattern == "SC706"]
    assert len(sc706) == 1
    assert "eventlet" in sc706[0].message
    assert "httpx" in sc706[0].message


def test_sc706_setup_cfg_conflict(tmp_path):
    """Flag conflicting libs in setup.cfg."""
    setup_cfg = tmp_path / "setup.cfg"
    setup_cfg.write_text(
        "[options]\ninstall_requires =\n    gevent\n    trio\n",
        encoding="utf-8",
    )
    _write_py(tmp_path, "x = 1\n")
    findings = scan_path(tmp_path)
    sc706 = [f for f in findings if f.pattern == "SC706"]
    assert len(sc706) == 1
    assert "gevent" in sc706[0].message
    assert "trio" in sc706[0].message


def test_sc706_no_flag_async_only(tmp_path):
    """No flag when only asyncio-based libs present."""
    req = tmp_path / "requirements.txt"
    req.write_text("uvicorn\naiohttp\nhttpx\n", encoding="utf-8")
    _write_py(tmp_path, "x = 1\n")
    findings = scan_path(tmp_path)
    sc706 = [f for f in findings if f.pattern == "SC706"]
    assert sc706 == []


def test_sc706_no_flag_sync_only(tmp_path):
    """No flag when only monkey-patching libs present (no conflict)."""
    req = tmp_path / "requirements.txt"
    req.write_text("gevent\neventlet\n", encoding="utf-8")
    _write_py(tmp_path, "x = 1\n")
    findings = scan_path(tmp_path)
    sc706 = [f for f in findings if f.pattern == "SC706"]
    assert sc706 == []


# ---------------------------------------------------------------------------
# SC703: Cross-file call-chain tracing for indirect blocking
# ---------------------------------------------------------------------------


def test_sc703_cross_file_indirect_blocking(tmp_path):
    """Async calls sync helper in another file that calls blocking I/O."""
    _write_py(
        tmp_path,
        """\
        import time

        def slow_helper():
            time.sleep(5)
        """,
        name="utils.py",
    )
    _write_py(
        tmp_path,
        """\
        from utils import slow_helper

        async def handle_request():
            slow_helper()
        """,
        name="main.py",
    )
    findings = scan_path(tmp_path)
    indirect = [
        f for f in findings
        if f.pattern == "SC703" and "Indirect blocking" in f.message
    ]
    assert len(indirect) >= 1
    msg = indirect[0].message
    assert "handle_request" in msg
    assert "slow_helper" in msg
    assert "time.sleep" in msg


def test_sc703_cross_file_clean_helper_no_flag(tmp_path):
    """Async calls sync helper that does NOT contain blocking I/O."""
    _write_py(
        tmp_path,
        """\
        def clean_helper():
            return 42
        """,
        name="utils.py",
    )
    _write_py(
        tmp_path,
        """\
        from utils import clean_helper

        async def handle_request():
            clean_helper()
        """,
        name="main.py",
    )
    findings = scan_path(tmp_path)
    indirect = [
        f for f in findings
        if f.pattern == "SC703" and "Indirect blocking" in f.message
    ]
    assert indirect == []


def test_sc703_cross_file_transitive_chain(tmp_path):
    """Async -> sync helper -> another sync helper -> blocking I/O."""
    _write_py(
        tmp_path,
        """\
        import time

        def deep_blocker():
            time.sleep(1)
        """,
        name="deep.py",
    )
    _write_py(
        tmp_path,
        """\
        from deep import deep_blocker

        def middle():
            deep_blocker()
        """,
        name="middle.py",
    )
    _write_py(
        tmp_path,
        """\
        from middle import middle

        async def handler():
            middle()
        """,
        name="main.py",
    )
    findings = scan_path(tmp_path)
    indirect = [
        f for f in findings
        if f.pattern == "SC703" and "Indirect blocking" in f.message
    ]
    assert len(indirect) >= 1
    msg = indirect[0].message
    assert "handler" in msg
    assert "middle" in msg


def test_sc703_cross_file_to_thread_no_flag(tmp_path):
    """Async offloads sync helper via asyncio.to_thread -- not flagged as indirect."""
    _write_py(
        tmp_path,
        """\
        import time

        def slow_helper():
            time.sleep(5)
        """,
        name="utils.py",
    )
    _write_py(
        tmp_path,
        """\
        import asyncio
        from utils import slow_helper

        async def handle_request():
            await asyncio.to_thread(slow_helper)
        """,
        name="main.py",
    )
    findings = scan_path(tmp_path)
    indirect = [
        f for f in findings
        if f.pattern == "SC703" and "Indirect blocking" in f.message
    ]
    # asyncio.to_thread(slow_helper) passes slow_helper as an argument, not a
    # direct call.  The call map for handle_request records "to_thread" as a
    # callee, not "slow_helper", so no indirect blocking chain is traced.
    assert indirect == []


def test_sc703_cross_file_max_depth(tmp_path):
    """Chain deeper than _MAX_CALL_CHAIN_DEPTH is not flagged."""
    # Build a chain of 7 functions: f0 -> f1 -> ... -> f6 -> blocking
    # With _MAX_CALL_CHAIN_DEPTH=5, the chain from f0 should not reach f6
    for i in range(7):
        if i == 6:
            code = f"""\
            import time

            def f{i}():
                time.sleep(1)
            """
        else:
            code = f"""\
            from f{i+1} import f{i+1}

            def f{i}():
                f{i+1}()
            """
        _write_py(tmp_path, code, name=f"f{i}.py")
    _write_py(
        tmp_path,
        """\
        from f0 import f0

        async def entry():
            f0()
        """,
        name="main.py",
    )
    findings = scan_path(tmp_path)
    indirect = [
        f for f in findings
        if f.pattern == "SC703" and "Indirect blocking" in f.message
        and "entry" in f.message
    ]
    # Chain is entry -> f0 -> f1 -> f2 -> f3 -> f4 -> f5 -> f6 -> blocking
    # That's depth 7 from f0, exceeding _MAX_CALL_CHAIN_DEPTH=5
    assert indirect == []


def test_sc703_same_file_indirect_blocking(tmp_path):
    """Async calls a sync helper in the same file that performs a blocking call."""
    _write_py(
        tmp_path,
        """\
        import time

        def sync_helper():
            time.sleep(3)

        async def handle():
            sync_helper()
        """,
        name="app.py",
    )
    findings = scan_path(tmp_path)
    indirect = [
        f for f in findings
        if f.pattern == "SC703" and "Indirect blocking" in f.message
    ]
    assert len(indirect) >= 1
    msg = indirect[0].message
    assert "handle" in msg
    assert "sync_helper" in msg
    assert "time.sleep" in msg


# ---------------------------------------------------------------------------
# --explain
# ---------------------------------------------------------------------------


def test_explain_single_rule():
    r = _run_cli("--explain", "SC701")
    assert r.returncode == 0
    assert "SC701" in r.stdout
    assert "Mutable Default" in r.stdout
    assert "Before:" in r.stdout
    assert "After:" in r.stdout
    assert "# noqa: SC701" in r.stdout


def test_explain_single_rule_lowercase():
    r = _run_cli("--explain", "sc701")
    assert r.returncode == 0
    assert "SC701" in r.stdout


def test_explain_family():
    r = _run_cli("--explain", "SC4")
    assert r.returncode == 0
    assert "Control Flow" in r.stdout
    assert "SC401" in r.stdout
    assert "SC407" in r.stdout


def test_explain_family_xx_suffix():
    """SC4xx form should work the same as SC4."""
    r = _run_cli("--explain", "SC4xx")
    assert r.returncode == 0
    assert "Control Flow" in r.stdout
    assert "SC401" in r.stdout


def test_explain_all():
    r = _run_cli("--explain", "all")
    assert r.returncode == 0
    for family in ["State", "Functions", "Types", "Control", "Architecture", "Hygiene", "Idioms", "Metrics"]:
        assert family in r.stdout


def test_explain_bare():
    """--explain with no argument lists all rules."""
    r = _run_cli("--explain")
    assert r.returncode == 0
    assert "SC101" in r.stdout
    assert "SC805" in r.stdout


def test_explain_invalid_code():
    r = _run_cli("--explain", "SC999")
    assert r.returncode != 0
    out = r.stdout + r.stderr
    assert "unknown" in out.lower() or "no rule" in out.lower()


def test_explain_all_rules_have_descriptions():
    """Every rule in the registry must have a description."""
    for code in _RULE_REGISTRY:
        assert code in _RULE_DESCRIPTIONS, f"{code} missing from _RULE_DESCRIPTIONS"


def test_explain_all_rules_have_examples_entry():
    """Every rule in the registry must have an entry in _RULE_EXAMPLES."""
    for code in _RULE_REGISTRY:
        assert code in _RULE_EXAMPLES, f"{code} missing from _RULE_EXAMPLES"


# ---------------------------------------------------------------------------
# File-level caching
# ---------------------------------------------------------------------------


def test_cache_hit_skips_reanalysis(tmp_path):
    """Second scan of unchanged file should use cache and produce same results."""
    p = _write_py(tmp_path, """\
        def process(items=[]):
            pass
    """)
    cache_dir = tmp_path / ".smellcheck-cache"

    # First scan — populates cache
    findings1 = scan_paths([tmp_path], cache_dir=cache_dir, use_cache=True)
    assert cache_dir.is_dir()
    cache_files = list(cache_dir.glob("*.json"))
    assert len(cache_files) == 1

    # Second scan — should hit cache, same results
    findings2 = scan_paths([tmp_path], cache_dir=cache_dir, use_cache=True)
    assert len(findings2) == len(findings1)
    for f1, f2 in zip(findings1, findings2):
        assert f1.pattern == f2.pattern
        assert f1.line == f2.line
        assert f1.message == f2.message


def test_cache_miss_on_file_change(tmp_path):
    """Modifying a file should invalidate its cache entry."""
    p = _write_py(tmp_path, """\
        def process(items=[]):
            pass
    """)
    cache_dir = tmp_path / ".smellcheck-cache"

    # First scan
    findings1 = scan_paths([tmp_path], cache_dir=cache_dir, use_cache=True)
    assert any(f.pattern == "SC701" for f in findings1)

    # Modify file to remove the smell
    p.write_text("def process(items=None):\n    pass\n", encoding="utf-8")

    # Second scan — cache miss, new results
    findings2 = scan_paths([tmp_path], cache_dir=cache_dir, use_cache=True)
    assert not any(f.pattern == "SC701" for f in findings2)

    # Should now have 2 cache files (old stale + new)
    cache_files = list(cache_dir.glob("*.json"))
    assert len(cache_files) == 2


def test_no_cache_flag_disables_caching(tmp_path):
    """use_cache=False should not create a cache directory."""
    _write_py(tmp_path, """\
        x = 1
    """)
    cache_dir = tmp_path / ".smellcheck-cache"

    scan_paths([tmp_path], cache_dir=cache_dir, use_cache=False)
    assert not cache_dir.exists()


def test_clear_cache(tmp_path):
    """_clear_cache should remove all .json files from cache dir."""
    cache_dir = tmp_path / ".smellcheck-cache"
    cache_dir.mkdir()
    (cache_dir / "abc123.json").write_text("{}", encoding="utf-8")
    (cache_dir / "def456.json").write_text("{}", encoding="utf-8")

    removed = _clear_cache(cache_dir)
    assert removed == 2
    assert list(cache_dir.glob("*.json")) == []


def test_cache_invalidated_by_config_change(tmp_path):
    """Different config should produce different cache keys."""
    p = _write_py(tmp_path, """\
        def process(items=[]):
            pass
    """)
    source = p.read_text(encoding="utf-8")

    key1 = _cache_key(source, _config_hash(None), "0.3.2")
    key2 = _cache_key(source, _config_hash({"select": ["SC701"]}), "0.3.2")
    assert key1 != key2


def test_cache_invalidated_by_version_change(tmp_path):
    """Different smellcheck version should produce different cache keys."""
    p = _write_py(tmp_path, """\
        x = 1
    """)
    source = p.read_text(encoding="utf-8")
    cfg = _config_hash(None)

    key1 = _cache_key(source, cfg, "0.3.1")
    key2 = _cache_key(source, cfg, "0.3.2")
    assert key1 != key2


def test_finding_serialization_roundtrip():
    """Finding should survive JSON serialization/deserialization."""
    f = Finding(
        file="test.py",
        line=42,
        pattern="SC701",
        name="Mutable Default",
        severity="error",
        message="mutable default arg",
        category="idioms",
        scope="file",
    )
    d = _serialize_finding(f)
    f2 = _deserialize_finding(d)
    assert f == f2


def test_file_data_serialization_roundtrip(tmp_path):
    """FileData should survive serialization via scan_file."""
    p = _write_py(tmp_path, """\
        class Foo:
            def __init__(self):
                self.x = 1
            def bar(self):
                return self.x
    """)
    findings, fd = scan_file(p)
    assert fd is not None

    serialized = _serialize_file_data(fd)
    restored = _deserialize_file_data(serialized)

    assert restored.filepath == fd.filepath
    assert restored.toplevel_defs == fd.toplevel_defs
    assert restored.class_names == fd.class_names
    assert restored.total_lines == fd.total_lines


def test_cache_cross_file_still_runs(tmp_path):
    """Cross-file analysis must still run even when per-file results are cached."""
    # Create two files that import each other (cyclic import)
    (tmp_path / "a.py").write_text(
        "import b\ndef fa(): pass\n", encoding="utf-8"
    )
    (tmp_path / "b.py").write_text(
        "import a\ndef fb(): pass\n", encoding="utf-8"
    )
    cache_dir = tmp_path / ".smellcheck-cache"

    # First scan — populates cache + cross-file
    findings1 = scan_paths([tmp_path], cache_dir=cache_dir, use_cache=True)
    cyclic1 = [f for f in findings1 if f.pattern == "SC503"]
    assert len(cyclic1) > 0, "Expected cyclic import findings"

    # Second scan — per-file cached, cross-file must still run
    findings2 = scan_paths([tmp_path], cache_dir=cache_dir, use_cache=True)
    cyclic2 = [f for f in findings2 if f.pattern == "SC503"]

    assert len(cyclic2) == len(cyclic1)


def test_cli_no_cache(tmp_path):
    """--no-cache flag should work via CLI."""
    _write_py(tmp_path, "x = 1\n")
    result = _run_cli(str(tmp_path), "--no-cache")
    assert result.returncode == 0
    assert not (tmp_path / _DEFAULT_CACHE_DIR).exists()


def test_cli_cache_dir(tmp_path):
    """--cache-dir should use the specified directory."""
    _write_py(tmp_path, "x = 1\n")
    custom_cache = tmp_path / "my-cache"
    result = _run_cli(str(tmp_path), "--cache-dir", str(custom_cache))
    assert result.returncode == 0
    assert custom_cache.is_dir()


def test_cli_clear_cache(tmp_path):
    """--clear-cache should remove cache entries and exit."""
    cache_dir = tmp_path / _DEFAULT_CACHE_DIR
    cache_dir.mkdir()
    (cache_dir / "test.json").write_text("{}", encoding="utf-8")

    result = _run_cli("--clear-cache", "--cache-dir", str(cache_dir))
    assert result.returncode == 0
    assert "Cleared 1" in result.stdout
    assert list(cache_dir.glob("*.json")) == []


def test_corrupted_cache_treated_as_miss(tmp_path):
    """Corrupted cache file should be silently ignored (cache miss)."""
    p = _write_py(tmp_path, """\
        def process(items=[]):
            pass
    """)
    cache_dir = tmp_path / ".smellcheck-cache"
    cache_dir.mkdir()

    # Write a corrupted cache file
    (cache_dir / "fake.json").write_text("NOT JSON", encoding="utf-8")

    # Scan should work fine, treating corrupted entry as miss
    findings = scan_paths([tmp_path], cache_dir=cache_dir, use_cache=True)
    assert any(f.pattern == "SC701" for f in findings)


# ---------------------------------------------------------------------------
# Block-level suppression tests
# ---------------------------------------------------------------------------


def test_block_disable_enable(tmp_path):
    """# smellcheck: disable SC701 ... # smellcheck: enable SC701"""
    _write_py(tmp_path, """\
        # smellcheck: disable SC701
        def foo(x=[]):
            return x
        # smellcheck: enable SC701

        def bar(y=[]):
            return y
    """)
    findings = scan_paths([tmp_path], use_cache=False)
    sc701 = [f for f in findings if f.pattern == "SC701"]
    # foo should be suppressed, bar should NOT be suppressed
    assert len(sc701) == 1
    assert "bar" in sc701[0].message or sc701[0].line > 5


def test_block_disable_multiple_codes(tmp_path):
    """# smellcheck: disable SC701, SC206 suppresses both codes."""
    _write_py(tmp_path, """\
        # smellcheck: disable SC701, SC206
        def foo(a, b, c, d, e, f, g=[]):
            return a
        # smellcheck: enable SC701, SC206

        def bar(a, b, c, d, e, f, g=[]):
            return a
    """)
    findings = scan_paths([tmp_path], use_cache=False)
    # foo should have no SC701 or SC206; bar should have both
    suppressed_region = [f for f in findings if f.line <= 4]
    unsuppressed_region = [f for f in findings if f.line > 5]
    assert not any(f.pattern in ("SC701", "SC206") for f in suppressed_region)
    assert any(f.pattern == "SC701" for f in unsuppressed_region)
    assert any(f.pattern == "SC206" for f in unsuppressed_region)


def test_block_enable_partial(tmp_path):
    """enable SC701 re-enables only SC701; SC206 stays suppressed."""
    _write_py(tmp_path, """\
        # smellcheck: disable SC701, SC206
        def foo(a, b, c, d, e, f, g=[]):
            return a
        # smellcheck: enable SC701

        def bar(a, b, c, d, e, f, h=[]):
            return a
        # smellcheck: enable SC206
    """)
    findings = scan_paths([tmp_path], use_cache=False)
    bar_findings = [f for f in findings if f.line >= 6 and f.line <= 8]
    # bar should have SC701 (re-enabled) but NOT SC206 (still disabled)
    assert any(f.pattern == "SC701" for f in bar_findings)
    assert not any(f.pattern == "SC206" for f in bar_findings)


def test_block_disable_all_enable_all(tmp_path):
    """# smellcheck: disable-all suppresses everything."""
    _write_py(tmp_path, """\
        # smellcheck: disable-all
        def foo(x=[]):
            return x
        # smellcheck: enable-all

        def bar(y=[]):
            return y
    """)
    findings = scan_paths([tmp_path], use_cache=False)
    # foo region should have nothing; bar should have SC701
    suppressed = [f for f in findings if f.line <= 4]
    assert len(suppressed) == 0
    assert any(f.pattern == "SC701" for f in findings if f.line > 5)


def test_block_disable_file_specific_codes(tmp_path):
    """# smellcheck: disable-file SC701 suppresses SC701 for entire file."""
    _write_py(tmp_path, """\
        # smellcheck: disable-file SC701

        def foo(x=[]):
            return x

        def bar(y=[]):
            return y
    """)
    findings = scan_paths([tmp_path], use_cache=False)
    assert not any(f.pattern == "SC701" for f in findings)


def test_block_disable_file_all(tmp_path):
    """# smellcheck: disable-file (no codes) suppresses everything."""
    _write_py(tmp_path, """\
        # smellcheck: disable-file

        def foo(x=[]):
            return x
        MAGIC = 42
    """)
    findings = scan_paths([tmp_path], use_cache=False)
    assert len(findings) == 0


def test_block_unterminated_disable(tmp_path):
    """Unterminated disable applies to end of file."""
    _write_py(tmp_path, """\
        # smellcheck: disable SC701
        def foo(x=[]):
            return x

        def bar(y=[]):
            return y
    """)
    findings = scan_paths([tmp_path], use_cache=False)
    # Both foo and bar SC701 should be suppressed (no enable before EOF)
    assert not any(f.pattern == "SC701" for f in findings)


def test_block_noqa_still_works_with_block(tmp_path):
    """Per-line # noqa still works alongside block directives."""
    _write_py(tmp_path, """\
        def foo(x=[]):  # noqa: SC701
            return x

        def bar(y=[]):
            return y
    """)
    findings = scan_paths([tmp_path], use_cache=False)
    sc701 = [f for f in findings if f.pattern == "SC701"]
    # foo suppressed by noqa, bar not suppressed
    assert len(sc701) == 1
    assert sc701[0].line > 3


def test_block_unknown_codes_ignored(tmp_path):
    """Unknown SC codes in directives don't crash."""
    _write_py(tmp_path, """\
        # smellcheck: disable SC999
        def foo(x=[]):
            return x
        # smellcheck: enable SC999
    """)
    findings = scan_paths([tmp_path], use_cache=False)
    # SC999 doesn't exist, so SC701 should still fire
    assert any(f.pattern == "SC701" for f in findings)


def test_block_case_insensitive(tmp_path):
    """Directive codes are case-insensitive."""
    _write_py(tmp_path, """\
        # smellcheck: disable sc701
        def foo(x=[]):
            return x
        # smellcheck: enable sc701
    """)
    findings = scan_paths([tmp_path], use_cache=False)
    assert not any(f.pattern == "SC701" for f in findings)


def test_block_whitespace_tolerance(tmp_path):
    """Various whitespace patterns in directives are accepted."""
    _write_py(tmp_path, """\
        #smellcheck: disable SC701
        def foo(x=[]):
            return x
        #  smellcheck:  enable  SC701

        # smellcheck:disable SC701
        def bar(y=[]):
            return y
        # smellcheck:enable SC701
    """)
    findings = scan_paths([tmp_path], use_cache=False)
    assert not any(f.pattern == "SC701" for f in findings)


def test_block_cross_file_not_affected(tmp_path):
    """Block directives do NOT suppress cross-file findings."""
    # Create two files that import each other (cyclic import = SC503)
    a = tmp_path / "mod_a.py"
    b = tmp_path / "mod_b.py"
    a.write_text(
        "# smellcheck: disable SC503\nimport mod_b\n# smellcheck: enable SC503\n",
        encoding="utf-8",
    )
    b.write_text("import mod_a\n", encoding="utf-8")
    findings = scan_paths([tmp_path], use_cache=False)
    sc503 = [f for f in findings if f.pattern == "SC503"]
    # SC503 is cross-file scope — block directives should NOT suppress it
    assert len(sc503) > 0


def test_block_disable_file_does_suppress_cross_file(tmp_path):
    """disable-file DOES suppress cross-file findings (file-level intent)."""
    a = tmp_path / "mod_a.py"
    b = tmp_path / "mod_b.py"
    a.write_text(
        "# smellcheck: disable-file SC503\nimport mod_b\n",
        encoding="utf-8",
    )
    b.write_text("import mod_a\n", encoding="utf-8")
    findings = scan_paths([tmp_path], use_cache=False)
    # SC503 reported on mod_a should be suppressed; mod_b's finding may remain
    sc503_a = [f for f in findings if f.pattern == "SC503" and "mod_a" in f.file]
    assert len(sc503_a) == 0


def test_parse_block_directives_unit():
    """Unit test for _parse_block_directives with known lines."""
    lines = [
        "# smellcheck: disable SC701",      # line 1
        "def foo(x=[]):",                     # line 2
        "    return x",                       # line 3
        "# smellcheck: enable SC701",        # line 4
        "def bar(y=[]):",                     # line 5
    ]
    block_map, daf, fdc = _parse_block_directives(lines)
    assert not daf
    assert len(fdc) == 0
    assert "SC701" in block_map
    ranges = block_map["SC701"]
    assert len(ranges) == 1
    start, end = ranges[0]
    assert start == 1 and end == 4
    # Line 2 is in range [1, 4)
    assert start <= 2 < end
    # Line 5 is NOT in range
    assert not (start <= 5 < end)


def test_parse_block_directives_disable_all():
    """Unit test for disable-all / enable-all."""
    lines = [
        "# smellcheck: disable-all",  # line 1
        "x = 42",                      # line 2
        "# smellcheck: enable-all",   # line 3
        "y = 99",                      # line 4
    ]
    block_map, daf, fdc = _parse_block_directives(lines)
    assert not daf
    assert "*" in block_map
    start, end = block_map["*"][0]
    assert start == 1 and end == 3


def test_parse_block_directives_disable_file():
    """Unit test for disable-file."""
    lines = [
        "# smellcheck: disable-file SC301, SC305",
        "class Foo:",
        "    pass",
    ]
    block_map, daf, fdc = _parse_block_directives(lines)
    assert not daf
    assert "SC301" in fdc
    assert "SC305" in fdc


def test_parse_block_directives_disable_file_all():
    """Unit test for disable-file with no codes (suppress all)."""
    lines = [
        "# smellcheck: disable-file",
        "class Foo:",
        "    pass",
    ]
    block_map, daf, fdc = _parse_block_directives(lines)
    assert daf is True
    assert len(fdc) == 0


def test_parse_block_directives_enable_all_without_disable_all():
    """enable-all without prior disable-all is a no-op."""
    lines = [
        "# smellcheck: disable SC701",       # line 1
        "def foo(x=[]):",                      # line 2
        "# smellcheck: enable-all",           # line 3 — no disable-all active
        "def bar(y=[]):",                      # line 4
    ]
    block_map, daf, fdc = _parse_block_directives(lines)
    assert not daf
    # SC701 range should extend to EOF (unterminated, not closed by enable-all)
    assert "SC701" in block_map
    start, end = block_map["SC701"][0]
    assert start == 1 and end == 5  # total + 1


def test_parse_block_directives_duplicate_disable():
    """Double disable of same code is idempotent."""
    lines = [
        "# smellcheck: disable SC701",  # line 1
        "# smellcheck: disable SC701",  # line 2 — redundant, ignored
        "def foo(x=[]):",                # line 3
        "# smellcheck: enable SC701",   # line 4
    ]
    block_map, daf, fdc = _parse_block_directives(lines)
    assert "SC701" in block_map
    # Only one range (first disable to the enable)
    assert len(block_map["SC701"]) == 1
    start, end = block_map["SC701"][0]
    assert start == 1 and end == 4


def test_block_disable_all_with_trailing_codes(tmp_path):
    """disable-all with trailing codes ignores the codes (suppresses all)."""
    _write_py(tmp_path, """\
        # smellcheck: disable-all SC701
        def foo(x=[]):
            return x
        MAGIC = 42
        # smellcheck: enable-all
    """)
    findings = scan_paths([tmp_path], use_cache=False)
    suppressed = [f for f in findings if f.line <= 5]
    assert len(suppressed) == 0


def test_explain_mentions_block_suppression(tmp_path):
    """--explain output includes block suppression syntax."""
    result = _run_cli("--explain", "SC701")
    assert result.returncode == 0
    assert "smellcheck: disable" in result.stdout
    assert "smellcheck: enable" in result.stdout
    assert "smellcheck: disable-file" in result.stdout


# ---------------------------------------------------------------------------
# Diff-aware scanning (--diff / --changed-only)
# ---------------------------------------------------------------------------


def _git(tmp_path: Path, *args: str) -> str:
    """Run a git command in tmp_path and return stdout."""
    result = subprocess.run(
        ["git", *args],
        capture_output=True,
        text=True,
        cwd=tmp_path,
        check=True,
    )
    return result.stdout.strip()


def _init_git_repo(tmp_path: Path) -> None:
    """Initialise a git repo with one committed Python file."""
    _git(tmp_path, "init")
    _git(tmp_path, "config", "user.email", "test@test.com")
    _git(tmp_path, "config", "user.name", "Test")
    # Initial commit with a clean file
    clean = tmp_path / "clean.py"
    clean.write_text("x = 1\n", encoding="utf-8")
    _git(tmp_path, "add", ".")
    _git(tmp_path, "commit", "-m", "initial")


def test_diff_scans_only_changed_files(tmp_path):
    """--diff should only scan files changed since the given ref."""
    _init_git_repo(tmp_path)
    # Add a smelly file after the initial commit
    smelly = tmp_path / "smelly.py"
    smelly.write_text(
        textwrap.dedent("""\
        def foo(a, b, c, d, e, f, g, h):
            pass
        """),
        encoding="utf-8",
    )
    _git(tmp_path, "add", ".")
    _git(tmp_path, "commit", "-m", "add smelly")
    result = _run_cli(str(tmp_path), "--diff", "HEAD~1", cwd=tmp_path)
    # Should find something in smelly.py but NOT in clean.py
    assert "smelly.py" in result.stdout
    assert "clean.py" not in result.stdout


def test_diff_unchanged_files_excluded(tmp_path):
    """Files present but not changed since ref should not appear."""
    _init_git_repo(tmp_path)
    # No new changes — diff vs HEAD should find nothing
    result = _run_cli(str(tmp_path), "--diff", "HEAD", cwd=tmp_path)
    assert result.returncode == 0
    assert "No changed Python files found" in result.stderr


def test_changed_only_alias(tmp_path):
    """--changed-only is shorthand for --diff HEAD."""
    _init_git_repo(tmp_path)
    # Modify a tracked file (unstaged)
    smelly = tmp_path / "clean.py"
    smelly.write_text(
        textwrap.dedent("""\
        def foo(a, b, c, d, e, f, g, h):
            pass
        """),
        encoding="utf-8",
    )
    result = _run_cli(str(tmp_path), "--changed-only", cwd=tmp_path)
    assert "clean.py" in (result.stdout + result.stderr)


def test_diff_no_changed_files_exits_zero(tmp_path):
    """When --diff finds no changed .py files, exit 0 with a message."""
    _init_git_repo(tmp_path)
    result = _run_cli(str(tmp_path), "--diff", "HEAD", cwd=tmp_path)
    assert result.returncode == 0
    assert "No changed Python files found" in result.stderr


def test_diff_not_a_git_repo(tmp_path):
    """--diff outside a git repo should exit 1 with a clear error."""
    # tmp_path is NOT a git repo
    p = tmp_path / "sample.py"
    p.write_text("x = 1\n", encoding="utf-8")
    result = _run_cli(str(tmp_path), "--diff", "HEAD", cwd=tmp_path)
    assert result.returncode == 1
    assert "git repository" in result.stderr.lower()


def test_diff_path_intersection(tmp_path):
    """--diff should intersect with positional path args."""
    _init_git_repo(tmp_path)
    subdir = tmp_path / "sub"
    subdir.mkdir()
    # Create files in two directories
    (tmp_path / "root_change.py").write_text("x = 1\n", encoding="utf-8")
    (subdir / "sub_change.py").write_text(
        textwrap.dedent("""\
        def foo(a, b, c, d, e, f, g, h):
            pass
        """),
        encoding="utf-8",
    )
    _git(tmp_path, "add", ".")
    _git(tmp_path, "commit", "-m", "add files")
    # Only scan sub/ — root_change.py should be excluded even though it's changed
    result = _run_cli(str(subdir), "--diff", "HEAD~1", cwd=tmp_path)
    assert "sub_change.py" in result.stdout
    assert "root_change.py" not in result.stdout


def test_diff_mutually_exclusive_with_generate_baseline(tmp_path):
    """--diff and --generate-baseline cannot be used together."""
    _init_git_repo(tmp_path)
    result = _run_cli(
        str(tmp_path), "--diff", "HEAD", "--generate-baseline", cwd=tmp_path
    )
    assert result.returncode == 1
    assert "mutually exclusive" in result.stderr


def test_diff_composes_with_fail_on(tmp_path):
    """--diff works alongside --fail-on."""
    _init_git_repo(tmp_path)
    smelly = tmp_path / "smelly.py"
    smelly.write_text(
        textwrap.dedent("""\
        def foo(a, b, c, d, e, f, g, h):
            pass
        """),
        encoding="utf-8",
    )
    _git(tmp_path, "add", ".")
    _git(tmp_path, "commit", "-m", "add smelly")
    result = _run_cli(
        str(tmp_path), "--diff", "HEAD~1", "--fail-on", "info", cwd=tmp_path
    )
    # Should exit 1 because there are findings at info+ level
    assert result.returncode == 1


def test_get_changed_files_unit(tmp_path):
    """Unit test for _get_changed_files."""
    _init_git_repo(tmp_path)
    new_file = tmp_path / "new.py"
    new_file.write_text("y = 2\n", encoding="utf-8")
    _git(tmp_path, "add", ".")
    _git(tmp_path, "commit", "-m", "add new")
    changed = _get_changed_files("HEAD~1", [tmp_path])
    assert any(p.name == "new.py" for p in changed)
    assert not any(p.name == "clean.py" for p in changed)


def test_diff_help_text():
    """--help should mention --diff and --changed-only."""
    result = _run_cli("--help")
    assert "--diff" in result.stdout
    assert "--changed-only" in result.stdout


def test_diff_invalid_ref(tmp_path):
    """--diff with a nonexistent ref should exit 1 with a git error."""
    _init_git_repo(tmp_path)
    result = _run_cli(str(tmp_path), "--diff", "nonexistent_ref", cwd=tmp_path)
    assert result.returncode == 1
    assert "git diff failed" in result.stderr


def test_diff_file_with_spaces(tmp_path):
    """--diff handles files with spaces in their names."""
    _init_git_repo(tmp_path)
    spaced = tmp_path / "my file.py"
    spaced.write_text("x = 1\n", encoding="utf-8")
    _git(tmp_path, "add", ".")
    _git(tmp_path, "commit", "-m", "add spaced")
    result = _run_cli(str(tmp_path), "--diff", "HEAD~1", cwd=tmp_path)
    assert result.returncode == 0
    assert "No changed Python files found" not in result.stderr


def test_diff_composes_with_baseline(tmp_path):
    """--diff works alongside --baseline."""
    _init_git_repo(tmp_path)
    smelly = tmp_path / "smelly.py"
    smelly.write_text(
        textwrap.dedent("""\
        def foo(a, b, c, d, e, f, g, h):
            pass
        """),
        encoding="utf-8",
    )
    _git(tmp_path, "add", ".")
    _git(tmp_path, "commit", "-m", "add smelly")
    # Generate baseline that captures the finding
    baseline = _run_cli(str(tmp_path), "--generate-baseline", cwd=tmp_path)
    baseline_file = tmp_path / ".smellcheck-baseline.json"
    baseline_file.write_text(baseline.stdout, encoding="utf-8")
    # Run with --diff + --baseline — finding should be suppressed
    result = _run_cli(
        str(tmp_path), "--diff", "HEAD~1",
        "--baseline", str(baseline_file), cwd=tmp_path,
    )
    assert "0 new findings" in result.stderr


def test_diff_composes_with_select(tmp_path):
    """--diff works alongside --select to filter checks."""
    _init_git_repo(tmp_path)
    smelly = tmp_path / "smelly.py"
    smelly.write_text(
        textwrap.dedent("""\
        def foo(a, b, c, d, e, f, g, h):
            pass
        """),
        encoding="utf-8",
    )
    _git(tmp_path, "add", ".")
    _git(tmp_path, "commit", "-m", "add smelly")
    # Select only a rule that won't match (SC101 = mutable default)
    result = _run_cli(
        str(tmp_path), "--diff", "HEAD~1", "--select", "SC101", cwd=tmp_path,
    )
    # SC202 (too many params) should NOT appear because we selected only SC101
    assert "SC202" not in result.stdout


def test_diff_and_changed_only_warns(tmp_path):
    """--diff and --changed-only together warns that --changed-only is ignored."""
    _init_git_repo(tmp_path)
    result = _run_cli(
        str(tmp_path), "--diff", "HEAD", "--changed-only", cwd=tmp_path
    )
    assert "ignored" in result.stderr.lower()


# ---------------------------------------------------------------------------
# JUnit XML output format
# ---------------------------------------------------------------------------


def test_junit_valid_xml(tmp_path, capsys):
    """JUnit output must be well-formed XML."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="junit")
    out = capsys.readouterr().out
    assert out.startswith('<?xml version="1.0"')
    root = ET.fromstring(out)
    assert root.tag == "testsuites"


def test_junit_structure(tmp_path, capsys):
    """JUnit has testsuites -> testsuite -> testcase -> failure hierarchy."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="junit")
    root = ET.fromstring(capsys.readouterr().out)
    assert root.attrib["name"] == "smellcheck"
    suites = root.findall("testsuite")
    assert len(suites) >= 1
    ts = suites[0]
    assert "name" in ts.attrib
    assert "tests" in ts.attrib
    cases = ts.findall("testcase")
    assert len(cases) >= 1
    tc = cases[0]
    assert "name" in tc.attrib
    assert "classname" in tc.attrib
    failure = tc.find("failure")
    assert failure is not None
    assert "message" in failure.attrib
    assert "type" in failure.attrib


def test_junit_file_grouping(tmp_path, capsys):
    """Each file gets its own testsuite; each finding a testcase."""
    _write_py(tmp_path, "def foo(x=[]): pass\n", name="a.py")
    _write_py(tmp_path, "def bar(y={}): pass\n", name="b.py")
    findings = scan_path(tmp_path)
    print_findings(findings, output_format="junit")
    root = ET.fromstring(capsys.readouterr().out)
    suites = root.findall("testsuite")
    suite_names = [s.attrib["name"] for s in suites]
    assert len(suites) >= 2
    assert any("a.py" in n for n in suite_names)
    assert any("b.py" in n for n in suite_names)


def test_junit_severity_in_failure_type(tmp_path, capsys):
    """The failure 'type' attribute reflects the finding severity."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="junit")
    root = ET.fromstring(capsys.readouterr().out)
    failures = root.findall(".//failure")
    types = {f.attrib["type"] for f in failures}
    assert types <= {"error", "warning", "info"}


def test_junit_counts_correct(tmp_path, capsys):
    """Tests and failures counts match the actual number of findings."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="junit")
    root = ET.fromstring(capsys.readouterr().out)
    total = int(root.attrib["tests"])
    failures = int(root.attrib["failures"])
    assert total == failures
    assert total == len(root.findall(".//testcase"))


def test_junit_empty_findings(capsys):
    """JUnit with no findings produces valid XML with zero counts."""
    print_findings([], output_format="junit")
    root = ET.fromstring(capsys.readouterr().out)
    assert root.attrib["tests"] == "0"
    assert root.attrib["failures"] == "0"
    assert root.findall("testsuite") == []


def test_cli_format_junit(tmp_path):
    """CLI --format junit produces valid XML to stdout."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    result = _run_cli(str(p), "--format", "junit")
    assert result.stdout.startswith('<?xml version="1.0"')
    root = ET.fromstring(result.stdout)
    assert root.tag == "testsuites"


def test_junit_classname_no_py_suffix(tmp_path, capsys):
    """Classname strips .py suffix and converts slashes to dots."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="junit")
    root = ET.fromstring(capsys.readouterr().out)
    tc = root.findall(".//testcase")[0]
    classname = tc.attrib["classname"]
    assert not classname.endswith(".py")
    assert "/" not in classname


# ---------------------------------------------------------------------------
# GitLab CodeClimate output format
# ---------------------------------------------------------------------------


def test_gitlab_valid_json_array(tmp_path, capsys):
    """GitLab output must be a JSON array."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="gitlab")
    out = capsys.readouterr().out
    data = json.loads(out)
    assert isinstance(data, list)
    assert len(data) >= 1


def test_gitlab_issue_fields(tmp_path, capsys):
    """Each CodeClimate issue has the required fields."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="gitlab")
    data = json.loads(capsys.readouterr().out)
    issue = data[0]
    assert issue["type"] == "issue"
    assert "check_name" in issue
    assert "description" in issue
    assert "categories" in issue
    assert isinstance(issue["categories"], list)
    assert "severity" in issue
    assert "fingerprint" in issue
    loc = issue["location"]
    assert "path" in loc
    assert "lines" in loc
    assert "begin" in loc["lines"]


def test_gitlab_severity_mapping(tmp_path, capsys):
    """Smellcheck severities map to CodeClimate severities."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="gitlab")
    data = json.loads(capsys.readouterr().out)
    severities = {d["severity"] for d in data}
    assert severities <= {"critical", "major", "minor"}
    # SC701 is error → critical
    sc701 = [d for d in data if d["check_name"] == "SC701"]
    if sc701:
        assert sc701[0]["severity"] == "critical"


def test_gitlab_category_mapping(tmp_path, capsys):
    """Families map to CodeClimate categories."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="gitlab")
    data = json.loads(capsys.readouterr().out)
    categories = {cat for d in data for cat in d["categories"]}
    assert categories <= {"Style", "Complexity", "Bug Risk", "Duplication"}


def test_gitlab_fingerprint_stable(tmp_path, capsys):
    """Same finding produces the same fingerprint across calls."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="gitlab")
    fp1 = json.loads(capsys.readouterr().out)[0]["fingerprint"]
    print_findings(findings, output_format="gitlab")
    fp2 = json.loads(capsys.readouterr().out)[0]["fingerprint"]
    assert fp1 == fp2
    assert len(fp1) == 32  # md5 hex digest


def test_gitlab_relative_paths(tmp_path, capsys):
    """Paths in GitLab output are relative, not absolute."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    findings = scan_path(p)
    print_findings(findings, output_format="gitlab")
    data = json.loads(capsys.readouterr().out)
    for issue in data:
        assert not issue["location"]["path"].startswith("/")


def test_gitlab_empty_findings(capsys):
    """Empty findings produce an empty JSON array."""
    print_findings([], output_format="gitlab")
    data = json.loads(capsys.readouterr().out)
    assert data == []


def test_cli_format_gitlab(tmp_path):
    """CLI --format gitlab produces valid CodeClimate JSON."""
    p = _write_py(tmp_path, "def foo(x=[]): pass\n")
    result = _run_cli(str(p), "--format", "gitlab")
    data = json.loads(result.stdout)
    assert isinstance(data, list)
    assert len(data) >= 1
    assert data[0]["type"] == "issue"


def test_cli_format_invalid_rejects():
    """Unknown format values are rejected with a clear error."""
    result = _run_cli(".", "--format", "csv")
    assert result.returncode != 0
    assert "invalid format" in result.stderr.lower()
    assert "junit" in result.stderr
    assert "gitlab" in result.stderr


# ---------------------------------------------------------------------------
# Config extends / inheritance — merge function
# ---------------------------------------------------------------------------


def test_merge_configs_override_select():
    """Child select replaces parent entirely."""
    base = {"select": ["SC101", "SC201"]}
    override = {"select": ["SC701"]}
    merged = _merge_smellcheck_configs(base, override)
    assert merged["select"] == ["SC701"]


def test_merge_configs_union_ignore():
    """Ignore lists are unioned and deduplicated."""
    base = {"ignore": ["SC601", "SC202"]}
    override = {"ignore": ["SC202", "SC301"]}
    merged = _merge_smellcheck_configs(base, override)
    assert merged["ignore"] == ["SC601", "SC202", "SC301"]


def test_merge_configs_deep_merge_per_file_ignores():
    """Per-file-ignores are deep merged: same glob unions codes."""
    base = {"per-file-ignores": {"tests/*": ["SC201"], "docs/*": ["SC101"]}}
    override = {"per-file-ignores": {"tests/*": ["SC206", "SC201"], "src/*": ["SC301"]}}
    merged = _merge_smellcheck_configs(base, override)
    pfi = merged["per-file-ignores"]
    assert pfi["tests/*"] == ["SC201", "SC206"]  # union, deduped
    assert pfi["docs/*"] == ["SC101"]  # from base only
    assert pfi["src/*"] == ["SC301"]  # from override only


def test_merge_configs_scalar_override():
    """Scalar values (fail-on, format) are overridden by child."""
    base = {"fail-on": "error", "format": "text"}
    override = {"fail-on": "warning"}
    merged = _merge_smellcheck_configs(base, override)
    assert merged["fail-on"] == "warning"
    assert merged["format"] == "text"  # kept from base


def test_merge_configs_extends_stripped():
    """The extends key is never present in merged output."""
    base = {"ignore": ["SC601"], "extends": "grandparent.toml"}
    override = {"ignore": ["SC202"], "extends": "base.toml"}
    merged = _merge_smellcheck_configs(base, override)
    assert "extends" not in merged


# ---------------------------------------------------------------------------
# Config extends / inheritance — resolve function
# ---------------------------------------------------------------------------


def _write_toml(path: Path, content: str) -> Path:
    path.write_text(textwrap.dedent(content), encoding="utf-8")
    return path


def test_extends_single_file(tmp_path):
    """Basic single-file inheritance."""
    _write_toml(tmp_path / "base.toml", """\
        [tool.smellcheck]
        ignore = ["SC601"]
        fail-on = "warning"
    """)
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = "base.toml"
        ignore = ["SC202"]
    """)
    config = load_config(tmp_path)
    assert config["fail-on"] == "warning"
    assert "SC601" in config["ignore"]
    assert "SC202" in config["ignore"]
    assert "extends" not in config


def test_extends_multiple_files(tmp_path):
    """List of bases; later wins for scalar values."""
    _write_toml(tmp_path / "base.toml", """\
        [tool.smellcheck]
        fail-on = "error"
        ignore = ["SC601"]
    """)
    _write_toml(tmp_path / "strict.toml", """\
        [tool.smellcheck]
        fail-on = "warning"
        ignore = ["SC202"]
    """)
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = ["base.toml", "strict.toml"]
    """)
    config = load_config(tmp_path)
    assert config["fail-on"] == "warning"  # strict wins over base
    assert set(config["ignore"]) == {"SC601", "SC202"}


def test_extends_recursive_chain(tmp_path):
    """Grandparent → parent → project chain."""
    _write_toml(tmp_path / "grandparent.toml", """\
        [tool.smellcheck]
        ignore = ["SC101"]
        fail-on = "error"
    """)
    _write_toml(tmp_path / "parent.toml", """\
        [tool.smellcheck]
        extends = "grandparent.toml"
        ignore = ["SC202"]
    """)
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = "parent.toml"
        ignore = ["SC301"]
    """)
    config = load_config(tmp_path)
    assert set(config["ignore"]) == {"SC101", "SC202", "SC301"}
    assert config["fail-on"] == "error"


def test_extends_relative_path(tmp_path):
    """Paths are resolved relative to the file containing extends."""
    subdir = tmp_path / "configs"
    subdir.mkdir()
    _write_toml(subdir / "base.toml", """\
        [tool.smellcheck]
        ignore = ["SC601"]
    """)
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = "configs/base.toml"
    """)
    config = load_config(tmp_path)
    assert "SC601" in config["ignore"]


def test_extends_cycle_detection(tmp_path, capsys):
    """Circular chain warns to stderr and doesn't loop."""
    _write_toml(tmp_path / "a.toml", """\
        [tool.smellcheck]
        extends = "b.toml"
        ignore = ["SC101"]
    """)
    _write_toml(tmp_path / "b.toml", """\
        [tool.smellcheck]
        extends = "a.toml"
        ignore = ["SC202"]
    """)
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = "a.toml"
    """)
    config = load_config(tmp_path)
    captured = capsys.readouterr()
    assert "circular" in captured.err
    # Should still have the non-circular configs merged
    assert "extends" not in config


def test_extends_depth_limit(tmp_path, capsys):
    """Chains deeper than _MAX_EXTENDS_CHAIN_DEPTH warn and stop."""
    # Create a chain deeper than the limit
    for i in range(_MAX_EXTENDS_CHAIN_DEPTH + 2):
        name = f"level{i}.toml"
        if i < _MAX_EXTENDS_CHAIN_DEPTH + 1:
            next_name = f"level{i + 1}.toml"
            _write_toml(tmp_path / name, f"""\
                [tool.smellcheck]
                extends = "{next_name}"
                ignore = ["SC{i:03d}"]
            """)
        else:
            _write_toml(tmp_path / name, f"""\
                [tool.smellcheck]
                ignore = ["SC{i:03d}"]
            """)
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = "level0.toml"
    """)
    config = load_config(tmp_path)
    captured = capsys.readouterr()
    assert "depth limit" in captured.err
    assert "extends" not in config


def test_extends_missing_file_warning(tmp_path, capsys):
    """Missing base file is skipped with a warning; own config still works."""
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = "nonexistent.toml"
        ignore = ["SC701"]
    """)
    config = load_config(tmp_path)
    captured = capsys.readouterr()
    assert "not found" in captured.err
    assert config["ignore"] == ["SC701"]
    assert "extends" not in config


def test_extends_no_smellcheck_section(tmp_path):
    """Base file without [tool.smellcheck] is treated as empty config."""
    _write_toml(tmp_path / "base.toml", """\
        [tool.other]
        key = "value"
    """)
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = "base.toml"
        ignore = ["SC701"]
    """)
    config = load_config(tmp_path)
    assert config["ignore"] == ["SC701"]


def test_extends_invalid_type_warning(tmp_path, capsys):
    """Non-string/list extends value warns and is ignored."""
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = 42
        ignore = ["SC701"]
    """)
    config = load_config(tmp_path)
    captured = capsys.readouterr()
    assert "must be a string or list" in captured.err
    assert config["ignore"] == ["SC701"]
    assert "extends" not in config


def test_extends_list_non_string_entries(tmp_path, capsys):
    """Non-string entries in extends list are skipped with a warning."""
    _write_toml(tmp_path / "base.toml", """\
        [tool.smellcheck]
        ignore = ["SC601"]
    """)
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = [42]
        ignore = ["SC701"]
    """)
    config = load_config(tmp_path)
    captured = capsys.readouterr()
    assert "non-empty strings" in captured.err
    assert config["ignore"] == ["SC701"]
    assert "extends" not in config


def test_extends_absent_no_change(tmp_path):
    """No extends key preserves existing behavior exactly."""
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        ignore = ["SC601"]
        fail-on = "warning"
    """)
    config = load_config(tmp_path)
    assert config == {"ignore": ["SC601"], "fail-on": "warning"}


def test_extends_e2e_scan(tmp_path):
    """Inherited ignore actually filters findings in scan_paths."""
    # base.toml ignores SC701 (mutable default)
    _write_toml(tmp_path / "base.toml", """\
        [tool.smellcheck]
        ignore = ["SC701"]
    """)
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = "base.toml"
    """)
    _write_py(tmp_path, "def foo(x=[]): pass\n")
    config = load_config(tmp_path)
    findings = scan_paths([tmp_path], config=config)
    patterns = [f.pattern for f in findings]
    assert "SC701" not in patterns


def test_extends_empty_string_warning(tmp_path, capsys):
    """Empty extends string warns and is ignored."""
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = ""
        ignore = ["SC701"]
    """)
    config = load_config(tmp_path)
    captured = capsys.readouterr()
    assert "empty" in captured.err
    assert config["ignore"] == ["SC701"]
    assert "extends" not in config


def test_extends_diamond_dependency(tmp_path):
    """Diamond deps: both siblings inherit from same base independently."""
    # common.toml is the shared base
    _write_toml(tmp_path / "common.toml", """\
        [tool.smellcheck]
        ignore = ["SC101"]
        fail-on = "error"
    """)
    # team-base extends common, overrides fail-on
    _write_toml(tmp_path / "team-base.toml", """\
        [tool.smellcheck]
        extends = "common.toml"
        ignore = ["SC202"]
    """)
    # strict also extends common, overrides fail-on
    _write_toml(tmp_path / "strict.toml", """\
        [tool.smellcheck]
        extends = "common.toml"
        fail-on = "warning"
        ignore = ["SC301"]
    """)
    # project extends both
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = ["team-base.toml", "strict.toml"]
    """)
    config = load_config(tmp_path)
    # All ignores from all branches are unioned
    assert set(config["ignore"]) == {"SC101", "SC202", "SC301"}
    # strict.toml is later in the list, so its fail-on wins
    assert config["fail-on"] == "warning"


def test_extends_symlink_cycle_detection(tmp_path, capsys):
    """Symlinked config files that form a cycle are detected."""
    _write_toml(tmp_path / "real.toml", """\
        [tool.smellcheck]
        ignore = ["SC101"]
    """)
    link = tmp_path / "link.toml"
    try:
        link.symlink_to(tmp_path / "real.toml")
    except OSError:
        import pytest
        pytest.skip("symlinks not supported on this platform")
    # real.toml extends link.toml (which is itself), creating a cycle
    _write_toml(tmp_path / "real.toml", """\
        [tool.smellcheck]
        extends = "link.toml"
        ignore = ["SC101"]
    """)
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = "real.toml"
    """)
    config = load_config(tmp_path)
    captured = capsys.readouterr()
    assert "circular" in captured.err
    assert "SC101" in config.get("ignore", [])


def test_extends_path_traversal_allowed(tmp_path):
    """Path traversal in extends is intentionally allowed (ruff/eslint model)."""
    subdir = tmp_path / "project"
    subdir.mkdir()
    _write_toml(tmp_path / "shared.toml", """\
        [tool.smellcheck]
        ignore = ["SC601"]
    """)
    _write_toml(subdir / "pyproject.toml", """\
        [tool.smellcheck]
        extends = "../shared.toml"
    """)
    config = load_config(subdir)
    assert "SC601" in config["ignore"]


def test_extends_absolute_path(tmp_path):
    """Absolute paths in extends work correctly."""
    base = tmp_path / "base.toml"
    _write_toml(base, """\
        [tool.smellcheck]
        ignore = ["SC601"]
    """)
    _write_toml(tmp_path / "pyproject.toml", f"""\
        [tool.smellcheck]
        extends = "{base.resolve().as_posix()}"
    """)
    config = load_config(tmp_path)
    assert "SC601" in config["ignore"]
    assert "extends" not in config


def test_extends_without_tomllib(tmp_path, monkeypatch):
    """When tomllib is unavailable, extends is silently skipped."""
    import smellcheck.detector as det
    _write_toml(tmp_path / "base.toml", """\
        [tool.smellcheck]
        ignore = ["SC601"]
    """)
    _write_toml(tmp_path / "pyproject.toml", """\
        [tool.smellcheck]
        extends = "base.toml"
        ignore = ["SC701"]
    """)
    monkeypatch.setattr(det, "tomllib", None)
    config = load_config(tmp_path)
    # load_config returns {} when tomllib is None
    assert config == {}


def test_merge_per_file_ignores_scalar_codes_warning(capsys):
    """per-file-ignores with scalar codes value warns and skips."""
    base = {}
    override = {"per-file-ignores": {"tests/*": "SC201"}}
    merged = _merge_smellcheck_configs(base, override)
    captured = capsys.readouterr()
    assert "must be a list" in captured.err
    # The bad glob is skipped; per-file-ignores is still present but empty
    assert merged.get("per-file-ignores", {}) == {}


# ---------------------------------------------------------------------------
# --plan / refactoring phases tests
# ---------------------------------------------------------------------------


def test_refactoring_phases_covers_all_rules():
    """Every SC code in _RULE_REGISTRY appears in exactly one phase."""
    phase_codes: list[str] = []
    for phase in _REFACTORING_PHASES:
        phase_codes.extend(phase["rules"])
    assert sorted(phase_codes) == sorted(_RULE_REGISTRY.keys()), (
        "Phase rules must exactly cover _RULE_REGISTRY"
    )
    # No duplicates
    assert len(phase_codes) == len(set(phase_codes)), "Duplicate rule in phases"


def test_rule_to_phase_mapping():
    """_RULE_TO_PHASE has an entry for every rule in the registry."""
    for code in _RULE_REGISTRY:
        assert code in _RULE_TO_PHASE, f"{code} missing from _RULE_TO_PHASE"


def test_group_findings_by_phase():
    """Findings are bucketed into the correct phase."""
    findings = [
        Finding("a.py", 1, "SC701", "x", "error", "msg", "idioms", "file"),
        Finding("a.py", 2, "SC602", "x", "error", "msg", "hygiene", "file"),
        Finding("a.py", 3, "SC401", "x", "warning", "msg", "control", "file"),
    ]
    grouped = _group_findings_by_phase(findings)
    assert 0 in grouped  # SC602, SC701 -> phase 0
    assert 1 in grouped  # SC401 -> phase 1
    assert len(grouped[0]) == 2
    assert len(grouped[1]) == 1


def test_plan_text_output():
    """Text format has headers, strategy, and select commands."""
    findings = [
        Finding("a.py", 1, "SC701", "x", "error", "msg", "idioms", "file"),
        Finding("a.py", 2, "SC201", "x", "warning", "msg", "functions", "file"),
    ]
    plan = _compute_plan(findings)
    text = _format_plan_text(plan)
    assert "Refactoring Plan" in text
    assert "strategy: local_first" in text
    assert "--select" in text
    assert "SC701" in text


def test_plan_json_output():
    """Valid JSON with 9 phases and expected keys including select_cmd."""
    findings = [
        Finding("a.py", 1, "SC602", "x", "error", "msg", "hygiene", "file"),
    ]
    plan = _compute_plan(findings)
    raw = _format_plan_json(plan)
    parsed = json.loads(raw)
    assert len(parsed["phases"]) == 9
    assert "select_cmd" in parsed["phases"][0]
    assert parsed["phases"][0]["select_cmd"] == "SC602,SC701,SC605"


def test_plan_skips_empty_phases():
    """Phases with 0 findings get status=skip."""
    findings = [
        Finding("a.py", 1, "SC701", "x", "error", "msg", "idioms", "file"),
    ]
    plan = _compute_plan(findings)
    # Only phase 0 should be active (SC701)
    active = [p for p in plan["phases"] if p["status"] == "active"]
    skipped = [p for p in plan["phases"] if p["status"] == "skip"]
    assert len(active) == 1
    assert active[0]["number"] == 0
    assert len(skipped) == 8


def test_plan_local_first_strategy():
    """Default strategy for per-file-dominant findings."""
    findings = [
        Finding("a.py", 1, "SC701", "x", "error", "msg", "idioms", "file"),
        Finding("a.py", 2, "SC602", "x", "error", "msg", "hygiene", "file"),
        Finding("a.py", 3, "SC201", "x", "warning", "msg", "functions", "file"),
    ]
    plan = _compute_plan(findings)
    assert plan["strategy"] == "local_first"
    assert plan["phase_order"] == list(range(9))


def test_plan_architecture_first_strategy():
    """Cross-file heavy findings trigger architecture_first reorder."""
    findings = [
        Finding("a.py", 1, "SC503", "x", "warning", "msg", "architecture", "cross_file"),
        Finding("a.py", 2, "SC504", "x", "warning", "msg", "architecture", "cross_file"),
        Finding("a.py", 3, "SC606", "x", "warning", "msg", "hygiene", "cross_file"),
        Finding("a.py", 4, "SC801", "x", "warning", "msg", "metrics", "metric"),
    ]
    plan = _compute_plan(findings)
    assert plan["strategy"] == "architecture_first"
    assert plan["phase_order"] == [0, 1, 7, 8, 2, 3, 4, 5, 6]


def test_plan_no_classes_skips_metrics():
    """When only functional code findings exist, phases 6 and 8 have no findings."""
    findings = [
        Finding("a.py", 1, "SC201", "x", "warning", "msg", "functions", "file"),
        Finding("a.py", 2, "SC402", "x", "warning", "msg", "control", "file"),
    ]
    plan = _compute_plan(findings)
    phase6 = plan["phases"][6]
    phase8 = plan["phases"][8]
    assert phase6["status"] == "skip"
    assert phase8["status"] == "skip"


def test_plan_internal_order():
    """Chain structure is preserved in plan output."""
    findings = [
        Finding("a.py", 1, "SC402", "x", "warning", "msg", "control", "file"),
    ]
    plan = _compute_plan(findings)
    phase4 = plan["phases"][4]
    assert phase4["internal_order"] == [["SC402", "SC404", "SC210", "SC302"]]


def test_plan_with_baseline(tmp_path):
    """--plan respects --baseline: baselined findings are excluded from the plan."""
    _write_py(tmp_path, "def foo(x=[]):\n    pass\n")
    # Generate baseline capturing the mutable default finding
    gen = _run_cli(str(tmp_path), "--generate-baseline", cwd=tmp_path)
    assert gen.returncode == 0
    bl = tmp_path / ".smellcheck-baseline.json"
    bl.write_text(gen.stdout, encoding="utf-8")
    # Plan without baseline — should have findings
    plan_no_bl = _run_cli(str(tmp_path), "--plan", "--format", "json", cwd=tmp_path)
    assert plan_no_bl.returncode == 0
    data_no_bl = json.loads(plan_no_bl.stdout)
    assert data_no_bl["total_findings"] > 0
    # Plan with baseline — same code, all findings suppressed
    plan_bl = _run_cli(
        str(tmp_path), "--plan", "--format", "json",
        "--baseline", str(bl), cwd=tmp_path,
    )
    assert plan_bl.returncode == 0
    data_bl = json.loads(plan_bl.stdout)
    assert data_bl["total_findings"] == 0
    assert all(p["status"] == "skip" for p in data_bl["phases"])


def test_cli_plan_integration(tmp_path):
    """E2E --plan produces output and exits 0."""
    p = tmp_path / "sample.py"
    p.write_text("def foo(x=[]):\n    pass\n", encoding="utf-8")
    result = _run_cli(str(p), "--plan")
    assert result.returncode == 0
    assert "Refactoring Plan" in result.stdout
    assert "Phase 0" in result.stdout


def test_cli_plan_json_integration(tmp_path):
    """--plan --format json produces valid JSON via CLI."""
    p = tmp_path / "sample.py"
    p.write_text("def foo(x=[]):\n    pass\n", encoding="utf-8")
    result = _run_cli(str(p), "--plan", "--format", "json")
    assert result.returncode == 0
    parsed = json.loads(result.stdout)
    assert "phases" in parsed
    assert len(parsed["phases"]) == 9


def test_plan_feedback_label_local_first():
    """Under local_first, phase 8 shows 'Feedback to' with loop count."""
    findings = [
        Finding("a.py", i, "SC201", "x", "warning", "msg", "functions", "file")
        for i in range(4)
    ] + [Finding("a.py", 5, "SC801", "x", "warning", "msg", "metrics", "metric")]
    plan = _compute_plan(findings)
    assert plan["strategy"] == "local_first"
    text = _format_plan_text(plan)
    assert "Feedback to Phase 6" in text
    assert "max 2 loops" in text


def test_plan_feedback_label_architecture_first():
    """Under architecture_first, phase 8 shows 'Feeds into' (no loop)."""
    findings = [
        Finding("a.py", 1, "SC503", "x", "warning", "msg", "architecture", "cross_file"),
        Finding("a.py", 2, "SC504", "x", "warning", "msg", "architecture", "cross_file"),
        Finding("a.py", 3, "SC606", "x", "warning", "msg", "hygiene", "cross_file"),
        Finding("a.py", 4, "SC801", "x", "warning", "msg", "metrics", "metric"),
    ]
    plan = _compute_plan(findings)
    assert plan["strategy"] == "architecture_first"
    text = _format_plan_text(plan)
    assert "Feeds into Phase 6" in text
    assert "max 2 loops" not in text


def test_plan_and_generate_baseline_mutually_exclusive(tmp_path):
    """--plan and --generate-baseline cannot be used together."""
    p = tmp_path / "sample.py"
    p.write_text("x = 1\n", encoding="utf-8")
    result = _run_cli(str(p), "--plan", "--generate-baseline")
    assert result.returncode == 1
    assert "mutually exclusive" in result.stderr


# ---------------------------------------------------------------------------
# Per-SC named tests (one per SC code, 56 total)
# ---------------------------------------------------------------------------


# --- SC1xx: State & Immutability ---


def test_SC101_remove_setters(tmp_path):
    p = _write_py(tmp_path, """\
        class User:
            def set_name(self, name):
                self._name = name
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC101" for f in findings)


def test_SC102_convert_variables_to_constants(tmp_path):
    p = _write_py(tmp_path, """\
        TAX_RATE = 0.08
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC102" for f in findings)


def test_SC103_protect_public_attributes(tmp_path):
    p = _write_py(tmp_path, """\
        class Account:
            def __init__(self):
                self.balance = 0
                self.name = "test"
                self.email = "a@b.com"
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC103" for f in findings)


def test_SC104_build_with_the_essence(tmp_path):
    p = _write_py(tmp_path, """\
        class User:
            def __init__(self):
                self.name = None
                self.age = None
                self.email = None
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC104" for f in findings)


def test_SC105_convert_attributes_to_sets(tmp_path):
    p = _write_py(tmp_path, """\
        class Employee:
            def __init__(self):
                self.is_manager = False
                self.is_admin = False
                self.is_contractor = False
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC105" for f in findings)


def test_SC106_replace_global_variables_with_di(tmp_path):
    p = _write_py(tmp_path, """\
        db = dict()
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC106" for f in findings)


def test_SC107_replace_sequential_ids(tmp_path):
    p = _write_py(tmp_path, """\
        class Order:
            next_id = 0
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC107" for f in findings)


# --- SC2xx: Functions ---


def test_SC201_extract_method(tmp_path):
    lines = ["def big():"] + [f"    x{i} = {i}" for i in range(30)]
    p = _write_py(tmp_path, "\n".join(lines) + "\n")
    findings = scan_path(p)
    assert any(f.pattern == "SC201" for f in findings)


def test_SC202_rename_result_variables(tmp_path):
    p = _write_py(tmp_path, """\
        def fetch():
            result = compute()
            return result
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC202" for f in findings)


def test_SC203_replace_input_calls(tmp_path):
    p = _write_py(tmp_path, """\
        def greet():
            name = input("Name: ")
            print(f"Hi {name}")
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC203" for f in findings)


def test_SC204_replace_null_with_collection(tmp_path):
    p = _write_py(tmp_path, """\
        def find(name):
            if name:
                return [name]
            return None
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC204" for f in findings)


def test_SC205_strip_annotations(tmp_path):
    p = _write_py(tmp_path, """\
        @a
        @b
        @c
        @d
        def decorated():
            pass
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC205" for f in findings)


def test_SC206_reify_parameters(tmp_path):
    p = _write_py(tmp_path, """\
        def process(a, b, c, d, e, f):
            return a + b + c + d + e + f
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC206" for f in findings)


def test_SC207_separate_query_from_modifier(tmp_path):
    p = _write_py(tmp_path, """\
        class Cart:
            def add_and_total(self, item):
                self.items = item
                return 42
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC207" for f in findings)


def test_SC208_remove_unused_parameters(tmp_path):
    p = _write_py(tmp_path, """\
        def process(a, b, c):
            return a + b
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC208" for f in findings)


def test_SC209_replace_long_lambda(tmp_path):
    p = _write_py(tmp_path, """\
        fn = lambda x, y, z: (x + y + z) * 2 if (x > 0 and y > 0 and z > 0) else (x - y - z) * 3 if x < 0 else 0
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC209" for f in findings)


def test_SC210_reduce_cyclomatic_complexity(tmp_path):
    branches = "\n".join(
        [f"    {'el' if i else ''}if x == {i}: return {i}" for i in range(12)]
    )
    p = _write_py(tmp_path, f"def complex_func(x):\n{branches}\n")
    findings = scan_path(p)
    assert any(f.pattern == "SC210" for f in findings)


def test_SC211_move_method_feature_envy(tmp_path):
    mod_a = tmp_path / "mod_a.py"
    mod_a.write_text(textwrap.dedent("""\
        class Helper:
            x = 1
            y = 2
            z = 3
    """), encoding="utf-8")
    mod_b = tmp_path / "mod_b.py"
    mod_b.write_text(textwrap.dedent("""\
        from mod_a import Helper
        class Worker:
            def do_work(self):
                h = Helper()
                a = Helper.x
                b = Helper.y
                c = Helper.z
                return a + b + c
    """), encoding="utf-8")
    findings = scan_paths([mod_a, mod_b])
    assert any(f.pattern == "SC211" for f in findings)


# --- SC3xx: Types & Classes ---


def test_SC301_extract_class(tmp_path):
    methods = "\n".join(
        [f"    def m{i}(self): pass" for i in range(15)]
    )
    p = _write_py(tmp_path, f"class Blob:\n{methods}\n")
    findings = scan_path(p)
    assert any(f.pattern == "SC301" for f in findings)


def test_SC302_replace_if_with_polymorphism(tmp_path):
    p = _write_py(tmp_path, """\
        def handle(shape):
            if isinstance(shape, Circle):
                return 1
            elif isinstance(shape, Square):
                return 2
            elif isinstance(shape, Triangle):
                return 3
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC302" for f in findings)


def test_SC303_replace_singleton(tmp_path):
    p = _write_py(tmp_path, """\
        class DB:
            _instance = None
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC303" for f in findings)


def test_SC304_replace_class_with_dataclass(tmp_path):
    p = _write_py(tmp_path, """\
        class Point:
            def __init__(self, x, y):
                self.x = x
                self.y = y
            def __repr__(self):
                return f"Point({self.x}, {self.y})"
            def __eq__(self, other):
                return self.x == other.x and self.y == other.y
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC304" for f in findings)


def test_SC305_use_unpacking_instead_of_indexing(tmp_path):
    p = _write_py(tmp_path, """\
        def unpack(data):
            a = data[0]
            b = data[1]
            c = data[2]
            return a + b + c
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC305" for f in findings)


def test_SC306_remove_lazy_class(tmp_path):
    p = _write_py(tmp_path, """\
        class Wrapper:
            def go(self):
                pass
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC306" for f in findings)


def test_SC307_remove_temporary_field(tmp_path):
    p = _write_py(tmp_path, """\
        class Builder:
            def __init__(self):
                self.x = 1
                self.y = 2
                self.z = 3
                self.w = 4
            def a(self):
                return self.x
            def b(self):
                return self.y
            def c(self):
                return self.z
            def d(self):
                return self.w
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC307" for f in findings)


def test_SC308_deep_inheritance_tree(tmp_path):
    mod = tmp_path / "deep.py"
    mod.write_text(textwrap.dedent("""\
        class A: pass
        class B(A): pass
        class C(B): pass
        class D(C): pass
        class E(D): pass
        class F(E): pass
    """), encoding="utf-8")
    dummy = tmp_path / "dummy.py"
    dummy.write_text("x = 1\n", encoding="utf-8")
    findings = scan_paths([mod, dummy])
    assert any(f.pattern == "SC308" for f in findings)


def test_SC309_wide_hierarchy(tmp_path):
    children = "\n".join([f"class Child{i}(Base): pass" for i in range(7)])
    mod = tmp_path / "wide.py"
    mod.write_text(f"class Base: pass\n{children}\n", encoding="utf-8")
    dummy = tmp_path / "dummy.py"
    dummy.write_text("x = 1\n", encoding="utf-8")
    findings = scan_paths([mod, dummy])
    assert any(f.pattern == "SC309" for f in findings)


# --- SC4xx: Control ---


def test_SC401_remove_dead_code(tmp_path):
    p = _write_py(tmp_path, """\
        def f():
            return 1
            x = 2
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC401" for f in findings)


def test_SC402_replace_nested_conditional_with_guard(tmp_path):
    p = _write_py(tmp_path, """\
        def deep(x):
            if x:
                if x > 1:
                    if x > 2:
                        if x > 3:
                            return x
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC402" for f in findings)


def test_SC403_replace_loop_with_pipeline(tmp_path):
    p = _write_py(tmp_path, """\
        def collect(items):
            result = []
            for item in items:
                result.append(item)
            return result
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC403" for f in findings)


def test_SC404_decompose_conditional(tmp_path):
    p = _write_py(tmp_path, """\
        def check(a, b, c, d):
            if a and b and c and d:
                return True
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC404" for f in findings)


def test_SC405_replace_control_flag_with_break(tmp_path):
    p = _write_py(tmp_path, """\
        def search(items):
            found = False
            for item in items:
                if found:
                    print("done")
                if item == "target":
                    found = True
            return found
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC405" for f in findings)


def test_SC406_simplify_complex_comprehension(tmp_path):
    p = _write_py(tmp_path, """\
        result = [x + y + z for x in range(10) for y in range(10) for z in range(10)]
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC406" for f in findings)


def test_SC407_add_default_else_branch(tmp_path):
    p = _write_py(tmp_path, """\
        def classify(x):
            if x > 0:
                return "positive"
            elif x < 0:
                return "negative"
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC407" for f in findings)


# --- SC5xx: Architecture ---


def test_SC501_replace_error_codes_with_exceptions(tmp_path):
    p = _write_py(tmp_path, """\
        def process(x):
            if x < 0:
                return -1
            if x == 0:
                return 0
            return 1
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC501" for f in findings)


def test_SC502_law_of_demeter(tmp_path):
    p = _write_py(tmp_path, """\
        def get_city(order):
            return order.customer.address.city
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC502" for f in findings)


def test_SC503_break_cyclic_import(tmp_path):
    mod_a = tmp_path / "mod_a.py"
    mod_a.write_text("import mod_b\n", encoding="utf-8")
    mod_b = tmp_path / "mod_b.py"
    mod_b.write_text("import mod_a\n", encoding="utf-8")
    findings = scan_paths([mod_a, mod_b])
    assert any(f.pattern == "SC503" for f in findings)


def test_tarjan_scc_complex_graph():
    """Tarjan SCC correctly handles complex graphs: multi-node cycles, disjoint
    cycles, nodes not in any cycle, and self-loops (single-node SCCs ignored)."""
    from smellcheck.detector import _tarjan_scc

    # Graph: a->b->c->a (3-cycle), d->e->d (2-cycle), f->g (no cycle), h->h (self-loop)
    graph = {
        "a": {"b"},
        "b": {"c"},
        "c": {"a"},
        "d": {"e"},
        "e": {"d"},
        "f": {"g"},
        "g": set(),
        "h": {"h"},
    }
    sccs = _tarjan_scc(graph)
    # Only SCCs with 2+ nodes are returned
    scc_sets = [frozenset(s) for s in sccs]
    assert frozenset({"a", "b", "c"}) in scc_sets
    assert frozenset({"d", "e"}) in scc_sets
    # f, g, h are not part of any multi-node SCC
    all_nodes = {n for s in sccs for n in s}
    assert "f" not in all_nodes
    assert "g" not in all_nodes
    assert "h" not in all_nodes
    assert len(sccs) == 2


def test_tarjan_scc_no_cycles():
    """Tarjan SCC returns empty list for a DAG."""
    from smellcheck.detector import _tarjan_scc

    graph = {"a": {"b"}, "b": {"c"}, "c": set()}
    assert _tarjan_scc(graph) == []


def test_SC503_three_node_cycle(tmp_path):
    """Three-module cycle is detected and reported exactly once."""
    (tmp_path / "mod_x.py").write_text("import mod_y\n", encoding="utf-8")
    (tmp_path / "mod_y.py").write_text("import mod_z\n", encoding="utf-8")
    (tmp_path / "mod_z.py").write_text("import mod_x\n", encoding="utf-8")
    findings = scan_paths(list(tmp_path.glob("*.py")))
    sc503 = [f for f in findings if f.pattern == "SC503"]
    assert len(sc503) == 1
    assert "mod_x" in sc503[0].message or "mod_y" in sc503[0].message or "mod_z" in sc503[0].message


def test_is_elif_uses_parent_map(tmp_path):
    """_is_elif correctly identifies elif branches using the cached parent map."""
    code = """\
x = 1
if x == 1:
    pass
elif x == 2:
    pass
elif x == 3:
    pass
else:
    pass
"""
    p = _write_py(tmp_path, code)
    # SC412 (missing else) and isinstance-chain checks depend on _is_elif
    # working correctly. The absence of false positives on elif branches
    # is the observable effect we can test via scan_paths.
    findings = scan_paths([p])
    # There should be no SC412 finding triggered on the elif branches themselves
    sc412 = [f for f in findings if f.pattern == "SC412"]
    # The top-level if has an else, so no SC412 expected at all
    assert len(sc412) == 0


def test_SC504_split_god_module(tmp_path):
    defs = "\n".join([f"def func_{i}(): pass" for i in range(35)])
    mod = tmp_path / "god.py"
    mod.write_text(defs + "\n", encoding="utf-8")
    dummy = tmp_path / "dummy.py"
    dummy.write_text("x = 1\n", encoding="utf-8")
    findings = scan_paths([mod, dummy])
    assert any(f.pattern == "SC504" for f in findings)


def test_SC505_shotgun_surgery(tmp_path):
    target = tmp_path / "target.py"
    target.write_text("def shared_func(): pass\n", encoding="utf-8")
    callers = []
    for i in range(7):
        caller = tmp_path / f"caller_{i}.py"
        caller.write_text(
            f"from target import shared_func\ndef use_{i}(): shared_func()\n",
            encoding="utf-8",
        )
        callers.append(caller)
    findings = scan_paths([target] + callers)
    assert any(f.pattern == "SC505" for f in findings)


def test_SC506_inappropriate_intimacy(tmp_path):
    mod_a = tmp_path / "intim_a.py"
    mod_a.write_text(textwrap.dedent("""\
        class A:
            def m1(self):
                return B.x + B.y + B.z + B.w
            def m2(self):
                return B.a
        class B:
            x = 1; y = 2; z = 3; w = 4; a = 5
            def m1(self):
                return A.x + A.y + A.z + A.w
    """), encoding="utf-8")
    dummy = tmp_path / "intim_b.py"
    dummy.write_text("x = 1\n", encoding="utf-8")
    findings = scan_paths([mod_a, dummy])
    assert any(f.pattern == "SC506" for f in findings)


def test_SC507_remove_speculative_generality(tmp_path):
    mod = tmp_path / "speculative.py"
    mod.write_text(textwrap.dedent("""\
        from abc import ABC, abstractmethod
        class AbstractHandler(ABC):
            @abstractmethod
            def handle(self): pass
    """), encoding="utf-8")
    dummy = tmp_path / "spec_dummy.py"
    dummy.write_text("x = 1\n", encoding="utf-8")
    findings = scan_paths([mod, dummy])
    assert any(f.pattern == "SC507" for f in findings)


def test_SC508_unstable_dependency(tmp_path):
    # stable_mod has many incoming deps (callers import it) -> low instability
    stable_mod = tmp_path / "stable_mod.py"
    stable_mod.write_text("import volatile\ndef helper(): pass\n", encoding="utf-8")
    # volatile has many outgoing deps, no incoming -> high instability
    many_imports = "\n".join([f"import dep_{i}" for i in range(6)])
    volatile = tmp_path / "volatile.py"
    volatile.write_text(many_imports + "\ndef go(): pass\n", encoding="utf-8")
    dep_files = []
    for i in range(6):
        d = tmp_path / f"dep_{i}.py"
        d.write_text("x = 1\n", encoding="utf-8")
        dep_files.append(d)
    # callers all import stable_mod -> stable_mod gets many incoming
    callers = []
    for i in range(6):
        c = tmp_path / f"caller_{i}.py"
        c.write_text("import stable_mod\n", encoding="utf-8")
        callers.append(c)
    findings = scan_paths([stable_mod, volatile] + dep_files + callers, use_cache=False)
    assert any(f.pattern == "SC508" for f in findings)


# --- SC6xx: Hygiene ---


def test_SC601_extract_constant(tmp_path):
    p = _write_py(tmp_path, """\
        def discount(price):
            return price * 0.15
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC601" for f in findings)


def test_SC602_remove_unhandled_exceptions(tmp_path):
    p = _write_py(tmp_path, """\
        try:
            risky()
        except:
            pass
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC602" for f in findings)


def test_SC603_replace_string_concatenation(tmp_path):
    p = _write_py(tmp_path, """\
        name = "world"
        msg = "hello " + name + " how " + "are you"
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC603" for f in findings)


def test_SC604_replace_with_contextlib(tmp_path):
    p = _write_py(tmp_path, """\
        class Lock:
            def __enter__(self):
                return self
            def __exit__(self, *args):
                pass
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC604" for f in findings)


def test_SC605_remove_empty_catch_block(tmp_path):
    p = _write_py(tmp_path, """\
        try:
            risky()
        except ValueError:
            pass
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC605" for f in findings)


def test_SC606_remove_duplicated_code(tmp_path):
    func_body = "\n".join([f"    x{i} = {i}" for i in range(10)])
    mod_a = tmp_path / "dup_a.py"
    mod_a.write_text(f"def compute_a():\n{func_body}\n    return x0\n", encoding="utf-8")
    mod_b = tmp_path / "dup_b.py"
    mod_b.write_text(f"def compute_b():\n{func_body}\n    return x0\n", encoding="utf-8")
    findings = scan_paths([mod_a, mod_b])
    assert any(f.pattern == "SC606" for f in findings)


# --- SC7xx: Idioms ---


def test_SC701_replace_mutable_default_arguments(tmp_path):
    p = _write_py(tmp_path, """\
        def foo(x=[]):
            return x
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC701" for f in findings)


def test_SC702_use_context_managers(tmp_path):
    p = _write_py(tmp_path, """\
        def read_file():
            f = open("test.txt")
            data = f.read()
            f.close()
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC702" for f in findings)


def test_SC703_avoid_blocking_calls_in_async(tmp_path):
    p = _write_py(tmp_path, """\
        import asyncio
        async def fetch():
            data = open("file.txt").read()
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC703" for f in findings)


# --- SC8xx: Metrics ---


def test_SC801_low_class_cohesion(tmp_path):
    # LCOM > 0.8: 6 methods each using a single unique field (no sharing).
    # field_count comes from __init__ assignments; method_count >= 3 required.
    # LCOM = 1 - (6*1)/(6*6) = 1 - 1/6 = 0.833 > 0.8 threshold.
    p = _write_py(tmp_path, """\
        class Blob:
            def __init__(self):
                self.x = 1
                self.y = 2
                self.z = 3
                self.w = 4
                self.v = 5
                self.u = 6
            def a(self): return self.x
            def b(self): return self.y
            def c(self): return self.z
            def d(self): return self.w
            def e(self): return self.v
            def f(self): return self.u
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC801" for f in findings)


def test_SC802_high_coupling_between_objects(tmp_path):
    # CBO counts UppercaseName.attr accesses in class methods (> 8 threshold).
    classes = "\n".join([f"class Dep{i}:\n    val = {i}" for i in range(9)])
    accesses = "\n".join([f"        x{i} = Dep{i}.val" for i in range(9)])
    code = f"{classes}\nclass Heavy:\n    def work(self):\n{accesses}\n"
    mod = tmp_path / "heavy.py"
    mod.write_text(code, encoding="utf-8")
    findings = scan_path(mod)
    assert any(f.pattern == "SC802" for f in findings)


def test_SC803_excessive_fan_out(tmp_path):
    # SC803 counts outgoing imports to other *scanned* modules (> 15 threshold).
    # Create hub.py importing 16 sibling modules, then scan all together.
    for i in range(16):
        (tmp_path / f"fmod{i}.py").write_text(f"x = {i}\n", encoding="utf-8")
    hub = tmp_path / "hub.py"
    hub.write_text(
        "\n".join([f"import fmod{i}" for i in range(16)]) + "\n",
        encoding="utf-8",
    )
    all_files = [hub] + [tmp_path / f"fmod{i}.py" for i in range(16)]
    findings = scan_paths(all_files, use_cache=False)
    assert any(f.pattern == "SC803" for f in findings)


def test_SC804_high_response_for_class(tmp_path):
    methods = "\n".join([f"    def m{i}(self): pass" for i in range(22)])
    p = _write_py(tmp_path, f"class BigClass:\n{methods}\n")
    findings = scan_path(p)
    assert any(f.pattern == "SC804" for f in findings)


def test_SC805_remove_middle_man(tmp_path):
    p = _write_py(tmp_path, """\
        class Proxy:
            def __init__(self):
                self.real = Real()
            def a(self): return self.real.a()
            def b(self): return self.real.b()
            def c(self): return self.real.c()
        class Real:
            def a(self): return 1
            def b(self): return 2
            def c(self): return 3
    """)
    findings = scan_path(p)
    assert any(f.pattern == "SC805" for f in findings)


# ---------------------------------------------------------------------------
# Environment variable configuration
# ---------------------------------------------------------------------------


def test_read_env_config_empty(monkeypatch):
    """_read_env_config returns empty dict when no SMELLCHECK_* vars are set."""
    for var in (
        "SMELLCHECK_MIN_SEVERITY",
        "SMELLCHECK_FAIL_ON",
        "SMELLCHECK_FORMAT",
        "SMELLCHECK_SELECT",
        "SMELLCHECK_IGNORE",
        "SMELLCHECK_BASELINE",
    ):
        monkeypatch.delenv(var, raising=False)
    assert _read_env_config() == {}


def test_read_env_config_severity(monkeypatch):
    """SMELLCHECK_MIN_SEVERITY is read correctly."""
    monkeypatch.setenv("SMELLCHECK_MIN_SEVERITY", "warning")
    cfg = _read_env_config()
    assert cfg["min-severity"] == "warning"


def test_read_env_config_fail_on(monkeypatch):
    """SMELLCHECK_FAIL_ON is read correctly."""
    monkeypatch.setenv("SMELLCHECK_FAIL_ON", "info")
    cfg = _read_env_config()
    assert cfg["fail-on"] == "info"


def test_read_env_config_format(monkeypatch):
    """SMELLCHECK_FORMAT is read correctly."""
    monkeypatch.setenv("SMELLCHECK_FORMAT", "json")
    cfg = _read_env_config()
    assert cfg["format"] == "json"


def test_read_env_config_select_comma_separated(monkeypatch):
    """SMELLCHECK_SELECT splits on commas."""
    monkeypatch.setenv("SMELLCHECK_SELECT", "SC701, SC601")
    cfg = _read_env_config()
    assert cfg["select"] == ["SC701", "SC601"]


def test_read_env_config_ignore_comma_separated(monkeypatch):
    """SMELLCHECK_IGNORE splits on commas."""
    monkeypatch.setenv("SMELLCHECK_IGNORE", "SC202,SC301")
    cfg = _read_env_config()
    assert cfg["ignore"] == ["SC202", "SC301"]


def test_read_env_config_baseline(monkeypatch):
    """SMELLCHECK_BASELINE is read as a string."""
    monkeypatch.setenv("SMELLCHECK_BASELINE", ".smellcheck-baseline.json")
    cfg = _read_env_config()
    assert cfg["baseline"] == ".smellcheck-baseline.json"


def test_read_env_config_invalid_severity(monkeypatch):
    """Invalid SMELLCHECK_MIN_SEVERITY causes sys.exit."""
    monkeypatch.setenv("SMELLCHECK_MIN_SEVERITY", "critical")
    import pytest

    with pytest.raises(SystemExit):
        _read_env_config()


def test_read_env_config_invalid_format(monkeypatch):
    """Invalid SMELLCHECK_FORMAT causes sys.exit."""
    monkeypatch.setenv("SMELLCHECK_FORMAT", "xml")
    import pytest

    with pytest.raises(SystemExit):
        _read_env_config()


def test_read_env_config_invalid_fail_on(monkeypatch):
    """Invalid SMELLCHECK_FAIL_ON causes sys.exit."""
    monkeypatch.setenv("SMELLCHECK_FAIL_ON", "critical")
    import pytest

    with pytest.raises(SystemExit):
        _read_env_config()


def test_read_env_config_comma_only_select(monkeypatch):
    """SMELLCHECK_SELECT=',' should not set an empty select list."""
    monkeypatch.setenv("SMELLCHECK_SELECT", ",")
    cfg = _read_env_config()
    assert "select" not in cfg


def test_read_env_config_comma_only_ignore(monkeypatch):
    """SMELLCHECK_IGNORE=',,' should not set an empty ignore list."""
    monkeypatch.setenv("SMELLCHECK_IGNORE", ",,")
    cfg = _read_env_config()
    assert "ignore" not in cfg


def test_env_var_format_via_cli(tmp_path):
    """SMELLCHECK_FORMAT env var changes output format."""
    p = tmp_path / "sample.py"
    p.write_text("x = 1\n", encoding="utf-8")
    result = _run_cli(str(p), env={"SMELLCHECK_FORMAT": "json"})
    assert result.returncode == 0
    parsed = json.loads(result.stdout)
    assert isinstance(parsed, list)


def test_env_var_overridden_by_cli_flag(tmp_path):
    """CLI --format overrides SMELLCHECK_FORMAT."""
    p = tmp_path / "sample.py"
    p.write_text("x = 1\n", encoding="utf-8")
    result = _run_cli(
        str(p), "--format", "text", env={"SMELLCHECK_FORMAT": "json"}
    )
    assert result.returncode == 0
    # Text output must NOT be JSON — verify the CLI flag overrode the env var.
    stdout = result.stdout.strip()
    assert not stdout.startswith("[") and not stdout.startswith("{"), (
        f"Expected text format but got JSON-like output: {stdout[:80]!r}"
    )


def test_env_var_min_severity_via_cli(tmp_path):
    """SMELLCHECK_MIN_SEVERITY filters findings via CLI."""
    p = tmp_path / "sample.py"
    p.write_text("x = 1\n", encoding="utf-8")
    result = _run_cli(str(p), env={"SMELLCHECK_MIN_SEVERITY": "error"})
    assert result.returncode == 0


def test_env_var_select_via_cli(tmp_path):
    """SMELLCHECK_SELECT limits rules to selected codes."""
    p = tmp_path / "sample.py"
    p.write_text("x = 1\n", encoding="utf-8")
    result = _run_cli(
        str(p), "--format", "json", env={"SMELLCHECK_SELECT": "SC999"}
    )
    assert result.returncode == 0
    findings = json.loads(result.stdout)
    assert len(findings) == 0


def test_env_var_invalid_format_via_cli(tmp_path):
    """Invalid SMELLCHECK_FORMAT produces error message."""
    p = tmp_path / "sample.py"
    p.write_text("x = 1\n", encoding="utf-8")
    result = _run_cli(str(p), env={"SMELLCHECK_FORMAT": "xml"})
    assert result.returncode == 1
    assert "invalid" in result.stderr.lower()


def test_env_var_whitespace_ignored(monkeypatch):
    """Whitespace-only env var values are treated as unset."""
    monkeypatch.setenv("SMELLCHECK_FORMAT", "   ")
    cfg = _read_env_config()
    assert "format" not in cfg


# ---------------------------------------------------------------------------
# SC509 — Lazy Re-export Module detection
# ---------------------------------------------------------------------------


def test_sc509_lazy_reexport_detected(tmp_path):
    """A module that only imports and re-exports should trigger SC509."""
    p = _write_py(
        tmp_path,
        """\
        from module_a import ClassA
        from module_b import ClassB
        from module_c import ClassC
        from module_d import func_d
        from module_e import func_e
        from module_f import ClassF

        __all__ = ["ClassA", "ClassB", "ClassC", "func_d", "func_e", "ClassF"]
        """,
    )
    findings = scan_path(p)
    sc509 = [f for f in findings if f.pattern == "SC509"]
    assert len(sc509) == 1
    assert "re-export" in sc509[0].message.lower() or "imports" in sc509[0].message.lower()


def test_sc509_init_py_excluded(tmp_path):
    """__init__.py files should NOT trigger SC509."""
    init = tmp_path / "__init__.py"
    init.write_text(
        textwrap.dedent("""\
        from module_a import ClassA
        from module_b import ClassB
        from module_c import ClassC
        from module_d import func_d
        from module_e import func_e
        from module_f import ClassF

        __all__ = ["ClassA", "ClassB", "ClassC", "func_d", "func_e", "ClassF"]
        """),
        encoding="utf-8",
    )
    findings = scan_path(init)
    sc509 = [f for f in findings if f.pattern == "SC509"]
    assert len(sc509) == 0


def test_sc509_module_with_logic_not_flagged(tmp_path):
    """A module with actual function defs should NOT trigger SC509."""
    p = _write_py(
        tmp_path,
        """\
        from module_a import ClassA
        from module_b import ClassB
        from module_c import ClassC
        from module_d import func_d
        from module_e import func_e
        from module_f import ClassF

        def process():
            return ClassA()
        """,
    )
    findings = scan_path(p)
    sc509 = [f for f in findings if f.pattern == "SC509"]
    assert len(sc509) == 0


def test_sc509_lazy_reexport_with_docstring(tmp_path):
    """SC509 should still detect lazy modules that have a module docstring."""
    p = _write_py(
        tmp_path,
        '''\
        """This module re-exports everything."""
        from core.models import User
        from core.models import Order
        from core.models import Product
        from core.utils import helper1
        from core.utils import helper2
        from core.utils import helper3
        ''',
        name="shim.py",
    )
    findings = scan_path(p)
    assert any(f.pattern == "SC509" for f in findings)


def test_sc509_small_module_excluded(tmp_path):
    """Modules with fewer than 5 statements should NOT trigger SC509."""
    p = _write_py(
        tmp_path,
        """\
        from module_a import ClassA
        from module_b import ClassB
        from module_c import ClassC
        """,
    )
    findings = scan_path(p)
    sc509 = [f for f in findings if f.pattern == "SC509"]
    assert len(sc509) == 0


def test_sc509_annotated_all_not_blocked(tmp_path):
    """SC509: annotated __all__ should not prevent detection."""
    p = _write_py(tmp_path, '''
__all__: list[str] = ["User", "Order", "Product"]
from core.models import User
from core.models import Order
from core.models import Product
from core.utils import helper1
from core.utils import helper2
''', name="shim.py")
    findings = scan_path(p)
    assert any(f.pattern == "SC509" for f in findings)


def test_sc509_future_import_not_counted(tmp_path):
    """SC509: __future__ imports should not inflate import ratio or statement count."""
    # 2 __future__ imports + 4 real imports + __all__ = would be 7 stmts if __future__ counted
    # With __future__ excluded: 4 imports + __all__ = 5 stmts, ratio = 4/5 = 0.8, threshold 0.8 with __all__
    # This should NOT fire because ratio is <= 0.8 (not strictly greater)
    p = _write_py(tmp_path, '''
from __future__ import annotations
from __future__ import division
__all__ = ["User", "Order", "Product", "helper"]
from core.models import User
from core.models import Order
from core.models import Product
from core.utils import helper
''', name="shim.py")
    findings = scan_path(p)
    assert not any(f.pattern == "SC509" for f in findings)


def test_sc509_fires_with_future_import_present(tmp_path):
    """SC509: should still detect lazy modules that happen to have __future__ imports."""
    # 1 __future__ + 6 real imports = 7 stmts if __future__ counted (ratio 6/7=0.857)
    # With __future__ excluded: 6 imports = 6 stmts, ratio 6/6=1.0 > 0.9 -> fires
    p = _write_py(tmp_path, '''
from __future__ import annotations
from core.models import User
from core.models import Order
from core.models import Product
from core.utils import helper1
from core.utils import helper2
from core.utils import helper3
''', name="shim.py")
    findings = scan_path(p)
    assert any(f.pattern == "SC509" for f in findings)


def test_sc509_module_with_constants_excluded(tmp_path):
    """SC509: module with constant assignments is not a lazy re-export.

    Uses >= 5 statements so the test exercises the constants exclusion,
    not the 'modules under 5 statements' skip.
    """
    p = _write_py(tmp_path, '''
DEFAULT_TIMEOUT = 30
MAX_RETRIES = 3
BASE_URL = "https://api.example.com"
from core.http import get, post, put, delete, patch
from core.auth import authenticate
from core.utils import retry
''', name="config.py")
    findings = scan_path(p)
    assert not any(f.pattern == "SC509" for f in findings)


def test_env_var_invalid_format_exits(tmp_path):
    """Invalid SMELLCHECK_FORMAT from env var should produce clear error."""
    p = _write_py(tmp_path, "x = 1")
    result = _run_cli(str(p), env={"SMELLCHECK_FORMAT": "xml"})
    assert result.returncode == 1
    assert "Error:" in result.stderr
    assert "text" in result.stderr or "json" in result.stderr  # lists valid formats


def test_env_var_invalid_severity_exits(tmp_path):
    """Invalid SMELLCHECK_MIN_SEVERITY from env var should produce clear error."""
    p = _write_py(tmp_path, "x = 1")
    result = _run_cli(str(p), env={"SMELLCHECK_MIN_SEVERITY": "critical"})
    assert result.returncode == 1
    assert "Error:" in result.stderr


def test_env_var_cli_overrides_env_format(tmp_path):
    """CLI --format should override SMELLCHECK_FORMAT env var."""
    p = _write_py(tmp_path, "def f(x=[]):\n    pass")  # triggers SC701 (error severity)
    result = _run_cli(str(p), "--format", "json", env={"SMELLCHECK_FORMAT": "text"})
    # returncode may be 1 due to findings; what matters is stdout is valid JSON
    import json
    data = json.loads(result.stdout)  # must be valid JSON, proving CLI --format won
    assert isinstance(data, list)


# ---------------------------------------------------------------------------
# Regression tests for bugs #77, #80, #81, #82
# ---------------------------------------------------------------------------


def test_finding_category_matches_registry_family(tmp_path):
    """Bug #77: Finding.category must match _RULE_REGISTRY[pattern].family."""
    p = _write_py(tmp_path, """\
        class User:
            def set_name(self, name):
                self._name = name

        def foo(x=[]):
            pass

        def bar(y={}):
            pass

        try:
            pass
        except:
            pass
    """)
    findings = scan_path(p)
    for f in findings:
        rd = _RULE_REGISTRY.get(f.pattern)
        if rd is not None:
            assert f.category == rd.family, (
                f"{f.pattern}: category={f.category!r} != registry family={rd.family!r}"
            )


def test_compute_plan_architecture_first_valid_indices():
    """Bug #80: architecture_first phase order is derived dynamically, not hardcoded."""
    findings = [
        Finding("a.py", 1, "SC503", "x", "warning", "msg", "architecture", "cross_file"),
        Finding("a.py", 2, "SC504", "x", "warning", "msg", "architecture", "cross_file"),
        Finding("a.py", 3, "SC606", "x", "warning", "msg", "hygiene", "cross_file"),
        Finding("a.py", 4, "SC801", "x", "warning", "msg", "metrics", "metric"),
    ]
    plan = _compute_plan(findings)
    assert plan["strategy"] == "architecture_first"
    order = plan["phase_order"]
    num_phases = len(_REFACTORING_PHASES)
    # All indices are valid
    assert sorted(order) == list(range(num_phases))
    # Correctness and dead_code come first
    phase_names = [_REFACTORING_PHASES[i]["name"] for i in order]
    assert phase_names[0] == "correctness"
    assert phase_names[1] == "dead_code"
    # Architecture and metrics come before clarity/idioms/control_flow/functions/state_class
    arch_pos = phase_names.index("architecture")
    metrics_pos = phase_names.index("metrics")
    clarity_pos = phase_names.index("clarity")
    assert arch_pos < clarity_pos
    assert metrics_pos < clarity_pos


def test_github_format_escapes_workflow_commands(tmp_path, capsys):
    """Bug #81: GitHub annotation fields are escaped to prevent log injection."""
    # Verify escape functions directly
    assert _escape_workflow_value("a:b") == "a%3Ab"
    assert _escape_workflow_value("line1\nline2") == "line1%0Aline2"
    assert _escape_workflow_value("50%") == "50%25"  # % -> %25
    assert _escape_workflow_value("ret\r") == "ret%0D"
    # Data escaping does NOT escape colons
    assert _escape_workflow_data("a:b") == "a:b"
    assert _escape_workflow_data("line1\nline2") == "line1%0Aline2"

    # Comma escaping
    assert _escape_workflow_value("a,b") == "a%2Cb"

    # Integration: scan code with a finding and verify github output is escaped
    p = _write_py(tmp_path, "def foo(x=[]):\n    pass\n")
    findings = scan_path(p)
    assert findings, "expected at least one finding for the integration check"
    _run_cli(str(p), "--format", "github")
    out = capsys.readouterr().out
    # No raw newlines inside an annotation line (each annotation is one line)
    for line in out.strip().split("\n"):
        if line.startswith("::"):
            # Must have the expected annotation structure
            assert "title=" in line
            # No bare '::' sequences inside the property section (before the closing '::')
            # The format is ::level prop=val,...::message — extract the properties section
            inner = line[2:]  # strip leading '::'
            prop_section, _, _message = inner.partition("::")
            # No unescaped colons inside property values (colons only appear as key=value separators
            # for the level keyword at the very start, not inside values)
            parts = prop_section.split(" ", 1)  # "level props"
            if len(parts) == 2:
                props_str = parts[1]
                for prop in props_str.split(","):
                    if "=" in prop:
                        _key, _, val = prop.partition("=")
                        assert ":" not in val, f"unescaped colon in annotation property value: {val!r}"


def test_baseline_malformed_entry_no_crash(tmp_path, capsys):
    """Bug #82: Malformed baseline entries without 'fingerprint' are skipped."""
    bl = tmp_path / "baseline.json"
    bl.write_text(
        json.dumps({
            "findings": [
                {"fingerprint": "abc123"},
                {"no_fingerprint_key": True},
                "not_a_dict",
                {"fingerprint": "def456"},
            ]
        }),
        encoding="utf-8",
    )
    fps = _load_baseline(bl)
    assert fps == {"abc123", "def456"}
    err = capsys.readouterr().err
    assert "malformed baseline entry" in err.lower() or "missing 'fingerprint'" in err.lower()
