"""Allocator pre-rank exclusions and candidates_json transport (#776).

Covers:
* #617 excluded before ranking (never leased when exclude_issue_numbers=[617]);
* excluded top candidate selects the next safe candidate;
* all candidates excluded → WAIT, no lease;
* decoded-list and JSON-string candidates_json;
* malformed / type-invalid fail-closed cases;
* dry-run/apply fingerprint match and drift rejection;
* foreign lease and same-owner lease on excluded issue;
* skipped-accounting reason parity (excluded_by_controller);
* public MCP entry-point coverage for exclude_issue_numbers.
"""

from __future__ import annotations

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

import gitea_mcp_server as srv
from allocator_service import (
    OUTCOME_ASSIGNED,
    OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE,
    OUTCOME_CANDIDATE_SET_DRIFT,
    OUTCOME_NO_SAFE,
    OUTCOME_PREVIEW,
    OUTCOME_WAIT,
    SKIP_CLAIMED_BY_OTHER_SESSION,
    SKIP_EXCLUDED_BY_CONTROLLER,
    WorkCandidate,
    allocate_next_work,
    candidate_from_dict,
    candidate_set_fingerprint,
    normalize_candidates_payload,
    normalize_exclude_issue_numbers,
)
from control_plane_db import ControlPlaneDB

REMOTE = "prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
MINE = "ctl-mine-776"
THEIRS = "ctl-theirs-776"


def _issue(number: int, **kwargs) -> WorkCandidate:
    base = dict(
        kind="issue",
        number=number,
        state="open",
        labels=("status:ready", "type:bug"),
        title=f"issue {number}",
        priority=20,
    )
    base.update(kwargs)
    return WorkCandidate(**base)


def _claim(number: int, *, session_id: str, instance: str | None, kind: str = "issue"):
    return {
        "lease_id": f"lease-{number}",
        "session_id": session_id,
        "controller_instance_id": instance,
        "role": "author",
        "profile": "prgs-author",
        "expires_at": "2026-07-21T12:00:00Z",
        "work_kind": kind,
        "work_number": number,
    }


