"""Tests for the dead code aging, decay, and effort estimation features.

Covers:
- Unit tests for _decay_score(), _estimate_removal_minutes(), _decay_tier()
- CLI tests for --aging, --effort, --decay, --sort-by-age, --sort-by-effort,
  --sort-by-decay flags
- JSON output tests for extended dead code data
"""

from __future__ import annotations

import sys
from pathlib import Path

import pytest
from click.testing import CliRunner

sys.path.insert(0, str(Path(__file__).parent))
from conftest import invoke_cli, parse_json_output

from roam.commands.cmd_dead import _decay_score, _decay_tier, _estimate_removal_minutes


# W414: All CLI integration tests below are read-only against the indexed
# project (they invoke `roam dead` variants and validate output). Re-
# indexing on every test costs ~2s x 15. Override at module scope so the
# project is built and indexed once per worker. Mirrors W346's pattern in
# test_json_contracts.
@pytest.fixture(scope="module")
def indexed_project(tmp_path_factory):
    """Module-scoped indexed Python project for read-only dead-aging tests."""
    import textwrap

    proj = tmp_path_factory.mktemp("dead_aging_proj")
    (proj / ".gitignore").write_text(".roam/\n", encoding="utf-8")
    src = proj / "src"
    src.mkdir()

    (src / "models.py").write_text(
        textwrap.dedent(
            '''\
            class User:
                """A user model."""
                def __init__(self, name, email):
                    self.name = name
                    self.email = email

                def display_name(self):
                    return self.name.title()

                def validate_email(self):
                    return "@" in self.email


            class Admin(User):
                """An admin user."""
                def __init__(self, name, email, role="admin"):
                    super().__init__(name, email)
                    self.role = role

                def promote(self, user):
                    pass
            '''
        ),
        encoding="utf-8",
    )
    (src / "service.py").write_text(
        textwrap.dedent(
            '''\
            from models import User, Admin


            def create_user(name, email):
                """Create a new user."""
                user = User(name, email)
                if not user.validate_email():
                    raise ValueError("Invalid email")
                return user


            def get_display(user):
                """Get display name."""
                return user.display_name()


            def unused_helper():
                """This function is never called (dead code)."""
                return 42
            '''
        ),
        encoding="utf-8",
    )
    (src / "utils.py").write_text(
        textwrap.dedent(
            '''\
            def format_name(first, last):
                """Format a full name."""
                return f"{first} {last}"


            def parse_email(raw):
                """Parse an email address."""
                if "@" not in raw:
                    return None
                parts = raw.split("@")
                return {"user": parts[0], "domain": parts[1]}


            UNUSED_CONSTANT = "never_referenced"
            '''
        ),
        encoding="utf-8",
    )

    import os
    import subprocess

    env = {
        **os.environ,
        "GIT_AUTHOR_NAME": "test",
        "GIT_AUTHOR_EMAIL": "t@t.com",
        "GIT_COMMITTER_NAME": "test",
        "GIT_COMMITTER_EMAIL": "t@t.com",
    }
    subprocess.run(["git", "init"], cwd=str(proj), capture_output=True)
    subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=str(proj), capture_output=True)
    subprocess.run(["git", "config", "user.name", "Test"], cwd=str(proj), capture_output=True)
    subprocess.run(["git", "add", "."], cwd=str(proj), capture_output=True)
    subprocess.run(
        ["git", "commit", "-m", "init"],
        cwd=str(proj),
        capture_output=True,
        env=env,
    )

    from roam.cli import cli

    runner = CliRunner()
    old_cwd = os.getcwd()
    try:
        os.chdir(str(proj))
        result = runner.invoke(cli, ["index"], catch_exceptions=False)
    finally:
        os.chdir(old_cwd)
    assert result.exit_code == 0, f"roam index failed:\n{result.output}"
    return proj


# ============================================================================
# Unit tests for internal functions
# ============================================================================


