"""MCP-level allocator inventory and dependency regressions (#758).

Exercises ``_allocator_candidates_from_gitea`` and the ``gitea_allocate_next_work``
tool end to end against a faked Gitea API, proving:

* the complete open-issue inventory is ranked (no pre-ranking truncation);
* ``limit`` cannot change which candidate wins;
* canonical ``Depends:`` declarations are resolved from live issue state;
* unavailable dependency evidence fails closed;
* an incomplete listing fails closed instead of ranking a partial set.

Issue numbers here are synthetic; no production number is special-cased.
"""

from __future__ import annotations

import os
import tempfile
import unittest
from unittest.mock import patch

import gitea_mcp_server as srv
from control_plane_db import ControlPlaneDB

FAKE_AUTH = "token REDACTED"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"


def _issue(number: int, *, body: str = "", labels=("status:ready",)) -> dict:
    return {
        "number": number,
        "title": f"issue {number}",
        "body": body,
        "labels": [{"name": name} for name in labels],
        "state": "open",
    }


def _depends_body(*refs: int) -> str:
    joined = ", ".join(f"#{r}" for r in refs)
    return f"## Dependencies and linkage\n\n* Parent: #999 · Depends: {joined}\n"


class _FakeGitea:
    """Minimal stand-in for the two Gitea list endpoints plus issue lookups."""

    def __init__(self, issues, *, closed=(), unavailable=(), fail_issue_list=False):
        self.issues = list(issues)
        self.closed = set(closed)
        self.unavailable = set(unavailable)
        self.fail_issue_list = fail_issue_list
        self.lookups: list[int] = []

    def api_get_all(self, url, _auth, **_kw):
        if "/pulls" in url:
            return []
        if self.fail_issue_list:
            raise RuntimeError("issue listing failed")
        return list(self.issues)

    def api_request(self, _method, url, _auth, **_kw):
        number = int(url.rsplit("/", 1)[-1])
        self.lookups.append(number)
        if number in self.unavailable:
            raise RuntimeError("lookup failed")
        if number in self.closed:
            return {"number": number, "state": "closed"}
        return {"number": number, "state": "open"}


class AllocatorInventoryTest(unittest.TestCase):
    """Direct tests of the candidate loader."""

    def _load(self, fake, **kwargs):
        with patch("gitea_mcp_server._resolve", return_value=("h", ORG, REPO)), patch(
            "gitea_mcp_server._auth", return_value=FAKE_AUTH
        ), patch("gitea_mcp_server.api_get_all", side_effect=fake.api_get_all), patch(
            "gitea_mcp_server.api_request", side_effect=fake.api_request
        ):
            return srv._allocator_candidates_from_gitea(
                remote="prgs", host=None, org=ORG, repo=REPO, **kwargs
            )

    def test_full_inventory_above_fifty_is_ranked(self) -> None:
        """AC1: all 73 open issues become candidates, not the first 50."""
        fake = _FakeGitea([_issue(n) for n in range(600, 673)])
        candidates, _reasons, complete = self._load(fake)
        self.assertTrue(complete)
        self.assertEqual(len(candidates), 73)
        self.assertEqual(min(c.number for c in candidates), 600)
        self.assertEqual(max(c.number for c in candidates), 672)

    def test_open_dependency_marks_candidate_unmet(self) -> None:
        """AC4/AC5/AC6: canonical Depends on an open issue blocks the candidate."""
        fake = _FakeGitea(
            [_issue(600, body=_depends_body(601, 602)), _issue(601), _issue(602)]
        )
        candidates, _reasons, _complete = self._load(fake)
        blocked = next(c for c in candidates if c.number == 600)
        self.assertTrue(blocked.dependency_unmet)
        self.assertIn("#601", blocked.dependency_reason)

    def test_closed_dependency_is_eligible(self) -> None:
        """A dependency absent from the open list is confirmed closed, not assumed."""
        fake = _FakeGitea([_issue(600, body=_depends_body(500))], closed={500})
        candidates, _reasons, _complete = self._load(fake)
        candidate = next(c for c in candidates if c.number == 600)
        self.assertFalse(candidate.dependency_unmet)
        self.assertIn(500, fake.lookups)  # proved live, not inferred

    def test_unavailable_dependency_evidence_fails_closed(self) -> None:
        """AC7: an unreachable dependency must block, never pass."""
        fake = _FakeGitea([_issue(600, body=_depends_body(500))], unavailable={500})
        candidates, _reasons, _complete = self._load(fake)
        candidate = next(c for c in candidates if c.number == 600)
        self.assertTrue(candidate.dependency_unmet)
        self.assertIn("fail closed", candidate.dependency_reason)

    def test_dependency_state_lookups_are_cached(self) -> None:
        """Repeated references resolve with a single live lookup."""
        fake = _FakeGitea(
            [_issue(n, body=_depends_body(500)) for n in range(600, 610)],
            closed={500},
        )
        self._load(fake)
        self.assertEqual(fake.lookups.count(500), 1)

    def test_open_dependency_needs_no_lookup(self) -> None:
        """The complete open listing already proves openness."""
        fake = _FakeGitea([_issue(600, body=_depends_body(601)), _issue(601)])
        self._load(fake)
        self.assertNotIn(601, fake.lookups)

    def test_failed_issue_listing_reports_incomplete(self) -> None:
        """AC3: a failed listing must not silently yield a short inventory."""
        fake = _FakeGitea([], fail_issue_list=True)
        _candidates, reasons, complete = self._load(fake)
        self.assertFalse(complete)
        self.assertTrue(any("failed to list open issues" in r for r in reasons))