def _cand_dict(number: int, **kwargs) -> dict:
    d = {
        "kind": "issue",
        "number": number,
        "state": "open",
        "labels": ["status:ready", "type:bug"],
        "title": f"issue {number}",
        "priority": 20,
    }
    d.update(kwargs)
    return d


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

    def _alloc(self, candidates, **kwargs):
        defaults = dict(
            session_id="sess-776",
            role="author",
            remote=REMOTE,
            org=ORG,
            repo=REPO,
            profile_name="prgs-author",
            controller_instance_id=MINE,
            claims={},
            apply=False,
        )
        defaults.update(kwargs)
        return allocate_next_work(self.db, candidates=candidates, **defaults)

    def test_exclude_617_before_ranking_never_selects(self) -> None:
        """AC2/AC8: highest-ranked #617 is removed before ranking."""
        cands = [_issue(617), _issue(700), _issue(701)]
        res = self._alloc(cands, exclude_issue_numbers=[617])
        self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
        self.assertEqual(res["selected"]["number"], 700)
        skipped = {s["number"]: s for s in res["skipped"]}
        self.assertIn(617, skipped)
        self.assertEqual(
            skipped[617]["reason_code"], SKIP_EXCLUDED_BY_CONTROLLER
        )
        self.assertIn(SKIP_EXCLUDED_BY_CONTROLLER, skipped[617]["reason"])

    def test_excluded_top_selects_next_safe(self) -> None:
        """AC2: excluding the oldest ready issue promotes the next number."""
        cands = [_issue(600), _issue(601), _issue(602)]
        res = self._alloc(cands, exclude_issue_numbers=[600])
        self.assertEqual(res["selected"]["number"], 601)

    def test_all_candidates_excluded_wait_no_lease(self) -> None:
        """AC7: every candidate excluded → WAIT, no assignment."""
        cands = [_issue(617), _issue(700)]
        res = self._alloc(cands, exclude_issue_numbers=[617, 700], apply=True)
        self.assertEqual(res["outcome"], OUTCOME_WAIT)
        self.assertIsNone(res["selected"])
        self.assertIsNone(res["assignment"])
        self.assertEqual(len(res["controller_excluded"]), 2)

    def test_omit_exclude_retains_existing_behavior(self) -> None:
        """AC1/AC9: omit exclusions → #617 still wins when oldest ready."""
        cands = [_issue(617), _issue(700)]
        res = self._alloc(cands)
        self.assertEqual(res["selected"]["number"], 617)
        self.assertEqual(res.get("exclude_issue_numbers"), [])

    def test_foreign_lease_still_skipped(self) -> None:
        """AC6/AC9: foreign claims keep SKIP_CLAIMED_BY_OTHER_SESSION."""
        cands = [_issue(617), _issue(700)]
        claims = {
            ("issue", 700): _claim(700, session_id="other", instance=THEIRS),
        }
        res = self._alloc(
            cands, exclude_issue_numbers=[617], claims=claims
        )
        # 617 excluded, 700 foreign → wait, no selection
        self.assertEqual(res["outcome"], OUTCOME_WAIT)
        self.assertIsNone(res["selected"])
        codes = {s["reason_code"] for s in res["skipped"]}
        self.assertIn(SKIP_EXCLUDED_BY_CONTROLLER, codes)
        self.assertIn(SKIP_CLAIMED_BY_OTHER_SESSION, codes)

    def test_same_owner_lease_on_excluded_blocks_resume_release(self) -> None:
        """AC5: excluded + live same-owner lease → structured blocker."""
        cands = [_issue(617), _issue(700)]
        claims = {
            ("issue", 617): _claim(617, session_id="sess-776", instance=MINE),
        }
        res = self._alloc(
            cands, exclude_issue_numbers=[617], claims=claims, apply=True
        )
        self.assertEqual(res["outcome"], OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE)
        self.assertIsNone(res["assignment"])
        self.assertEqual(res["blocked_lease"]["number"], 617)
        self.assertIn("resume", res["blocked_lease"]["safe_next_action"])

    def test_dry_run_apply_fingerprint_match(self) -> None:
        """AC4: dry-run and apply share the same fingerprint."""
        cands = [_issue(617), _issue(700)]
        dry = self._alloc(cands, exclude_issue_numbers=[617], apply=False)
        apply_res = self._alloc(
            cands,
            exclude_issue_numbers=[617],
            apply=True,
            expected_candidate_set_fingerprint=dry["candidate_set_fingerprint"],
        )
        self.assertEqual(
            dry["candidate_set_fingerprint"],
            apply_res["candidate_set_fingerprint"],
        )
        self.assertEqual(apply_res["outcome"], OUTCOME_ASSIGNED)
        self.assertEqual(apply_res["selected"]["number"], 700)

    def test_apply_rejects_fingerprint_drift(self) -> None:
        """AC4: material candidate-set drift fails closed on apply."""
        cands = [_issue(617), _issue(700)]
        res = self._alloc(
            cands,
            exclude_issue_numbers=[617],
            apply=True,
            expected_candidate_set_fingerprint="0" * 64,
        )
        self.assertFalse(res["success"])
        self.assertEqual(res["outcome"], OUTCOME_CANDIDATE_SET_DRIFT)
        self.assertIsNone(res["assignment"])

    def test_fingerprint_stable_helper(self) -> None:
        cands = [_issue(700), _issue(617)]
        a = candidate_set_fingerprint(cands, exclude_issue_numbers=[617])
        b = candidate_set_fingerprint(
            list(reversed(cands)), exclude_issue_numbers=[617]
        )
        self.assertEqual(a, b)

    def test_normalize_exclude_rejects_bool(self) -> None:
        with self.assertRaises(ValueError):
            normalize_exclude_issue_numbers([True])

    def test_normalize_exclude_rejects_scalar(self) -> None:
        with self.assertRaises(ValueError):
            normalize_exclude_issue_numbers(617)


