"""Tests for the vibe-check command — AI code anti-pattern auditor.

Covers:
- Each of the 8 detection patterns individually
- Composite scoring formula
- Threshold / gate failure
- JSON output format and envelope
- Text output format
- Clean project baseline
"""

from __future__ import annotations

import json
import os
import sys
from pathlib import Path

import pytest
from click.testing import CliRunner

sys.path.insert(0, str(Path(__file__).parent))
from conftest import git_init, index_in_process

# ===========================================================================
# Fixtures
# ===========================================================================


@pytest.fixture
def cli_runner():
    return CliRunner()


@pytest.fixture
def clean_project(tmp_path):
    """A project with minimal clean code (no AI rot)."""
    repo = tmp_path / "clean"
    repo.mkdir()
    (repo / ".gitignore").write_text(".roam/\n")
    (repo / "app.py").write_text(
        "def main():\n    return greet('world')\n\n\ndef greet(name):\n    return f'Hello, {name}!'\n"
    )
    (repo / "utils.py").write_text("from app import greet\n\n\ndef run():\n    return greet('test')\n")
    git_init(repo)
    old_cwd = os.getcwd()
    try:
        os.chdir(str(repo))
        out, rc = index_in_process(repo)
        assert rc == 0, f"index failed: {out}"
    finally:
        os.chdir(old_cwd)
    return repo


@pytest.fixture
def dirty_project(tmp_path):
    """A project deliberately injected with AI rot patterns."""
    repo = tmp_path / "dirty"
    repo.mkdir()
    (repo / ".gitignore").write_text(".roam/\n")

    # File with empty handlers (pattern 3) and stubs (pattern 4)
    (repo / "handlers.py").write_text(
        "def handle_request(data):\n"
        "    try:\n"
        "        process(data)\n"
        "    except Exception:\n"
        "        pass\n"
        "\n"
        "\n"
        "def handle_error(err):\n"
        "    try:\n"
        "        log(err)\n"
        "    except Exception:\n"
        "        pass\n"
        "\n"
        "\n"
        "def process(data):\n"
        "    return data\n"
        "\n"
        "\n"
        "def log(msg):\n"
        "    print(msg)\n"
        "\n"
        "\n"
        "def stub_one():\n"
        "    pass\n"
        "\n"
        "\n"
        "def stub_two():\n"
        "    pass\n"
        "\n"
        "\n"
        "def stub_three():\n"
        "    ...\n"
        "\n"
        "\n"
        "def stub_four():\n"
        "    raise NotImplementedError\n"
    )

    # Dead exports (pattern 1) — functions never called
    (repo / "dead_module.py").write_text(
        "def never_called_one():\n"
        "    return 1\n"
        "\n"
        "\n"
        "def never_called_two():\n"
        "    return 2\n"
        "\n"
        "\n"
        "def never_called_three():\n"
        "    return 3\n"
    )

    # Copy-paste functions (pattern 8) — three identical normalized bodies
    (repo / "clones.py").write_text(
        "def process_alpha(data):\n"
        "    result = []\n"
        "    for item in data:\n"
        "        if item > 0:\n"
        "            result.append(item * 2)\n"
        "    return result\n"
        "\n"
        "\n"
        "def process_beta(items):\n"
        "    result = []\n"
        "    for item in items:\n"
        "        if item > 0:\n"
        "            result.append(item * 2)\n"
        "    return result\n"
        "\n"
        "\n"
        "def process_gamma(values):\n"
        "    result = []\n"
        "    for item in values:\n"
        "        if item > 0:\n"
        "            result.append(item * 2)\n"
        "    return result\n"
    )

    # File that uses mixed error handling (pattern 6)
    (repo / "mixed_errors.py").write_text(
        "def operation_a():\n"
        "    try:\n"
        "        return do_thing()\n"
        "    except ValueError:\n"
        "        raise RuntimeError('failed')\n"
        "\n"
        "\n"
        "def operation_b():\n"
        "    result = check_thing()\n"
        "    assert result is not None\n"
        "    return result\n"
        "\n"
        "\n"
        "def operation_c():\n"
        "    if not valid():\n"
        "        return None\n"
        "    return compute()\n"
        "\n"
        "\n"
        "def do_thing():\n"
        "    return 42\n"
        "\n"
        "\n"
        "def check_thing():\n"
        "    return True\n"
        "\n"
        "\n"
        "def valid():\n"
        "    return True\n"
        "\n"
        "\n"
        "def compute():\n"
        "    return 99\n"
    )

    # Main that ties things together
    (repo / "main.py").write_text(
        "from handlers import handle_request, process, log\n"
        "from mixed_errors import operation_a, operation_b, operation_c\n"
        "\n"
        "\n"
        "def main():\n"
        "    handle_request({'key': 'value'})\n"
        "    operation_a()\n"
        "    operation_b()\n"
        "    operation_c()\n"
    )

    git_init(repo)
    old_cwd = os.getcwd()
    try:
        os.chdir(str(repo))
        out, rc = index_in_process(repo)
        assert rc == 0, f"index failed: {out}"
    finally:
        os.chdir(old_cwd)
    return repo


