"""Regression coverage for the PR checks assessor defect (#751).

Gitea's *combined* commit status reports ``state: pending`` both when a check is
executing and when the status-context collection is empty. The assessor read
``state`` alone and defaulted ``checks_required`` to ``True``, so a PR whose head
had no status contexts — and never would — was routed to ``blocked`` forever.

These tests pin the corrected semantics end to end: live branch protection
decides whether checks are required, and the actual context collection decides
what the checks say.
"""

from __future__ import annotations

import sys
import unittest
from unittest.mock import patch

sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))

import pr_sync_status  # noqa: E402
from pr_sync_status import (  # noqa: E402
    ACTION_BLOCKED,
    ACTION_MERGE_NOW,
    ACTION_UPDATE_BRANCH_BY_MERGE,
    CHECKS_FAILURE,
    CHECKS_MISSING_REQUIRED,
    CHECKS_NONE,
    CHECKS_NOT_REQUIRED,
    CHECKS_PENDING,
    CHECKS_SUCCESS,
    CHECKS_UNKNOWN,
    assess_pr_sync_status,
    classify_commit_checks,
)


def _sha(prefix: str) -> str:
    return (prefix + "0" * 40)[:40]


PR_HEAD = _sha("aaaaaaaa")
BASE_HEAD = _sha("bbbbbbbb")


def _base_kwargs(**overrides):
    data = {
        "host": "gitea.prgs.cc",
        "org": "Scaled-Tech-Consulting",
        "repo": "Gitea-Tools",
        "pr_number": 751,
        "pr_state": "open",
        "source_branch": "fix/issue-751-checks-assessor",
        "pr_head_sha": PR_HEAD,
        "base_head_sha": BASE_HEAD,
        "commits_behind": 0,
        "mergeable": True,
        "has_conflicts": False,
        "branch_protection_requires_current_base": False,
        "approval_at_current_head": True,
        "checks_status": "success",
        "active_author_lock": True,
        "active_reviewer_lease": False,
        "active_merger_lease": False,
    }
    data.update(overrides)
    return data


def _reasons(result) -> str:
    return " | ".join(result["reasons"]).lower()