class TestDecayScore:
    """Tests for _decay_score() internal function."""

    def test_fresh_code_low_score(self):
        """Fresh code with no complexity, single cluster, active author => low score."""
        score = _decay_score(
            age_days=0,
            cognitive_complexity=0,
            cluster_size=1,
            importing_files=0,
            author_active=True,
            dead_loc=5,
        )
        assert score >= 0
        assert score <= 25, f"Fresh code should have low decay score, got {score}"

    def test_old_complex_code_high_score(self):
        """Year-old code with high complexity, large cluster, inactive author => high score."""
        score = _decay_score(
            age_days=365,
            cognitive_complexity=20,
            cluster_size=5,
            importing_files=3,
            author_active=False,
            dead_loc=100,
        )
        assert score >= 50, f"Old complex code should have high decay score, got {score}"

    def test_score_bounded_0_to_100(self):
        """Score is always in [0, 100] even with extreme inputs."""
        low = _decay_score(0, 0, 1, 0, True, 1)
        assert 0 <= low <= 100

        high = _decay_score(10000, 100, 50, 100, False, 10000)
        assert 0 <= high <= 100

    def test_inactive_author_adds_points(self):
        """Inactive author should increase the decay score vs. active author."""
        score_active = _decay_score(
            age_days=100,
            cognitive_complexity=5,
            cluster_size=1,
            importing_files=0,
            author_active=True,
            dead_loc=10,
        )
        score_inactive = _decay_score(
            age_days=100,
            cognitive_complexity=5,
            cluster_size=1,
            importing_files=0,
            author_active=False,
            dead_loc=10,
        )
        assert score_inactive > score_active

    def test_higher_complexity_increases_score(self):
        """Higher cognitive complexity should increase the decay score."""
        score_low_cc = _decay_score(
            age_days=100,
            cognitive_complexity=0,
            cluster_size=1,
            importing_files=0,
            author_active=True,
            dead_loc=10,
        )
        score_high_cc = _decay_score(
            age_days=100,
            cognitive_complexity=20,
            cluster_size=1,
            importing_files=0,
            author_active=True,
            dead_loc=10,
        )
        assert score_high_cc > score_low_cc

    def test_older_code_increases_score(self):
        """Older code should have a higher decay score than newer code."""
        score_new = _decay_score(
            age_days=1,
            cognitive_complexity=5,
            cluster_size=1,
            importing_files=0,
            author_active=True,
            dead_loc=10,
        )
        score_old = _decay_score(
            age_days=1000,
            cognitive_complexity=5,
            cluster_size=1,
            importing_files=0,
            author_active=True,
            dead_loc=10,
        )
        assert score_old > score_new

    def test_score_is_integer(self):
        """_decay_score should always return an integer."""
        score = _decay_score(180, 10, 3, 2, False, 50)
        assert isinstance(score, int)


class TestDecayTier:
    """Tests for _decay_tier() internal function."""

    def test_fresh_tier(self):
        assert _decay_tier(0) == "Fresh"
        assert _decay_tier(10) == "Fresh"
        assert _decay_tier(25) == "Fresh"

    def test_stale_tier(self):
        assert _decay_tier(26) == "Stale"
        assert _decay_tier(30) == "Stale"
        assert _decay_tier(50) == "Stale"

    def test_decayed_tier(self):
        assert _decay_tier(51) == "Decayed"
        assert _decay_tier(60) == "Decayed"
        assert _decay_tier(75) == "Decayed"

    def test_fossilized_tier(self):
        assert _decay_tier(76) == "Fossilized"
        assert _decay_tier(80) == "Fossilized"
        assert _decay_tier(100) == "Fossilized"

    def test_tier_boundary_values(self):
        """Boundary values at tier transitions."""
        assert _decay_tier(25) == "Fresh"
        assert _decay_tier(26) == "Stale"
        assert _decay_tier(50) == "Stale"
        assert _decay_tier(51) == "Decayed"
        assert _decay_tier(75) == "Decayed"
        assert _decay_tier(76) == "Fossilized"


class TestEstimateRemovalMinutes:
    """Tests for _estimate_removal_minutes() internal function."""

    def test_returns_positive(self):
        """Removal estimate should always be positive."""
        result = _estimate_removal_minutes(
            dead_loc=5,
            cognitive_complexity=0,
            importing_files=0,
            cluster_size=1,
            age_years=0,
            author_active=True,
        )
        assert result > 0

    def test_increases_with_complexity(self):
        """Higher cognitive complexity should increase removal time."""
        low = _estimate_removal_minutes(
            dead_loc=10,
            cognitive_complexity=0,
            importing_files=0,
            cluster_size=1,
            age_years=1,
            author_active=True,
        )
        high = _estimate_removal_minutes(
            dead_loc=10,
            cognitive_complexity=20,
            importing_files=0,
            cluster_size=1,
            age_years=1,
            author_active=True,
        )
        assert high > low

    def test_increases_with_loc(self):
        """More lines of dead code should increase removal time."""
        small = _estimate_removal_minutes(
            dead_loc=5,
            cognitive_complexity=5,
            importing_files=0,
            cluster_size=1,
            age_years=1,
            author_active=True,
        )
        large = _estimate_removal_minutes(
            dead_loc=100,
            cognitive_complexity=5,
            importing_files=0,
            cluster_size=1,
            age_years=1,
            author_active=True,
        )
        assert large > small

    def test_active_author_reduces_effort(self):
        """Active author should reduce the removal effort."""
        active = _estimate_removal_minutes(
            dead_loc=20,
            cognitive_complexity=5,
            importing_files=0,
            cluster_size=1,
            age_years=1,
            author_active=True,
        )
        inactive = _estimate_removal_minutes(
            dead_loc=20,
            cognitive_complexity=5,
            importing_files=0,
            cluster_size=1,
            age_years=1,
            author_active=False,
        )
        assert active < inactive

    def test_increases_with_importing_files(self):
        """More importing files should increase removal time."""
        isolated = _estimate_removal_minutes(
            dead_loc=20,
            cognitive_complexity=5,
            importing_files=0,
            cluster_size=1,
            age_years=1,
            author_active=True,
        )
        coupled = _estimate_removal_minutes(
            dead_loc=20,
            cognitive_complexity=5,
            importing_files=10,
            cluster_size=1,
            age_years=1,
            author_active=True,
        )
        assert coupled > isolated

    def test_returns_float(self):
        """_estimate_removal_minutes should return a numeric value."""
        result = _estimate_removal_minutes(
            dead_loc=10,
            cognitive_complexity=5,
            importing_files=2,
            cluster_size=3,
            age_years=2,
            author_active=False,
        )
        assert isinstance(result, (int, float))


