"""Tests for file scoring and selection from analysis/scoring.py.

Tests score_files and select_key_files functions. Pure functions with zero IO.
All fields asserted. Minimum 1 error/edge case test per 2 happy path tests.
"""

from __future__ import annotations

from hypothesis import given, settings
from hypothesis import strategies as st

from tests.conftest import make_file_info, make_parsed_file
from unfamiliar.analysis.graph.import_graph import ImportGraph
from unfamiliar.analysis.models import FileInfo, FileTestInfo, ParsedFile, ScoredFile
from unfamiliar.analysis.scoring import score_files, select_key_files


class TestScoreFiles:
    """Tests for score_files function."""

    def test_empty_files_returns_empty_list(self):
        """score_files with empty files list returns empty list."""
        result = score_files(
            [], {}, ImportGraph(), FileTestInfo(test_files=set()), set()
        )
        assert result == []

    def test_no_scoring_signals_returns_zero_score(self):
        """File with no signals has a score=0 and no reasons."""
        files = [make_file_info("src/utils.py")]
        parsed = {"src/utils.py": make_parsed_file("src/utils.py")}
        graph = ImportGraph()
        test_info = FileTestInfo(test_files=set())

        result = score_files(files, parsed, graph, test_info, set())

        assert len(result) == 1
        assert result[0].path == "src/utils.py"
        assert result[0].score == 0
        assert result[0].reasons == []

    def test_entry_point_file_gets_100_points(self):
        """File with entry point gets +100 score and entry point reason."""
        files = [make_file_info("src/main.py")]
        parsed = {"src/main.py": make_parsed_file("src/main.py")}
        graph = ImportGraph()
        test_info = FileTestInfo(test_files=set())
        entry_points = {"src/main.py"}

        result = score_files(files, parsed, graph, test_info, entry_points)

        assert len(result) == 1
        assert result[0].score == 100
        assert "entry point" in result[0].reasons

    def test_hub_file_gets_50_points(self):
        """File with in_degree >= 3 gets +50 score and hub reason."""
        files = [make_file_info("src/hub.py")]
        parsed = {"src/hub.py": make_parsed_file("src/hub.py")}
        graph = ImportGraph()
        graph.in_degree = {"src/hub.py": 5}
        test_info = FileTestInfo(test_files=set())

        result = score_files(files, parsed, graph, test_info, set())

        assert result[0].score == 50
        assert any("hub" in r.lower() for r in result[0].reasons)

    def test_high_complexity_file_gets_30_points(self):
        """File with complexity >= 15 gets +30 score and complex reason."""
        files = [make_file_info("src/complex.py")]
        parsed = {"src/complex.py": make_parsed_file("src/complex.py", complexity=20)}
        graph = ImportGraph()
        test_info = FileTestInfo(test_files=set())

        result = score_files(files, parsed, graph, test_info, set())

        assert result[0].score == 30
        assert any("complex" in r.lower() for r in result[0].reasons)

    def test_test_file_gets_20_points(self):
        """File in test_files set gets +20 score and test reason."""
        files = [make_file_info("tests/test_main.py")]
        parsed = {"tests/test_main.py": make_parsed_file("tests/test_main.py")}
        graph = ImportGraph()
        test_info = FileTestInfo(test_files={"tests/test_main.py"})

        result = score_files(files, parsed, graph, test_info, set())

        assert result[0].score == 20
        assert any("test" in r.lower() for r in result[0].reasons)

    def test_all_4_signals_combined_higher_than_any_single(self):
        """File with all 4 signals scores higher than any individual signal."""
        files = [make_file_info("src/cli.py")]
        parsed = {"src/cli.py": make_parsed_file("src/cli.py", complexity=20)}
        graph = ImportGraph()
        graph.in_degree = {"src/cli.py": 5}
        test_info = FileTestInfo(test_files={"src/cli.py"})
        entry_points = {"src/cli.py"}

        result = score_files(files, parsed, graph, test_info, entry_points)

        # All 4 signal reasons present
        assert len(result[0].reasons) == 4

        # All signals are additive: 100 (entry) + 50 (hub) + 30 (complexity) + 20 (test)
        assert result[0].score == 200

    def test_sort_order_highest_score_first(self):
        """Files are sorted by score descending."""
        files = [
            make_file_info("a.py"),
            make_file_info("b.py"),
            make_file_info("c.py"),
        ]
        parsed = {
            "a.py": make_parsed_file("a.py", complexity=20),  # 30 (complexity)
            "b.py": make_parsed_file("b.py"),  # 100 (entry point via dict)
            "c.py": make_parsed_file("c.py"),  # 0
        }
        graph = ImportGraph()
        test_info = FileTestInfo(test_files=set())
        entry_points = {"b.py"}

        result = score_files(files, parsed, graph, test_info, entry_points)

        assert result[0].path == "b.py"
        assert result[0].score == 100
        assert result[1].path == "a.py"
        assert result[1].score == 30
        assert result[2].path == "c.py"
        assert result[2].score == 0

    def test_tie_break_by_path_alphabetically(self):
        """Tied scores are sorted by path alphabetically."""
        files = [
            make_file_info("z.py"),
            make_file_info("a.py"),
            make_file_info("m.py"),
        ]
        parsed = {f: make_parsed_file(f) for f in ["z.py", "a.py", "m.py"]}
        graph = ImportGraph()
        test_info = FileTestInfo(test_files=set())

        result = score_files(files, parsed, graph, test_info, set())

        # All have 0 score, should be sorted by path
        assert result[0].path == "a.py"
        assert result[1].path == "m.py"
        assert result[2].path == "z.py"

    def test_all_scored_file_fields_populated(self):
        """ScoredFile has all fields populated correctly."""
        files = [make_file_info("src/main.py")]
        parsed = {"src/main.py": make_parsed_file("src/main.py")}
        graph = ImportGraph()
        test_info = FileTestInfo(test_files=set())
        entry_points = {"src/main.py"}

        result = score_files(files, parsed, graph, test_info, entry_points)

        assert len(result) == 1
        sf = result[0]
        assert isinstance(sf, ScoredFile)
        assert sf.path == "src/main.py"
        assert isinstance(sf.score, int)
        assert isinstance(sf.reasons, list)
        assert len(sf.reasons) == 1

    def test_hub_boundary_in_degree_3_gets_50_points(self):
        """File with in_degree exactly 3 (the threshold) gets hub bonus."""
        files = [make_file_info("src/hub.py")]
        parsed = {"src/hub.py": make_parsed_file("src/hub.py")}
        graph = ImportGraph()
        graph.in_degree = {"src/hub.py": 3}
        test_info = FileTestInfo(test_files=set())

        result = score_files(files, parsed, graph, test_info, set())

        assert result[0].score == 50
        assert any("hub" in r.lower() for r in result[0].reasons)

    def test_hub_below_boundary_in_degree_2_gets_0_points(self):
        """File with in_degree < 3 (the threshold) does not get hub bonus."""
        files = [make_file_info("src/hub.py")]
        parsed = {"src/hub.py": make_parsed_file("src/hub.py")}
        graph = ImportGraph()
        graph.in_degree = {"src/hub.py": 2}
        test_info = FileTestInfo(test_files=set())

        result = score_files(files, parsed, graph, test_info, set())

        assert result[0].score == 0
        assert result[0].reasons == []

    def test_entry_point_plus_hub_is_additive(self):
        """Entry point + hub bonuses are additive, not replacing"""
        files = [make_file_info("src/main.py")]
        parsed = {"src/main.py": make_parsed_file("src/main.py")}
        graph = ImportGraph()
        graph.in_degree = {"src/main.py": 3}
        test_info = FileTestInfo(test_files=set())
        entry_points = {"src/main.py"}

        result = score_files(files, parsed, graph, test_info, entry_points)

        # 100 (entry point) + 50 (hub) = 150
        assert result[0].score == 150
        assert any("entry point" in r.lower() for r in result[0].reasons)
        assert any("hub" in r.lower() for r in result[0].reasons)

    def test_file_not_in_parsed_files_gets_zero_complexity_bonus(self):
        """A file not in parsed_files (unknown language) gets no complexity bonus."""
        files = [make_file_info("src/utils.rb")]  # currently Ruby is not parsed
        parsed = {}
        graph = ImportGraph()
        test_info = FileTestInfo(test_files=set())

        result = score_files(files, parsed, graph, test_info, set())

        assert len(result) == 1
        assert result[0].path == "src/utils.rb"
        assert result[0].score == 0
        assert result[0].reasons == []


