"""Dependency parsing/resolution and allocator completeness tests (#758).

Covers the two defects behind #758:

* Defect 1 — candidate truncation before ranking, which let a result-size
  parameter change the winner.
* Defect 2 — dependency state inferred from body substrings, which emitted
  canonical ``Depends:`` blocked issues as eligible.

No production behavior is special-cased for any issue number (#758 AC14), so
these tests use synthetic issue numbers throughout.
"""

from __future__ import annotations

import os
import tempfile
import unittest

import allocator_dependencies
from allocator_service import (
    OUTCOME_PREVIEW,
    SELECTION_POLICY,
    WorkCandidate,
    allocate_next_work,
    classify_skip,
    sort_candidates,
)
from control_plane_db import ControlPlaneDB

# The canonical linkage line this repository writes into issue bodies.
CANONICAL_BODY = (
    "## Dependencies and linkage\n\n"
    "* Parent: #900 · Depends: #901, #902 · Related: #903, #904\n"
)


class ParseDependencyRefsTest(unittest.TestCase):
    def test_parses_canonical_depends_field(self) -> None:
        self.assertEqual(
            allocator_dependencies.parse_dependency_refs(CANONICAL_BODY),
            (901, 902),
        )

    def test_stops_at_sibling_field_and_ignores_related(self) -> None:
        """"Related:" refs must never be treated as dependencies."""
        refs = allocator_dependencies.parse_dependency_refs(CANONICAL_BODY)
        self.assertNotIn(903, refs)
        self.assertNotIn(904, refs)
        self.assertNotIn(900, refs)  # Parent is not a dependency

    def test_single_reference(self) -> None:
        body = "* Parent: #10 · Depends: #11 · Related: #12"
        self.assertEqual(allocator_dependencies.parse_dependency_refs(body), (11,))

    def test_depends_on_spelling_and_and_separator(self) -> None:
        body = "Depends on #21 and #22\n"
        self.assertEqual(
            allocator_dependencies.parse_dependency_refs(body), (21, 22)
        )

    def test_newline_terminates_declaration(self) -> None:
        body = "Depends: #31, #32\nRelated: #33\n"
        self.assertEqual(
            allocator_dependencies.parse_dependency_refs(body), (31, 32)
        )

    def test_legacy_blocked_on_marker_still_recognized(self) -> None:
        body = "This work is blocked on #41 until that lands.\n"
        self.assertEqual(allocator_dependencies.parse_dependency_refs(body), (41,))

    def test_dependencies_heading_alone_is_not_a_declaration(self) -> None:
        """"Dependencies and linkage" must not parse as "Depends"."""
        body = "## Dependencies and linkage\n\n* Related: #51\n"
        self.assertEqual(allocator_dependencies.parse_dependency_refs(body), ())

    def test_deduplicates_and_preserves_order(self) -> None:
        body = "Depends: #61, #62, #61\n"
        self.assertEqual(
            allocator_dependencies.parse_dependency_refs(body), (61, 62)
        )

    def test_malformed_and_empty_inputs(self) -> None:
        for body in ("", None, "Depends:", "Depends: none", "Depends: TBD\n"):
            self.assertEqual(allocator_dependencies.parse_dependency_refs(body), ())


class ResolveDependencyStateTest(unittest.TestCase):
    def test_open_dependency_is_unmet(self) -> None:
        result = allocator_dependencies.resolve_dependency_state(
            (901, 902), lambda n: "open", subject="issue#644"
        )
        self.assertTrue(result["dependency_unmet"])
        self.assertEqual(result["unmet"], (901, 902))
        self.assertIn("#901", result["reason"])

    def test_closed_dependencies_are_met(self) -> None:
        result = allocator_dependencies.resolve_dependency_state(
            (901, 902), lambda n: "closed"
        )
        self.assertFalse(result["dependency_unmet"])
        self.assertEqual(result["met"], (901, 902))
        self.assertIsNone(result["reason"])

    def test_mixed_open_and_closed_is_unmet(self) -> None:
        states = {901: "closed", 902: "open"}
        result = allocator_dependencies.resolve_dependency_state(
            (901, 902), states.get
        )
        self.assertTrue(result["dependency_unmet"])
        self.assertEqual(result["unmet"], (902,))
        self.assertEqual(result["met"], (901,))

    def test_unavailable_evidence_fails_closed(self) -> None:
        """AC7: unknown state must block, never pass."""
        result = allocator_dependencies.resolve_dependency_state(
            (901,), lambda n: None
        )
        self.assertTrue(result["dependency_unmet"])
        self.assertEqual(result["unavailable"], (901,))
        self.assertIn("fail closed", result["reason"])

    def test_raising_lookup_fails_closed(self) -> None:
        def boom(_n: int) -> str:
            raise RuntimeError("lookup exploded")

        result = allocator_dependencies.resolve_dependency_state((901,), boom)
        self.assertTrue(result["dependency_unmet"])
        self.assertEqual(result["unavailable"], (901,))

    def test_no_refs_is_eligible(self) -> None:
        result = allocator_dependencies.resolve_dependency_state((), lambda n: None)
        self.assertFalse(result["dependency_unmet"])
        self.assertIsNone(result["reason"])