# ============================================================================
# CLI tests
# ============================================================================


class TestDeadAgingCLI:
    """CLI integration tests for dead --aging, --effort, --decay flags."""

    def test_dead_aging_runs(self, cli_runner, indexed_project, monkeypatch):
        """roam dead --aging exits 0."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["dead", "--aging"], cwd=indexed_project)
        assert result.exit_code == 0

    def test_dead_effort_runs(self, cli_runner, indexed_project, monkeypatch):
        """roam dead --effort exits 0."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["dead", "--effort"], cwd=indexed_project)
        assert result.exit_code == 0

    def test_dead_decay_runs(self, cli_runner, indexed_project, monkeypatch):
        """roam dead --decay exits 0."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["dead", "--decay"], cwd=indexed_project)
        assert result.exit_code == 0

    def test_dead_no_decay_keeps_list_drops_decay(self, cli_runner, indexed_project, monkeypatch):
        """`--no-decay` returns the SAME dead-export list (skips only the
        git-blame age/decay pass — the compile structural_dead probe's fast
        path). Pins: list_counts match the default run; no decay_distribution."""
        monkeypatch.chdir(indexed_project)
        default = parse_json_output(invoke_cli(cli_runner, ["--json", "dead"], cwd=indexed_project))
        fast = parse_json_output(invoke_cli(cli_runner, ["--json", "dead", "--no-decay"], cwd=indexed_project))
        # Same core dead set (the list is what the probe prefetches).
        assert fast["list_counts"]["high_confidence"] == default["list_counts"]["high_confidence"]
        # Decay distribution is computed by the default run but NOT under --no-decay.
        assert "decay_distribution" not in (fast.get("summary") or {})

    def test_dead_sort_by_age_runs(self, cli_runner, indexed_project, monkeypatch):
        """roam dead --sort-by-age exits 0."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["dead", "--sort-by-age"], cwd=indexed_project)
        assert result.exit_code == 0

    def test_dead_sort_by_effort_runs(self, cli_runner, indexed_project, monkeypatch):
        """roam dead --sort-by-effort exits 0."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["dead", "--sort-by-effort"], cwd=indexed_project)
        assert result.exit_code == 0

    def test_dead_sort_by_decay_runs(self, cli_runner, indexed_project, monkeypatch):
        """roam dead --sort-by-decay exits 0."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["dead", "--sort-by-decay"], cwd=indexed_project)
        assert result.exit_code == 0

    def test_dead_aging_text_shows_age_columns(self, cli_runner, indexed_project, monkeypatch):
        """roam dead --aging --all text output should include age-related headers."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["dead", "--aging", "--all"], cwd=indexed_project)
        assert result.exit_code == 0
        out = result.output
        # The table headers should include Age(d) or LastMod(d) or Author
        # (unless there are no dead symbols, which is also valid)
        if "Unreferenced" in out and "none" not in out.lower():
            assert "Age" in out or "age" in out.lower() or "Author" in out

    def test_dead_decay_text_shows_decay_info(self, cli_runner, indexed_project, monkeypatch):
        """roam dead --decay text output should include decay info."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["dead", "--decay", "--all"], cwd=indexed_project)
        assert result.exit_code == 0
        out = result.output
        if "Unreferenced" in out and "none" not in out.lower():
            # Should show decay distribution or tier
            assert (
                "Decay" in out
                or "decay" in out.lower()
                or "Fresh" in out
                or "Stale" in out
                or "Decayed" in out
                or "Fossilized" in out
            )

    def test_dead_combined_flags(self, cli_runner, indexed_project, monkeypatch):
        """roam dead --aging --effort --decay all combined should work."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(
            cli_runner,
            ["dead", "--aging", "--effort", "--decay", "--all"],
            cwd=indexed_project,
        )
        assert result.exit_code == 0


class TestDeadAgingJSON:
    """JSON output tests for dead code aging features."""

    def test_json_dead_aging_includes_aging_data(self, cli_runner, indexed_project, monkeypatch):
        """roam --json dead --aging includes aging data in symbol dicts."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(
            cli_runner,
            ["dead", "--aging"],
            cwd=indexed_project,
            json_mode=True,
        )
        data = parse_json_output(result, "dead --aging")
        # Check the envelope
        assert "summary" in data
        # R22 confidence triple shape — aging is nested under value
        all_syms = data.get("high_confidence", []) + data.get("low_confidence", [])
        if all_syms:
            sym = all_syms[0]
            assert set(sym.keys()) >= {"value", "confidence", "reason"}
            value = sym["value"]
            assert "aging" in value, f"Symbol value missing 'aging' key: {list(value.keys())}"
            aging = value["aging"]
            assert "age_days" in aging
            assert "dead_loc" in aging
            assert "author_active" in aging

    def test_json_dead_decay_includes_distribution(self, cli_runner, indexed_project, monkeypatch):
        """roam --json dead --decay includes decay_distribution in summary."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(
            cli_runner,
            ["dead", "--decay"],
            cwd=indexed_project,
            json_mode=True,
        )
        data = parse_json_output(result, "dead --decay")
        summary = data.get("summary", {})
        assert "decay_distribution" in summary, f"Missing 'decay_distribution' in summary: {list(summary.keys())}"
        dist = summary["decay_distribution"]
        for tier in ["fresh", "stale", "decayed", "fossilized"]:
            assert tier in dist, f"Missing '{tier}' in decay_distribution"

    def test_json_dead_effort_includes_total_hours(self, cli_runner, indexed_project, monkeypatch):
        """roam --json dead --effort includes total_effort_hours in summary."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(
            cli_runner,
            ["dead", "--effort"],
            cwd=indexed_project,
            json_mode=True,
        )
        data = parse_json_output(result, "dead --effort")
        summary = data.get("summary", {})
        assert "total_effort_hours" in summary, f"Missing 'total_effort_hours' in summary: {list(summary.keys())}"
        assert isinstance(summary["total_effort_hours"], (int, float))

    def test_json_dead_effort_symbols_have_effort_data(self, cli_runner, indexed_project, monkeypatch):
        """roam --json dead --effort individual symbols include effort fields."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(
            cli_runner,
            ["dead", "--effort"],
            cwd=indexed_project,
            json_mode=True,
        )
        data = parse_json_output(result, "dead --effort")
        # R22 confidence triple shape — effort is nested under value
        all_syms = data.get("high_confidence", []) + data.get("low_confidence", [])
        if all_syms:
            sym = all_syms[0]
            value = sym["value"]
            assert "effort" in value, f"Symbol value missing 'effort' key: {list(value.keys())}"
            effort = value["effort"]
            assert "removal_minutes" in effort
            assert effort["removal_minutes"] >= 0

    def test_json_dead_decay_symbols_have_decay_score(self, cli_runner, indexed_project, monkeypatch):
        """roam --json dead --decay individual symbols include decay_score."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(
            cli_runner,
            ["dead", "--decay"],
            cwd=indexed_project,
            json_mode=True,
        )
        data = parse_json_output(result, "dead --decay")
        # R22 confidence triple shape — decay_score is nested under value
        all_syms = data.get("high_confidence", []) + data.get("low_confidence", [])
        if all_syms:
            sym = all_syms[0]
            value = sym["value"]
            assert "decay_score" in value, f"Symbol value missing 'decay_score': {list(value.keys())}"
            assert 0 <= value["decay_score"] <= 100

    def test_json_dead_summary_has_median_age(self, cli_runner, indexed_project, monkeypatch):
        """roam --json dead --aging summary includes median_age_days."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(
            cli_runner,
            ["dead", "--aging"],
            cwd=indexed_project,
            json_mode=True,
        )
        data = parse_json_output(result, "dead --aging")
        summary = data.get("summary", {})
        assert "median_age_days" in summary, f"Missing 'median_age_days' in summary: {list(summary.keys())}"
        assert isinstance(summary["median_age_days"], (int, float))
        assert summary["median_age_days"] >= 0