class CandidatesJsonNormalizeTest(unittest.TestCase):
    def test_decoded_list(self) -> None:
        """AC3: already-decoded list from MCP transport."""
        cands = normalize_candidates_payload([_cand_dict(617), _cand_dict(700)])
        self.assertEqual([c.number for c in cands], [617, 700])

    def test_json_string(self) -> None:
        """AC3: backward-compatible JSON string."""
        raw = json.dumps([_cand_dict(617)])
        cands = normalize_candidates_payload(raw)
        self.assertEqual(cands[0].number, 617)

    def test_malformed_json_fail_closed(self) -> None:
        with self.assertRaises(ValueError) as ctx:
            normalize_candidates_payload("{not json")
        self.assertIn("malformed", str(ctx.exception).lower())

    def test_scalar_fail_closed(self) -> None:
        with self.assertRaises(ValueError):
            normalize_candidates_payload(42)

    def test_bool_number_fail_closed(self) -> None:
        with self.assertRaises(ValueError):
            normalize_candidates_payload([_cand_dict(True)])  # type: ignore[arg-type]

    def test_invalid_record_fail_closed(self) -> None:
        with self.assertRaises(ValueError):
            normalize_candidates_payload(["not-a-dict"])

    def test_object_not_list_fail_closed(self) -> None:
        with self.assertRaises(ValueError):
            normalize_candidates_payload(json.dumps({"number": 1}))

    def test_candidate_from_dict_rejects_bool_number(self) -> None:
        with self.assertRaises(ValueError):
            candidate_from_dict({"kind": "issue", "number": True})


class AllocateNextWorkMcpExcludeTest(unittest.TestCase):
    """Public MCP entry-point coverage (#776 AC8)."""

    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 _call(self, **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.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.sentry_observability.monitor_checkin", return_value=None
        ):
            return srv.gitea_allocate_next_work(
                remote="prgs", org=ORG, repo=REPO, role="author", **kwargs
            )

    def test_mcp_exclude_617_decoded_list_never_selects(self) -> None:
        """AC8: public tool with decoded list + exclude_issue_numbers=[617]."""
        candidates = [_cand_dict(617), _cand_dict(700)]
        res = self._call(
            candidates_json=candidates,
            exclude_issue_numbers=[617],
            apply=False,
        )
        self.assertTrue(res.get("success"), res)
        self.assertEqual(res["selected"]["number"], 700)
        skipped = {s["number"]: s for s in res["skipped"]}
        self.assertEqual(
            skipped[617]["reason_code"], SKIP_EXCLUDED_BY_CONTROLLER
        )
        self.assertNotEqual(res["selected"]["number"], 617)

    def test_mcp_exclude_617_json_string_apply(self) -> None:
        """AC8: JSON-string transport + apply never leases #617."""
        raw = json.dumps([_cand_dict(617), _cand_dict(700)])
        res = self._call(
            candidates_json=raw,
            exclude_issue_numbers=[617],
            apply=True,
        )
        self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
        self.assertEqual(res["assignment"]["work_number"], 700)
        self.assertNotEqual(res["selected"]["number"], 617)

    def test_mcp_malformed_candidates_json_fail_closed(self) -> None:
        res = self._call(candidates_json="{bad", apply=False)
        self.assertFalse(res["success"])
        self.assertIsNone(res["assignment"])
        self.assertTrue(any("fail closed" in r for r in res["reasons"]))

    def test_mcp_bool_number_fail_closed(self) -> None:
        res = self._call(
            candidates_json=[{"kind": "issue", "number": True, "priority": 20}],
            apply=False,
        )
        self.assertFalse(res["success"])
        self.assertIsNone(res["assignment"])

    def test_mcp_fingerprint_dry_run_apply_parity(self) -> None:
        candidates = [_cand_dict(617), _cand_dict(700)]
        dry = self._call(
            candidates_json=candidates,
            exclude_issue_numbers=[617],
            apply=False,
        )
        apply_res = self._call(
            candidates_json=candidates,
            exclude_issue_numbers=[617],
            apply=True,
            expected_candidate_set_fingerprint=dry["candidate_set_fingerprint"],
        )
        self.assertEqual(
            dry["candidate_set_fingerprint"],
            apply_res["candidate_set_fingerprint"],
        )
        self.assertEqual(apply_res["selected"]["number"], 700)


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