# ===========================================================================
# Helper to invoke the command
# ===========================================================================


def _invoke(runner, project_path, *extra_args, json_mode=False):
    from roam.cli import cli

    args = []
    if json_mode:
        args.append("--json")
    args.append("vibe-check")
    args.extend(extra_args)

    old_cwd = os.getcwd()
    try:
        os.chdir(str(project_path))
        result = runner.invoke(cli, args, catch_exceptions=False)
    finally:
        os.chdir(old_cwd)
    return result


# ===========================================================================
# Tests: Basic functionality
# ===========================================================================


class TestVibeCheckBasic:
    """Basic command execution and output format."""

    def test_runs_on_clean_project(self, cli_runner, clean_project):
        """vibe-check runs without error on a clean project."""
        result = _invoke(cli_runner, clean_project)
        assert result.exit_code == 0, f"Failed:\n{result.output}"
        assert "VERDICT:" in result.output

    def test_verdict_line(self, cli_runner, clean_project):
        """Output starts with a VERDICT line containing the score."""
        result = _invoke(cli_runner, clean_project)
        assert result.exit_code == 0
        lines = result.output.strip().split("\n")
        assert lines[0].startswith("VERDICT:")
        assert "/100" in lines[0]

    def test_pattern_table_in_output(self, cli_runner, clean_project):
        """Text output includes the pattern breakdown table."""
        result = _invoke(cli_runner, clean_project)
        assert result.exit_code == 0
        # All 8 pattern names should appear
        assert "Dead exports" in result.output
        assert "Empty error handlers" in result.output
        assert "Abandoned stubs" in result.output
        assert "Copy-paste functions" in result.output

    def test_score_summary_line(self, cli_runner, clean_project):
        """Output includes the score summary line."""
        result = _invoke(cli_runner, clean_project)
        assert result.exit_code == 0
        assert "AI rot score" in result.output
        assert "0=pristine" in result.output


# ===========================================================================
# Tests: JSON output
# ===========================================================================