class TestClassifyCommitChecks(unittest.TestCase):
    """Pure classification from live evidence."""

    def test_empty_collection_with_combined_pending_is_not_executing_ci(self):
        # The exact PR #750 failure mode at the classification layer.
        result = classify_commit_checks(
            combined_state="pending",
            statuses=[],
            checks_enabled=True,
            required_contexts=[],
        )
        self.assertNotEqual(result["checks_status"], CHECKS_PENDING)
        self.assertEqual(result["checks_status"], CHECKS_NONE)
        self.assertEqual(result["context_count"], 0)
        joined = " ".join(result["reasons"]).lower()
        self.assertIn("empty", joined)
        self.assertIn("does not indicate", joined)

    def test_protection_disables_status_checks(self):
        result = classify_commit_checks(
            combined_state="pending",
            statuses=[],
            checks_enabled=False,
            required_contexts=[],
        )
        self.assertFalse(result["checks_required"])
        self.assertEqual(result["checks_status"], CHECKS_NOT_REQUIRED)

    def test_no_required_contexts_aggregates_reported_contexts(self):
        result = classify_commit_checks(
            combined_state="success",
            statuses=[{"context": "build", "status": "success"}],
            checks_enabled=True,
            required_contexts=[],
        )
        self.assertTrue(result["checks_required"])
        self.assertEqual(result["checks_status"], CHECKS_SUCCESS)

    def test_required_checks_pending(self):
        result = classify_commit_checks(
            combined_state="pending",
            statuses=[{"context": "build", "status": "pending"}],
            checks_enabled=True,
            required_contexts=["build"],
        )
        self.assertEqual(result["checks_status"], CHECKS_PENDING)

    def test_required_checks_failed(self):
        result = classify_commit_checks(
            combined_state="failure",
            statuses=[{"context": "build", "status": "failure"}],
            checks_enabled=True,
            required_contexts=["build"],
        )
        self.assertEqual(result["checks_status"], CHECKS_FAILURE)

    def test_required_checks_successful(self):
        result = classify_commit_checks(
            combined_state="success",
            statuses=[{"context": "build", "status": "success"}],
            checks_enabled=True,
            required_contexts=["build"],
        )
        self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
        self.assertTrue(result["checks_required"])

    def test_required_context_configured_with_no_matching_result(self):
        result = classify_commit_checks(
            combined_state="success",
            statuses=[{"context": "lint", "status": "success"}],
            checks_enabled=True,
            required_contexts=["build"],
        )
        self.assertEqual(result["checks_status"], CHECKS_MISSING_REQUIRED)
        self.assertEqual(result["missing_required_contexts"], ["build"])

    def test_mixed_required_and_unrelated_contexts_ignores_unrelated(self):
        # An unrelated failing context must not fail a satisfied required set.
        result = classify_commit_checks(
            combined_state="failure",
            statuses=[
                {"context": "build", "status": "success"},
                {"context": "optional-scan", "status": "failure"},
            ],
            checks_enabled=True,
            required_contexts=["build"],
        )
        self.assertEqual(result["checks_status"], CHECKS_SUCCESS)
        # ...and a failing *required* context still fails despite passing extras.
        failing = classify_commit_checks(
            combined_state="success",
            statuses=[
                {"context": "build", "status": "failure"},
                {"context": "optional-scan", "status": "success"},
            ],
            checks_enabled=True,
            required_contexts=["build"],
        )
        self.assertEqual(failing["checks_status"], CHECKS_FAILURE)

    def test_policy_unreadable_fails_closed(self):
        result = classify_commit_checks(
            combined_state=None,
            statuses=[],
            checks_enabled=None,
            required_contexts=[],
            policy_determinable=False,
        )
        self.assertTrue(result["checks_required"])
        self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)
        self.assertIn("fail closed", " ".join(result["reasons"]).lower())

    def test_malformed_policy_indeterminate_flag_fails_closed(self):
        result = classify_commit_checks(
            combined_state="success",
            statuses=[{"context": "build", "status": "success"}],
            checks_enabled=None,
            required_contexts=[],
            policy_determinable=True,
        )
        self.assertTrue(result["checks_required"])
        self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)

    def test_status_collection_unreadable_fails_closed_when_required(self):
        result = classify_commit_checks(
            checks_enabled=True,
            required_contexts=["build"],
            status_determinable=False,
        )
        self.assertTrue(result["checks_required"])
        self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)

    def test_unrecognized_context_state_never_reads_as_success(self):
        result = classify_commit_checks(
            combined_state="success",
            statuses=[{"context": "build", "status": "banana"}],
            checks_enabled=True,
            required_contexts=["build"],
        )
        self.assertEqual(result["checks_status"], CHECKS_UNKNOWN)

    def test_newest_wins_per_context(self):
        # Gitea returns newest-first; the stale failure must not win.
        result = classify_commit_checks(
            combined_state="success",
            statuses=[
                {"context": "build", "status": "success"},
                {"context": "build", "status": "failure"},
            ],
            checks_enabled=True,
            required_contexts=["build"],
        )
        self.assertEqual(result["checks_status"], CHECKS_SUCCESS)

    def test_malformed_status_rows_are_discarded_not_treated_as_passing(self):
        result = classify_commit_checks(
            combined_state="pending",
            statuses=[{"context": "build"}, "not-a-dict", None],
            checks_enabled=True,
            required_contexts=["build"],
        )
        self.assertEqual(result["checks_status"], CHECKS_MISSING_REQUIRED)