class TestSelectKeyFiles:
    """Tests for select_key_files function."""

    def test_empty_scored_files_returns_empty(self):
        """select_key_files with empty list returns empty list."""
        result = select_key_files([], {}, token_budget=1000)
        assert result == []

    def test_all_files_fit_within_budget(self):
        """All files selected when total tokens under budget."""
        scored = [
            ScoredFile(path="a.py", score=100, reasons=["test"]),
            ScoredFile(path="b.py", score=50, reasons=["test"]),
        ]
        files = {
            "a.py": make_file_info("a.py", size_bytes=100),
            "b.py": make_file_info("b.py", size_bytes=100),
        }

        result = select_key_files(scored, files, token_budget=100)

        assert len(result) == 2

    def test_first_file_exceeds_budget_still_selected(self):
        """At least one file selected even if it exceeds budget."""
        scored = [
            ScoredFile(path="large.py", score=100, reasons=["test"]),
        ]
        files = {
            "large.py": make_file_info("large.py", size_bytes=1000),
        }

        result = select_key_files(scored, files, token_budget=50)

        assert len(result) == 1
        assert result[0].path == "large.py"

    def test_budget_exhausted_after_some_files(self):
        """Exactly N files selected when budget exhausted after Nth."""
        scored = [
            ScoredFile(path="a.py", score=100, reasons=[]),
            ScoredFile(path="b.py", score=50, reasons=[]),
            ScoredFile(path="c.py", score=25, reasons=[]),
        ]
        files = {
            "a.py": make_file_info("a.py", size_bytes=100),
            "b.py": make_file_info("b.py", size_bytes=100),
            "c.py": make_file_info("c.py", size_bytes=100),
        }

        # Budget for exactly 2 files (100 bytes * 0.25 = 25 tokens each)
        result = select_key_files(
            scored, files, token_budget=50, avg_tokens_per_byte=0.25
        )

        assert len(result) == 2

    def test_file_missing_in_files_dict_skipped(self):
        """File in scored_files but not in files dict is skipped."""
        scored = [
            ScoredFile(path="exists.py", score=100, reasons=[]),
            ScoredFile(path="missing.py", score=50, reasons=[]),
        ]
        files = {
            "exists.py": make_file_info("exists.py", size_bytes=100),
        }

        result = select_key_files(scored, files, token_budget=1000)

        assert len(result) == 1
        assert result[0].path == "exists.py"

    def test_default_avg_tokens_per_byte(self):
        """select_key_files uses 0.25 as default avg_tokens_per_byte."""
        scored = [
            ScoredFile(path="a.py", score=100, reasons=[]),
            ScoredFile(path="b.py", score=50, reasons=[]),
        ]
        files = {
            "a.py": make_file_info("a.py", size_bytes=100),
            "b.py": make_file_info("b.py", size_bytes=100),
        }

        result = select_key_files(scored, files, token_budget=50)

        assert len(result) == 2