class TestVibeCheckJSON:
    """JSON output format and envelope contract."""

    def test_json_output_valid(self, cli_runner, clean_project):
        """--json produces valid JSON."""
        result = _invoke(cli_runner, clean_project, json_mode=True)
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert isinstance(data, dict)

    def test_json_envelope_keys(self, cli_runner, clean_project):
        """JSON output follows the roam envelope contract."""
        result = _invoke(cli_runner, clean_project, json_mode=True)
        data = json.loads(result.output)
        assert data["command"] == "vibe-check"
        assert "version" in data
        assert "summary" in data
        assert "_meta" in data

    def test_json_summary_fields(self, cli_runner, clean_project):
        """Summary contains required fields."""
        result = _invoke(cli_runner, clean_project, json_mode=True)
        data = json.loads(result.output)
        summary = data["summary"]
        assert "verdict" in summary
        assert "score" in summary
        assert "severity" in summary
        assert "total_issues" in summary
        assert "files_scanned" in summary
        assert "patterns_detected" in summary
        assert isinstance(summary["score"], int)
        assert summary["score"] >= 0
        assert summary["score"] <= 100

    def test_json_patterns_array(self, cli_runner, clean_project):
        """JSON includes patterns array with all 10 patterns.

        W371 added two informational patterns (``modular_mirage`` and
        ``boilerplate_inflation``) on top of the score-bearing 8. The
        informational rows carry ``weight: 0`` and
        ``informational: True`` so the canonical AI rot score stays
        computed off the original 8.
        """
        result = _invoke(cli_runner, clean_project, json_mode=True)
        data = json.loads(result.output)
        assert "patterns" in data
        patterns = data["patterns"]
        assert len(patterns) == 10
        score_bearing = [p for p in patterns if not p.get("informational")]
        informational = [p for p in patterns if p.get("informational")]
        assert len(score_bearing) == 8, (
            "8 score-bearing patterns must remain; W371 must not promote informational patterns into the weight set"
        )
        assert len(informational) == 2
        info_names = {p["name"] for p in informational}
        assert info_names == {"modular_mirage", "boilerplate_inflation"}
        for p in informational:
            assert p["weight"] == 0, f"informational pattern {p['name']} must have weight 0"
        for p in patterns:
            assert "name" in p
            assert "found" in p
            assert "total" in p
            assert "rate" in p
            assert "severity" in p
            assert "weight" in p

    def test_json_worst_files(self, cli_runner, clean_project):
        """JSON includes worst_files array."""
        result = _invoke(cli_runner, clean_project, json_mode=True)
        data = json.loads(result.output)
        assert "worst_files" in data
        assert isinstance(data["worst_files"], list)

    def test_json_recommendations(self, cli_runner, clean_project):
        """JSON includes recommendations array."""
        result = _invoke(cli_runner, clean_project, json_mode=True)
        data = json.loads(result.output)
        assert "recommendations" in data
        assert isinstance(data["recommendations"], list)


# ===========================================================================
# Tests: Scoring
# ===========================================================================


class TestVibeCheckScoring:
    """Composite scoring and severity labels."""

    def test_clean_project_low_score(self, cli_runner, clean_project):
        """Clean project should have a low AI rot score."""
        result = _invoke(cli_runner, clean_project, json_mode=True)
        data = json.loads(result.output)
        # Clean project should score low (could still have some dead exports)
        assert data["summary"]["score"] <= 50

    def test_dirty_project_higher_score(self, cli_runner, dirty_project):
        """Dirty project should have a higher score than clean."""
        result = _invoke(cli_runner, dirty_project, json_mode=True)
        data = json.loads(result.output)
        # Should detect at least some issues
        assert data["summary"]["total_issues"] > 0

    def test_severity_labels(self):
        """Test severity label function."""
        from roam.commands.cmd_vibe_check import _severity_label

        assert _severity_label(0) == "HEALTHY"
        assert _severity_label(15) == "HEALTHY"
        assert _severity_label(16) == "LOW"
        assert _severity_label(35) == "LOW"
        assert _severity_label(36) == "MODERATE"
        assert _severity_label(55) == "MODERATE"
        assert _severity_label(56) == "HIGH"
        assert _severity_label(75) == "HIGH"
        assert _severity_label(76) == "CRITICAL"
        assert _severity_label(100) == "CRITICAL"

    def test_compute_score_all_zero(self):
        """All zero rates should produce score 0."""
        from roam.commands.cmd_vibe_check import _compute_score

        patterns = {
            "dead_exports": {"rate": 0.0},
            "short_churn": {"rate": 0.0},
            "empty_handlers": {"rate": 0.0},
            "abandoned_stubs": {"rate": 0.0},
            "hallucinated_imports": {"rate": 0.0},
            "error_inconsistency": {"rate": 0.0},
            "comment_anomalies": {"rate": 0.0},
            "copy_paste": {"rate": 0.0},
        }
        assert _compute_score(patterns) == 0

    def test_compute_score_all_hundred(self):
        """All 100% rates should produce score 100."""
        from roam.commands.cmd_vibe_check import _compute_score

        patterns = {
            "dead_exports": {"rate": 100.0},
            "short_churn": {"rate": 100.0},
            "empty_handlers": {"rate": 100.0},
            "abandoned_stubs": {"rate": 100.0},
            "hallucinated_imports": {"rate": 100.0},
            "error_inconsistency": {"rate": 100.0},
            "comment_anomalies": {"rate": 100.0},
            "copy_paste": {"rate": 100.0},
        }
        assert _compute_score(patterns) == 100

    def test_compute_score_partial(self):
        """Partial rates should produce an intermediate score."""
        from roam.commands.cmd_vibe_check import _compute_score

        patterns = {
            "dead_exports": {"rate": 50.0},
            "short_churn": {"rate": 0.0},
            "empty_handlers": {"rate": 50.0},
            "abandoned_stubs": {"rate": 0.0},
            "hallucinated_imports": {"rate": 0.0},
            "error_inconsistency": {"rate": 0.0},
            "comment_anomalies": {"rate": 0.0},
            "copy_paste": {"rate": 0.0},
        }
        score = _compute_score(patterns)
        # dead_exports: 50 * 15/100 = 7.5, empty_handlers: 50 * 20/100 = 10
        # total = 17.5 => round to 18
        assert score == 18

    def test_score_capped_at_100(self):
        """Even with rates > 100, score should cap at 100."""
        from roam.commands.cmd_vibe_check import _compute_score

        patterns = {
            "dead_exports": {"rate": 200.0},
            "short_churn": {"rate": 200.0},
            "empty_handlers": {"rate": 200.0},
            "abandoned_stubs": {"rate": 200.0},
            "hallucinated_imports": {"rate": 200.0},
            "error_inconsistency": {"rate": 200.0},
            "comment_anomalies": {"rate": 200.0},
            "copy_paste": {"rate": 200.0},
        }
        assert _compute_score(patterns) == 100