class TestChecksGateSemantics(unittest.TestCase):
    """``assess_pr_sync_status`` routing and blocker reasons."""

    def test_not_required_allows_merge_now_with_empty_checks(self):
        result = assess_pr_sync_status(
            **_base_kwargs(checks_status=CHECKS_NOT_REQUIRED),
            checks_required=False,
        )
        self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
        self.assertTrue(result["approval_valid_for_merge"])
        self.assertFalse(result["checks_required"])
        self.assertIn("does not require status checks", _reasons(result))

    def test_none_blocks_when_checks_required(self):
        result = assess_pr_sync_status(
            **_base_kwargs(checks_status=CHECKS_NONE),
            checks_required=True,
        )
        self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
        self.assertIn("no status context", _reasons(result))
        self.assertIn("not executing ci", _reasons(result))

    def test_missing_required_blocks(self):
        result = assess_pr_sync_status(
            **_base_kwargs(checks_status=CHECKS_MISSING_REQUIRED),
            checks_required=True,
        )
        self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
        self.assertIn("no matching status result", _reasons(result))

    def test_required_pending_blocks(self):
        result = assess_pr_sync_status(
            **_base_kwargs(checks_status=CHECKS_PENDING), checks_required=True
        )
        self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
        self.assertIn("not finished", _reasons(result))

    def test_required_failure_blocks(self):
        result = assess_pr_sync_status(
            **_base_kwargs(checks_status=CHECKS_FAILURE), checks_required=True
        )
        self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
        self.assertIn("failed", _reasons(result))

    def test_required_success_merges(self):
        result = assess_pr_sync_status(
            **_base_kwargs(checks_status=CHECKS_SUCCESS), checks_required=True
        )
        self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)

    def test_unknown_blocks_when_required(self):
        result = assess_pr_sync_status(
            **_base_kwargs(checks_status=CHECKS_UNKNOWN), checks_required=True
        )
        self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
        self.assertIn("unknown", _reasons(result))

    def test_unrecognized_status_does_not_fall_through_to_merge(self):
        # Regression: the previous gate only handled a fixed vocabulary and let
        # anything else reach merge_now.
        result = assess_pr_sync_status(
            **_base_kwargs(checks_status="totally-unexpected"),
            checks_required=True,
        )
        self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
        self.assertIn("unrecognized checks status", _reasons(result))

    def test_checks_gate_does_not_bypass_approval_requirement(self):
        result = assess_pr_sync_status(
            **_base_kwargs(
                checks_status=CHECKS_NOT_REQUIRED, approval_at_current_head=False
            ),
            checks_required=False,
        )
        self.assertNotEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
        self.assertFalse(result["approval_valid_for_merge"])

    def test_checks_gate_does_not_bypass_current_base_protection(self):
        # Existing current-base behavior stays intact (#727).
        result = assess_pr_sync_status(
            **_base_kwargs(
                checks_status=CHECKS_NOT_REQUIRED,
                commits_behind=3,
                branch_protection_requires_current_base=True,
            ),
            checks_required=False,
        )
        self.assertEqual(
            result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
        )

    def test_checks_gate_does_not_bypass_conflicts(self):
        result = assess_pr_sync_status(
            **_base_kwargs(checks_status=CHECKS_NOT_REQUIRED, mergeable=False),
            checks_required=False,
        )
        self.assertNotEqual(result["recommended_next_action"], ACTION_MERGE_NOW)