class AllocateNextWorkToolTest(unittest.TestCase):
    """End-to-end tests of the gitea_allocate_next_work MCP tool."""

    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()

    def _allocate(self, fake, **kwargs):
        with patch("gitea_mcp_server._profile_operation_gate", return_value=None), patch(
            "gitea_mcp_server._resolve", return_value=("h", ORG, REPO)
        ), patch("gitea_mcp_server._auth", return_value=FAKE_AUTH), patch(
            "gitea_mcp_server.get_profile",
            return_value={"profile_name": "prgs-author", "role": "author"},
        ), patch(
            "gitea_mcp_server._authenticated_username", return_value="jcwalker3"
        ), patch(
            "gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
        ), patch(
            "gitea_mcp_server.api_get_all", side_effect=fake.api_get_all
        ), patch(
            "gitea_mcp_server.api_request", side_effect=fake.api_request
        ), patch(
            "gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
        ):
            return srv.gitea_allocate_next_work(
                remote="prgs", org=ORG, repo=REPO, role="author", **kwargs
            )

    def test_limit_does_not_change_selection(self) -> None:
        """AC2/AC11: the winner is identical at limit=1 and limit=300."""
        issues = [_issue(n) for n in range(600, 673)]  # 73 candidates
        low = self._allocate(_FakeGitea(issues), limit=1)
        high = self._allocate(_FakeGitea(issues), limit=300)
        self.assertEqual(low["selected"]["number"], high["selected"]["number"])
        self.assertEqual(low["selected"]["number"], 600)
        self.assertEqual(low["candidate_count"], 73)
        self.assertEqual(high["candidate_count"], 73)

    def test_dependency_blocked_winner_falls_through(self) -> None:
        """AC8: a blocked highest-ranked issue yields the next eligible one."""
        issues = [
            _issue(600, body=_depends_body(601)),
            _issue(601),
            _issue(602),
        ]
        result = self._allocate(_FakeGitea(issues))
        # 600 is blocked by open 601; 601 itself is a valid candidate.
        self.assertEqual(result["selected"]["number"], 601)
        skipped = {s["number"] for s in result["skipped"]}
        self.assertIn(600, skipped)

    def test_limit_truncates_only_the_reported_skip_list(self) -> None:
        """A shortened report is labelled, never presented as full coverage."""
        issues = [_issue(n, body=_depends_body(999)) for n in range(600, 640)]
        issues.append(_issue(999))  # open dependency blocks all of the above
        issues.append(_issue(700))  # the one eligible candidate
        result = self._allocate(_FakeGitea(issues), limit=5)
        self.assertTrue(result["skipped_report_truncated"])
        self.assertEqual(len(result["skipped"]), 5)
        self.assertGreater(result["skipped_total"], 5)
        self.assertEqual(result["limit_applies_to"], "reported_skip_list_only")

    def test_incomplete_inventory_fails_closed(self) -> None:
        """AC3: no selection is made from a partial candidate set."""
        result = self._allocate(_FakeGitea([], fail_issue_list=True))
        self.assertFalse(result["success"])
        self.assertFalse(result["inventory_complete"])
        self.assertIsNone(result["assignment"])
        self.assertTrue(
            any("fail closed" in r for r in result["reasons"]),
            result["reasons"],
        )

    def test_selection_policy_is_reported(self) -> None:
        """AC10: tie-breaking is stated in the result, not left implicit."""
        result = self._allocate(_FakeGitea([_issue(600)]))
        self.assertIn("selection_policy", result)
        self.assertIn("number asc", result["selection_policy"])


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