# ===========================================================================
# Tests: Threshold gate
# ===========================================================================


class TestVibeCheckGate:
    """Threshold / gate failure behavior."""

    def test_no_threshold_no_failure(self, cli_runner, dirty_project):
        """Without --threshold, command always exits 0."""
        result = _invoke(cli_runner, dirty_project)
        assert result.exit_code == 0

    def test_high_threshold_passes(self, cli_runner, clean_project):
        """--threshold 99 should pass on a clean project."""
        result = _invoke(cli_runner, clean_project, "--threshold", "99")
        assert result.exit_code == 0

    def test_threshold_zero_means_no_gate(self, cli_runner, dirty_project):
        """--threshold 0 means no gate (default behavior)."""
        result = _invoke(cli_runner, dirty_project, "--threshold", "0")
        assert result.exit_code == 0

    def test_gate_failure_text_mode(self, cli_runner, dirty_project):
        """If score exceeds threshold, exit code should be 5 (gate failure)."""
        result = _invoke(cli_runner, dirty_project, "--threshold", "1")
        # Will only fail if score > 1 (highly likely for dirty project)
        if result.exit_code == 5:
            assert "GATE FAILED" in result.output

    def test_gate_failure_json_mode(self, cli_runner, dirty_project):
        """Gate failure in JSON mode still produces valid JSON."""
        result = _invoke(cli_runner, dirty_project, "--threshold", "1", json_mode=True)
        # Parse JSON regardless of exit code
        data = json.loads(result.output)
        assert "summary" in data
        assert isinstance(data["summary"]["score"], int)


# ===========================================================================
# Tests: Individual pattern detectors
# ===========================================================================