class TestBranchProtectionPolicy(unittest.TestCase):
    """Live protection reader derives the status-check requirement."""

    def setUp(self):
        import gitea_mcp_server as gms

        self.gms = gms

    def _policy(self, responses):
        def fake_api_request(method, url, auth, *args, **kwargs):
            for fragment, payload in responses.items():
                if fragment in url:
                    if isinstance(payload, Exception):
                        raise payload
                    return payload
            return None

        with patch.object(self.gms, "api_request", side_effect=fake_api_request):
            return self.gms._branch_protection_policy(
                "https://example/api/v1/repos/o/r", {"h": "1"}, base_branch="master"
            )

    def test_status_checks_enabled_with_contexts(self):
        policy = self._policy({
            "branch_protections": [{
                "branch_name": "master",
                "block_on_outdated_branch": True,
                "enable_status_check": True,
                "status_check_contexts": ["ci/build", " "],
            }],
        })
        self.assertTrue(policy["determinable"])
        self.assertTrue(policy["checks_enabled"])
        self.assertTrue(policy["requires_current_base"])
        self.assertEqual(policy["required_contexts"], ["ci/build"])

    def test_status_checks_disabled(self):
        policy = self._policy({
            "branch_protections": [{
                "branch_name": "master",
                "enable_status_check": False,
            }],
        })
        self.assertTrue(policy["determinable"])
        self.assertFalse(policy["checks_enabled"])

    def test_no_protection_rule_means_checks_not_required(self):
        # PR #750's repository shape: protection list readable but empty.
        policy = self._policy({"branch_protections": [], "branches/master": {}})
        self.assertTrue(policy["determinable"])
        self.assertFalse(policy["protection_found"])
        self.assertFalse(policy["checks_enabled"])
        self.assertIsNone(policy["requires_current_base"])

    def test_protection_without_status_field_means_not_enabled(self):
        policy = self._policy({
            "branch_protections": [{
                "branch_name": "master",
                "block_on_outdated_branch": True,
            }],
        })
        self.assertTrue(policy["determinable"])
        self.assertFalse(policy["checks_enabled"])
        self.assertTrue(policy["requires_current_base"])

    def test_api_failure_is_not_determinable(self):
        policy = self._policy({
            "branch_protections": RuntimeError("boom"),
        })
        self.assertFalse(policy["determinable"])
        self.assertIsNone(policy["checks_enabled"])

    def test_missing_branch_is_not_determinable(self):
        with patch.object(self.gms, "api_request", return_value=[]):
            policy = self.gms._branch_protection_policy(
                "https://example/api/v1/repos/o/r", {}, base_branch="  "
            )
        self.assertFalse(policy["determinable"])

    def test_requires_current_base_accessor_preserved(self):
        def fake(method, url, auth, *a, **k):
            if "branch_protections" in url:
                return [{"branch_name": "master",
                         "block_on_outdated_branch": True}]
            return None

        with patch.object(self.gms, "api_request", side_effect=fake):
            value = self.gms._branch_protection_requires_current_base(
                "https://example/api/v1/repos/o/r", {}, base_branch="master"
            )
        self.assertTrue(value)


class TestCommitChecksSnapshot(unittest.TestCase):
    def setUp(self):
        import gitea_mcp_server as gms

        self.gms = gms

    def test_reads_state_and_context_collection(self):
        payload = {"state": "pending", "statuses": [{"context": "b",
                                                     "status": "pending"}]}
        with patch.object(self.gms, "api_request", return_value=payload):
            snap = self.gms._commit_checks_snapshot(
                "https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
            )
        self.assertTrue(snap["determinable"])
        self.assertEqual(snap["combined_state"], "pending")
        self.assertEqual(len(snap["statuses"]), 1)

    def test_empty_collection_recorded_as_determinable(self):
        with patch.object(
            self.gms, "api_request", return_value={"state": "pending", "statuses": []}
        ):
            snap = self.gms._commit_checks_snapshot(
                "https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
            )
        self.assertTrue(snap["determinable"])
        self.assertEqual(snap["statuses"], [])

    def test_api_failure_is_not_determinable(self):
        with patch.object(self.gms, "api_request", side_effect=RuntimeError("x")):
            snap = self.gms._commit_checks_snapshot(
                "https://example/api/v1/repos/o/r", {}, sha=PR_HEAD
            )
        self.assertFalse(snap["determinable"])