class DependencyBlockedCandidateTest(unittest.TestCase):
    """A dependency-blocked candidate must be skipped, not selected."""

    def test_classify_skip_rejects_unmet_dependency(self) -> None:
        candidate = WorkCandidate(
            kind="issue",
            number=644,
            labels=("status:ready",),
            priority=20,
            dependency_unmet=True,
            dependency_reason="issue#644 depends on unresolved issue(s) #633",
        )
        reason = classify_skip(candidate, role="author", terminal_pr=None)
        self.assertIsNotNone(reason)
        self.assertIn("#633", reason)


class SelectionInvarianceTest(unittest.TestCase):
    """AC1/AC2/AC11: ranking sees everything; result bounds cannot move the winner."""

    def setUp(self) -> None:
        self._tmp = tempfile.TemporaryDirectory()
        self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))

    def tearDown(self) -> None:
        self._tmp.cleanup()

    @staticmethod
    def _ready_issue(number: int, **kw) -> WorkCandidate:
        return WorkCandidate(
            kind="issue",
            number=number,
            labels=("status:ready",),
            priority=20,
            title=f"issue {number}",
            **kw,
        )

    def _preview(self, candidates):
        return allocate_next_work(
            self.db,
            session_id="s-758",
            role="author",
            remote="prgs",
            org="Scaled-Tech-Consulting",
            repo="Gitea-Tools",
            candidates=candidates,
            apply=False,
        )

    def test_more_than_fifty_candidates_lowest_number_wins(self) -> None:
        """Winner is the oldest eligible issue across a >50 inventory."""
        candidates = [self._ready_issue(n) for n in range(600, 700)]  # 100 items
        result = self._preview(candidates)
        self.assertEqual(result["outcome"], OUTCOME_PREVIEW)
        self.assertEqual(result["selected"]["number"], 600)

    def test_selection_is_invariant_to_candidate_ordering(self) -> None:
        """Ranking must not depend on the order the inventory arrived in."""
        forward = [self._ready_issue(n) for n in range(600, 700)]
        reverse = list(reversed(forward))
        self.assertEqual(
            self._preview(forward)["selected"]["number"],
            self._preview(reverse)["selected"]["number"],
        )

    def test_truncating_inventory_changes_winner(self) -> None:
        """Regression guard: this is exactly what pre-ranking slicing did.

        A 50-item slice of a 100-item inventory yields a different winner, so
        any future reintroduction of pre-ranking truncation is detectable.
        """
        full = [self._ready_issue(n) for n in range(600, 700)]
        sliced = sorted(full, key=lambda c: -c.number)[:50]
        self.assertNotEqual(
            self._preview(full)["selected"]["number"],
            self._preview(sliced)["selected"]["number"],
        )

    def test_blocked_first_candidate_falls_through_to_next(self) -> None:
        """AC8: a blocked winner must not end the iteration."""
        blocked = self._ready_issue(
            600,
            dependency_unmet=True,
            dependency_reason="issue#600 depends on unresolved issue(s) #599",
        )
        result = self._preview([blocked, self._ready_issue(601)])
        self.assertEqual(result["selected"]["number"], 601)
        skipped = {s["number"] for s in result["skipped"]}
        self.assertIn(600, skipped)

    def test_all_blocked_yields_no_safe_work(self) -> None:
        candidates = [
            self._ready_issue(
                n, dependency_unmet=True, dependency_reason=f"issue#{n} blocked"
            )
            for n in range(600, 605)
        ]
        result = self._preview(candidates)
        self.assertIsNone(result["selected"])
        self.assertEqual(len(result["skipped"]), 5)

    def test_dry_run_and_apply_select_identically(self) -> None:
        """AC9: apply mode must not re-rank differently from preview."""
        candidates = [self._ready_issue(n) for n in range(600, 700)]
        preview = self._preview(candidates)
        applied = allocate_next_work(
            self.db,
            session_id="s-758-apply",
            role="author",
            remote="prgs",
            org="Scaled-Tech-Consulting",
            repo="Gitea-Tools",
            candidates=candidates,
            apply=True,
        )
        self.assertEqual(
            preview["selected"]["number"], applied["selected"]["number"]
        )

    def test_sort_is_stable_and_documented(self) -> None:
        ordered = sort_candidates(
            [self._ready_issue(603), self._ready_issue(601), self._ready_issue(602)]
        )
        self.assertEqual([c.number for c in ordered], [601, 602, 603])
        self.assertIn("never affect selection", SELECTION_POLICY)


if __name__ == "__main__":
    unittest.main()