class TestPatternDetectors:
    """Test each pattern detector individually."""

    def test_dead_exports_detection(self, cli_runner, dirty_project):
        """Pattern 1: dead exports detected in project with unreferenced functions."""
        result = _invoke(cli_runner, dirty_project, json_mode=True)
        data = json.loads(result.output)
        dead_pattern = next(p for p in data["patterns"] if p["name"] == "dead_exports")
        # dead_module.py has 3 never-called functions + stubs + clones
        assert dead_pattern["found"] > 0

    def test_empty_handlers_detection(self, cli_runner, dirty_project):
        """Pattern 3: empty error handlers detected."""
        result = _invoke(cli_runner, dirty_project, json_mode=True)
        data = json.loads(result.output)
        handler_pattern = next(p for p in data["patterns"] if p["name"] == "empty_handlers")
        # handlers.py has 2 empty except: pass blocks
        assert handler_pattern["found"] >= 2

    def test_stubs_detection(self, cli_runner, dirty_project):
        """Pattern 4: abandoned stubs detected."""
        result = _invoke(cli_runner, dirty_project, json_mode=True)
        data = json.loads(result.output)
        stub_pattern = next(p for p in data["patterns"] if p["name"] == "abandoned_stubs")
        # handlers.py has stub_one, stub_two (pass), stub_three (...), stub_four (NotImplementedError)
        assert stub_pattern["found"] >= 2

    def test_error_inconsistency_detection(self, cli_runner, dirty_project):
        """Pattern 6: error handling inconsistency detected."""
        result = _invoke(cli_runner, dirty_project, json_mode=True)
        data = json.loads(result.output)
        error_pattern = next(p for p in data["patterns"] if p["name"] == "error_inconsistency")
        # mixed_errors.py uses try/except, raise, assert, and return None
        # handlers.py uses try/except
        # At least one file should use 3+ patterns
        # Note: this may or may not trigger depending on exact detection
        assert error_pattern["total"] > 0

    def test_normalize_body(self):
        """Copy-paste normalization strips names and whitespace."""
        from roam.commands.cmd_vibe_check import _normalize_body

        body_a = """
def process_alpha(data):
    result = []
    for item in data:
        if item > 0:
            result.append(item * 2)
    return result
"""
        body_b = """
def process_beta(items):
    result = []
    for item in items:
        if item > 0:
            result.append(item * 2)
    return result
"""
        norm_a = _normalize_body(body_a)
        norm_b = _normalize_body(body_b)
        # After normalization, these should be very similar
        # (they differ only in parameter names which are NOT stripped by our normalizer,
        # but the important thing is the normalizer runs without error)
        assert len(norm_a) > 10
        assert len(norm_b) > 10


# ===========================================================================
# Tests: Edge cases
# ===========================================================================


class TestVibeCheckEdgeCases:
    """Edge cases and robustness."""

    def test_empty_project(self, tmp_path, cli_runner):
        """vibe-check on an empty project doesn't crash."""
        repo = tmp_path / "empty"
        repo.mkdir()
        (repo / ".gitignore").write_text(".roam/\n")
        (repo / "empty.py").write_text("# empty file\n")
        git_init(repo)
        old_cwd = os.getcwd()
        try:
            os.chdir(str(repo))
            out, rc = index_in_process(repo)
            assert rc == 0, f"index failed: {out}"
            result = _invoke(cli_runner, repo)
            assert result.exit_code == 0
            assert "VERDICT:" in result.output
        finally:
            os.chdir(old_cwd)

    def test_json_deterministic_keys(self, cli_runner, clean_project):
        """JSON output uses sorted keys for deterministic output."""
        result = _invoke(cli_runner, clean_project, json_mode=True)
        data = json.loads(result.output)
        # Check that command comes before summary (alphabetical)
        keys = list(data.keys())
        assert keys.index("command") < keys.index("summary")

    def test_help_flag(self, cli_runner):
        """--help works for vibe-check."""
        from roam.cli import cli

        result = cli_runner.invoke(cli, ["vibe-check", "--help"])
        assert result.exit_code == 0
        assert "AI" in result.output or "rot" in result.output or "anti-pattern" in result.output


# ===========================================================================
# Tests: Worst files aggregation
# ===========================================================================