class TestMcpWrapperForwarding(unittest.TestCase):
    """The production MCP path must reach the derived ``checks_required``."""

    def setUp(self):
        import gitea_mcp_server as gms

        self.gms = gms
        self.base = (
            "https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools"
        )

    def _patches(self, fake_api_request, spy):
        return [
            patch.object(self.gms, "_profile_operation_gate", return_value=None),
            patch.object(self.gms, "_resolve", return_value=(
                "gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools")),
            patch.object(self.gms, "_auth", return_value={"Authorization": "x"}),
            patch.object(self.gms, "repo_api_url", return_value=self.base),
            patch.object(self.gms, "api_request", side_effect=fake_api_request),
            patch.object(self.gms, "gitea_get_pr_review_feedback", return_value={
                "success": True, "approval_at_current_head": True}),
            patch.object(self.gms, "_prove_author_ownership_for_pr", return_value={
                "has_author_lock": True}),
            patch.object(pr_sync_status, "assess_pr_sync_status", side_effect=spy),
        ]

    def _run(self, *, protections, status_payload, pr_number=750,
             caller_checks_status=None):
        captured = {}
        real_assess = pr_sync_status.assess_pr_sync_status

        def spy(**kwargs):
            captured.update(kwargs)
            return real_assess(**kwargs)

        def fake_api_request(method, url, auth, *args, **kwargs):
            if f"/pulls/{pr_number}" in url:
                return {
                    "number": pr_number,
                    "state": "open",
                    "title": "t",
                    "body": "b",
                    "mergeable": True,
                    "head": {"sha": PR_HEAD, "ref": "fix/x"},
                    "base": {"sha": BASE_HEAD, "ref": "master"},
                }
            if "/branch_protections" in url:
                if isinstance(protections, Exception):
                    raise protections
                return protections
            if "/branches/master" in url:
                return {"commit": {"id": BASE_HEAD}}
            if "/status" in url:
                if isinstance(status_payload, Exception):
                    raise status_payload
                return status_payload
            if "/compare/" in url:
                return {"total_commits": 0}
            if "/comments" in url:
                return []
            return None

        stack = self._patches(fake_api_request, spy)
        for p in stack:
            p.start()
        try:
            result = self.gms.gitea_assess_pr_sync_status(
                pr_number=pr_number, remote="prgs",
                checks_status=caller_checks_status,
            )
        finally:
            for p in reversed(stack):
                p.stop()
        return result, captured

    def test_derived_checks_required_is_forwarded(self):
        result, captured = self._run(
            protections=[{"branch_name": "master", "enable_status_check": True,
                          "status_check_contexts": ["ci/build"]}],
            status_payload={"state": "pending", "statuses": [
                {"context": "ci/build", "status": "pending"}]},
        )
        self.assertIn("checks_required", captured)
        self.assertTrue(captured["checks_required"])
        self.assertEqual(captured["checks_status"], CHECKS_PENDING)
        self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)

    def test_pr750_empty_status_with_no_protection_is_merge_ready(self):
        # The exact reported failure: combined pending, zero contexts, and no
        # branch protection requiring checks.
        result, captured = self._run(
            protections=[],
            status_payload={"state": "pending", "statuses": []},
        )
        self.assertFalse(captured["checks_required"])
        self.assertEqual(captured["checks_status"], CHECKS_NOT_REQUIRED)
        self.assertNotEqual(result["checks_status"], CHECKS_PENDING)
        self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)

    def test_protection_read_failure_blocks(self):
        result, captured = self._run(
            protections=RuntimeError("protection unavailable"),
            status_payload={"state": "success", "statuses": []},
        )
        self.assertTrue(captured["checks_required"])
        self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)

    def test_caller_supplied_status_cannot_mask_live_required_failure(self):
        # A caller-declared "success" must not override live evidence.
        result, captured = self._run(
            protections=[{"branch_name": "master", "enable_status_check": True,
                          "status_check_contexts": ["ci/build"]}],
            status_payload={"state": "failure", "statuses": [
                {"context": "ci/build", "status": "failure"}]},
            caller_checks_status="success",
        )
        self.assertEqual(captured["checks_status"], CHECKS_FAILURE)
        self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
        self.assertEqual(
            result["checks_evidence"]["caller_supplied_checks_status"], "success"
        )

    def test_checks_evidence_is_reported_without_secrets(self):
        result, _ = self._run(
            protections=[],
            status_payload={"state": "pending", "statuses": []},
        )
        evidence = result["checks_evidence"]
        self.assertEqual(evidence["context_count"], 0)
        self.assertEqual(evidence["combined_state"], "pending")
        self.assertFalse(evidence["protection_found"])
        blob = repr(result).lower()
        self.assertNotIn("authorization", blob)
        self.assertNotIn("token", blob)


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