# --- Property-Based Tests ---


@settings(max_examples=50)
@given(
    st.lists(
        st.builds(
            FileInfo,
            path=st.text(
                alphabet=st.characters(whitelist_categories=["L"]), min_size=1
            ),
            language=st.just("python"),
            size_bytes=st.integers(min_value=0, max_value=10000),
            content_hash=st.just("hash"),
        ),
        min_size=1,
        max_size=10,
    )
)
def test_score_files_paths_preserved(file_infos):
    """score_files output paths always equal input paths."""

    parsed = {
        fi.path: ParsedFile(
            path=fi.path,
            language="python",
            imports=[],
            definitions=[],
            entry_points=[],
            complexity=0,
            line_count=0,
        )
        for fi in file_infos
    }
    graph = ImportGraph()
    test_info = FileTestInfo(test_files=set())

    result = score_files(file_infos, parsed, graph, test_info, set())

    assert {sf.path for sf in result} == {f.path for f in file_infos}


_CANDIDATE_PATHS = ["a.py", "b.py", "c.py", "d.py", "e.py"]


@settings(max_examples=50)
@given(
    paths=st.lists(
        st.sampled_from(_CANDIDATE_PATHS),
        min_size=2,
        max_size=5,
        unique=True,
    ),
    scores=st.lists(st.integers(0, 100), min_size=2, max_size=5),
)
def test_select_key_files_never_exceeds_budget(paths, scores):
    """select_key_files total tokens never exceed budget (except for the first file)."""
    # Build scored_files and files dict from the same set of paths
    scored_files = [
        ScoredFile(path=p, score=scores[i % len(scores)], reasons=[])
        for i, p in enumerate(paths)
    ]
    files = {p: make_file_info(p, size_bytes=100) for p in paths}
    budget = 50  # 100 bytes * 0.25 = 25 tokens per file; budget fits exactly 2

    result = select_key_files(scored_files, files, token_budget=budget)

    if len(result) > 1:
        total_tokens = sum(
            files[sf.path].size_bytes * 0.25 for sf in result if sf.path in files
        )
        assert total_tokens <= budget