class TestWorstFiles:
    """Test worst files aggregation."""

    def test_worst_files_in_dirty_project(self, cli_runner, dirty_project):
        """Dirty project should have worst files listed."""
        result = _invoke(cli_runner, dirty_project, json_mode=True)
        data = json.loads(result.output)
        # At least some files should be in worst_files if issues are detected
        if data["summary"]["total_issues"] > 0:
            # worst_files might be empty if all issues are non-file-specific
            assert isinstance(data["worst_files"], list)

    def test_worst_files_structure(self, cli_runner, dirty_project):
        """Worst files entries have the right structure."""
        result = _invoke(cli_runner, dirty_project, json_mode=True)
        data = json.loads(result.output)
        for wf in data["worst_files"]:
            assert "file" in wf
            assert "total_issues" in wf
            assert "breakdown" in wf
            assert isinstance(wf["total_issues"], int)
            assert wf["total_issues"] > 0


# ===========================================================================
# Tests: pattern-rate unit invariant (dogfood FP #2 — boilerplate >100%)
# ===========================================================================


@pytest.fixture
def boilerplate_dense_project(tmp_path):
    """A project whose ONE file carries MULTIPLE boilerplate-inflation hits.

    Two public shallow-wrappers (real docstring + single return call) live in
    a single file. Pre-fix this produced found=2 occurrences against
    total_files_scanned=1 → an impossible 200% rate. Post-fix ``found`` counts
    AFFECTED FILES (1), so the rate stays a proper fraction.
    """
    repo = tmp_path / "boiler"
    repo.mkdir()
    (repo / ".gitignore").write_text(".roam/\n")
    (repo / "wrappers.py").write_text(
        "def fetch_user(uid):\n"
        '    """Fetch the user record for the given id."""\n'
        "    return repo_get_user(uid)\n"
        "\n\n"
        "def fetch_order(oid):\n"
        '    """Fetch the order record for the given id."""\n'
        "    return repo_get_order(oid)\n"
        "\n\n"
        "def repo_get_user(uid):\n"
        "    return uid\n"
        "\n\n"
        "def repo_get_order(oid):\n"
        "    return oid\n"
    )
    git_init(repo)
    old_cwd = os.getcwd()
    try:
        os.chdir(str(repo))
        out, rc = index_in_process(repo)
        assert rc == 0, f"index failed: {out}"
    finally:
        os.chdir(old_cwd)
    return repo


class TestPatternRateInvariant:
    """Every vibe-check pattern rate must be a real fraction ∈ [0, 100]%."""

    def test_boilerplate_detector_found_le_total(self, boilerplate_dense_project):
        """Unit invariant: ``found`` (per-file) never exceeds ``total`` (files scanned)."""
        from roam.commands.cmd_vibe_check import _detect_boilerplate_inflation
        from roam.db.connection import open_db

        with open_db(readonly=True, project_root=boilerplate_dense_project) as conn:
            found, total, details = _detect_boilerplate_inflation(conn, boilerplate_dense_project)

        # The crafted file has 2 occurrences but counts as 1 affected file.
        assert found == len(details), "found must equal the affected-file count (per-file unit)"
        assert found <= total, f"found ({found}) must not exceed files scanned ({total})"
        # Sanity: the multi-occurrence file is still recorded with its count.
        if details:
            assert details[0]["count"] >= 2

    def test_all_pattern_rates_within_bounds(self, cli_runner, boilerplate_dense_project):
        """No pattern (incl. boilerplate_inflation) may report a >100% rate."""
        result = _invoke(cli_runner, boilerplate_dense_project, json_mode=True)
        data = json.loads(result.output)
        for p in data["patterns"]:
            rate = p["rate"]
            assert 0.0 <= rate <= 100.0, f"{p['name']} rate {rate} outside [0, 100]"

    def test_boilerplate_rate_within_bounds_unit(self, boilerplate_dense_project):
        """Direct check on the boilerplate ratio: found/total ∈ [0, 1]."""
        from roam.commands.cmd_vibe_check import _detect_boilerplate_inflation
        from roam.db.connection import open_db

        with open_db(readonly=True, project_root=boilerplate_dense_project) as conn:
            found, total, _ = _detect_boilerplate_inflation(conn, boilerplate_dense_project)

        ratio = found / max(total, 1)
        assert 0.0 <= ratio <= 1.0, f"boilerplate ratio {ratio} outside [0, 1]"
