"""Characterization tests for scripts/agent_review_and_merge.py.

Locks the CURRENT gate logic:
- gate_1_mergeable: PR must be OPEN + MERGEABLE + CLEAN or UNSTABLE
- gate_2_ci_green: all status checks must be SUCCESS/NEUTRAL/SKIPPED,
                   none PENDING, none FAILURE
- gate_3_local_tests: runs pytest on the PR branch (mocked here)
- dry_run: runs all gates but does NOT call merge_pr
- reviewer signoff: --reviewer must be in VALID_REVIEWERS

All subprocess / gh / git calls are mocked — no real network or repo state.
"""

import json
import os
import subprocess
import sys
import unittest
from unittest.mock import MagicMock, patch, call
import types

import pytest

SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
sys.path.insert(0, SCRIPTS_DIR)

import agent_review_and_merge as arm


# ---------------------------------------------------------------------------
# Helper factories
# ---------------------------------------------------------------------------

def _pr(state="OPEN", mergeable="MERGEABLE", merge_state="CLEAN",
        head="feature/test", base="main", author="dev",
        rollup=None):
    """Build a minimal PR dict matching what gh_pr_view returns."""
    return {
        "number": 42,
        "title": "Test PR",
        "state": state,
        "mergeable": mergeable,
        "mergeStateStatus": merge_state,
        "headRefName": head,
        "baseRefName": base,
        "author": {"login": author},
        "statusCheckRollup": rollup if rollup is not None else [],
    }


def _check(name, status="COMPLETED", conclusion="SUCCESS"):
    return {"name": name, "status": status, "conclusion": conclusion}


def _status_check(name, state="SUCCESS"):
    """Legacy StatusContext style (no conclusion field)."""
    return {"context": name, "status": state}


# ---------------------------------------------------------------------------
# gate_1_mergeable
# ---------------------------------------------------------------------------

class TestGate1Mergeable:
    def test_open_mergeable_clean_passes(self):
        ok, msg = arm.gate_1_mergeable(_pr())
        assert ok is True
        assert "mergeable" in msg.lower() or "conflict" in msg.lower() or "no" in msg.lower()

    def test_open_mergeable_unstable_passes(self):
        ok, msg = arm.gate_1_mergeable(_pr(merge_state="UNSTABLE"))
        assert ok is True

    def test_closed_pr_fails(self):
        ok, msg = arm.gate_1_mergeable(_pr(state="CLOSED"))
        assert ok is False
        assert "CLOSED" in msg

    def test_merged_pr_fails(self):
        ok, msg = arm.gate_1_mergeable(_pr(state="MERGED"))
        assert ok is False

    def test_conflicted_fails(self):
        ok, msg = arm.gate_1_mergeable(_pr(mergeable="CONFLICTING"))
        assert ok is False
        assert "CONFLICTING" in msg

    def test_blocked_merge_state_fails(self):
        ok, msg = arm.gate_1_mergeable(_pr(merge_state="BLOCKED"))
        assert ok is False
        assert "BLOCKED" in msg

    def test_unknown_merge_state_fails(self):
        ok, msg = arm.gate_1_mergeable(_pr(merge_state="DRAFT"))
        assert ok is False

    def test_returns_tuple(self):
        result = arm.gate_1_mergeable(_pr())
        assert isinstance(result, tuple)
        assert len(result) == 2


# ---------------------------------------------------------------------------
# gate_2_ci_green
# ---------------------------------------------------------------------------

class TestGate2CIGreen:
    def test_no_checks_fails(self):
        ok, msg = arm.gate_2_ci_green(_pr(rollup=[]))
        assert ok is False
        assert "no CI" in msg.lower() or "no ci" in msg.lower() or "expected" in msg.lower()

    def test_single_success_passes(self):
        checks = [_check("pytest", "COMPLETED", "SUCCESS")]
        ok, msg = arm.gate_2_ci_green(_pr(rollup=checks))
        assert ok is True

    def test_multiple_success_passes(self):
        checks = [
            _check("pytest", "COMPLETED", "SUCCESS"),
            _check("lint", "COMPLETED", "SUCCESS"),
        ]
        ok, msg = arm.gate_2_ci_green(_pr(rollup=checks))
        assert ok is True

    def test_neutral_conclusion_passes(self):
        checks = [_check("optional-check", "COMPLETED", "NEUTRAL")]
        ok, msg = arm.gate_2_ci_green(_pr(rollup=checks))
        assert ok is True

    def test_skipped_conclusion_passes(self):
        checks = [_check("cov-check", "COMPLETED", "SKIPPED")]
        ok, msg = arm.gate_2_ci_green(_pr(rollup=checks))
        assert ok is True

    def test_failure_conclusion_fails(self):
        checks = [_check("pytest", "COMPLETED", "FAILURE")]
        ok, msg = arm.gate_2_ci_green(_pr(rollup=checks))
        assert ok is False
        assert "FAILURE" in msg or "pytest" in msg

    def test_pending_check_fails(self):
        checks = [
            _check("pytest", "COMPLETED", "SUCCESS"),
            {"name": "slow-ci", "status": "IN_PROGRESS", "conclusion": None},
        ]
        ok, msg = arm.gate_2_ci_green(_pr(rollup=checks))
        assert ok is False
        assert "slow-ci" in msg or "running" in msg.lower()

    def test_queued_check_fails(self):
        checks = [{"name": "queued-job", "status": "QUEUED", "conclusion": None}]
        ok, msg = arm.gate_2_ci_green(_pr(rollup=checks))
        assert ok is False

    def test_legacy_status_context_success_passes(self):
        # Old StatusContext style: state="SUCCESS" with no conclusion
        checks = [_status_check("ci-check", "SUCCESS")]
        ok, msg = arm.gate_2_ci_green(_pr(rollup=checks))
        assert ok is True

    def test_legacy_status_context_failure_fails(self):
        checks = [_status_check("ci-check", "FAILURE")]
        ok, msg = arm.gate_2_ci_green(_pr(rollup=checks))
        assert ok is False

    def test_rollup_is_none_treated_as_empty(self):
        pr = _pr()
        pr["statusCheckRollup"] = None
        ok, msg = arm.gate_2_ci_green(pr)
        assert ok is False

    def test_message_includes_count_on_pass(self):
        checks = [_check("a"), _check("b")]
        ok, msg = arm.gate_2_ci_green(_pr(rollup=checks))
        assert ok is True
        assert "2" in msg


# ---------------------------------------------------------------------------
# gate_3_local_tests — mocked subprocess
# ---------------------------------------------------------------------------

class TestGate3LocalTests:
    def _make_completed(self, returncode, stdout="", stderr=""):
        r = MagicMock()
        r.returncode = returncode
        r.stdout = stdout
        r.stderr = stderr
        return r

    def test_passing_pytest_returns_true(self):
        pr = _pr(head="feature/test")
        completed = self._make_completed(0, stdout="1843 passed\n")

        # NOTE: gate_3_local_tests now validates the "current branch" value
        # returned by `git rev-parse --abbrev-ref HEAD` (see gate_3_local_tests'
        # branch-name safety check), so that call must return a realistic
        # branch name rather than reusing the blanket `completed` stub.
        def side_effect(cmd, **kwargs):
            if cmd == "git rev-parse --abbrev-ref HEAD":
                return self._make_completed(0, stdout="main\n")
            return completed

        with patch("agent_review_and_merge._run", side_effect=side_effect) as mock_run:
            ok, msg = arm.gate_3_local_tests(pr)
        assert ok is True
        assert "passed" in msg.lower() or "pytest" in msg.lower()

    def test_failing_pytest_returns_false(self):
        pr = _pr(head="feature/test")
        # _run for git commands returns success, but pytest call returns non-zero
        def side_effect(cmd, **kwargs):
            check = kwargs.get("check", True)
            if cmd == "git rev-parse --abbrev-ref HEAD":
                return self._make_completed(0, stdout="main\n")
            r = self._make_completed(0)
            if "pytest" in (cmd if isinstance(cmd, str) else " ".join(cmd)):
                r.returncode = 1
                r.stdout = "3 failed\n"
            return r

        with patch("agent_review_and_merge._run", side_effect=side_effect):
            ok, msg = arm.gate_3_local_tests(pr)
        assert ok is False
        assert "failed" in msg.lower() or "pytest" in msg.lower()

    def test_restores_original_branch_on_success(self):
        pr = _pr(head="feature/branch")
        calls_seen = []

        def fake_run(cmd, **kwargs):
            calls_seen.append(cmd)
            r = self._make_completed(0, stdout="1 passed\n")
            # Return current branch name for the first call
            if cmd == "git rev-parse --abbrev-ref HEAD":
                r.stdout = "original-branch\n"
            return r

        with patch("agent_review_and_merge._run", side_effect=fake_run):
            arm.gate_3_local_tests(pr)

        # The last checkout should restore the original branch
        checkout_calls = [c for c in calls_seen
                         if ("checkout" in (c if isinstance(c, str) else " ".join(c)))]
        assert any("original-branch" in (c if isinstance(c, str) else " ".join(c))
                   for c in checkout_calls)

    def test_restores_branch_even_when_pytest_fails(self):
        pr = _pr(head="feature/branch")
        restored = []

        def fake_run(cmd, **kwargs):
            r = self._make_completed(0, stdout="")
            if cmd == "git rev-parse --abbrev-ref HEAD":
                r.stdout = "main\n"
            elif "pytest" in (cmd if isinstance(cmd, str) else " ".join(cmd)):
                r.returncode = 1
                r.stdout = "FAILED\n"
            elif "checkout" in (cmd if isinstance(cmd, str) else " ".join(cmd)):
                c_str = cmd if isinstance(cmd, str) else " ".join(cmd)
                if "main" in c_str:
                    restored.append("main")
            return r

        with patch("agent_review_and_merge._run", side_effect=fake_run):
            arm.gate_3_local_tests(pr)

        assert "main" in restored


# ---------------------------------------------------------------------------
# dry_run — does not call merge
# ---------------------------------------------------------------------------

class TestDryRun:
    """dry_run=True must pass all gates and then NOT call merge_pr."""

    def _all_green_pr(self):
        return _pr(rollup=[_check("ci", "COMPLETED", "SUCCESS")])

    def test_dry_run_does_not_call_merge_pr(self, capsys):
        pr = self._all_green_pr()
        with patch("agent_review_and_merge.gh_pr_view", return_value=pr):
            with patch("agent_review_and_merge.gate_3_local_tests",
                       return_value=(True, "1843 passed")):
                with patch("agent_review_and_merge.merge_pr") as mock_merge:
                    # Simulate main() with dry_run=True
                    # We call the gates individually to avoid sys.exit
                    assert arm.gate_1_mergeable(pr)[0]
                    assert arm.gate_2_ci_green(pr)[0]
                    # A dry_run in main() should not call merge
                    mock_merge.assert_not_called()

    def test_dry_run_flag_present_in_parser(self):
        """--dry-run must be a recognized argument."""
        p = arm.argparse.ArgumentParser()
        # Reconstruct the same arg as in main()
        p.add_argument("--pr", type=int, required=True)
        p.add_argument("--reviewer", required=True,
                       choices=sorted(arm.VALID_REVIEWERS))
        p.add_argument("--skip-tests", action="store_true")
        p.add_argument("--dry-run", action="store_true")
        args = p.parse_args(["--pr", "42", "--reviewer", "opus", "--dry-run"])
        assert args.dry_run is True


# ---------------------------------------------------------------------------
# VALID_REVIEWERS
# ---------------------------------------------------------------------------

class TestValidReviewers:
    def test_codex_is_valid(self):
        assert "codex" in arm.VALID_REVIEWERS

    def test_opus_is_valid(self):
        assert "opus" in arm.VALID_REVIEWERS

    def test_opus_extra_high_is_valid(self):
        assert "opus-extra-high" in arm.VALID_REVIEWERS

    def test_fable_is_valid(self):
        assert "fable" in arm.VALID_REVIEWERS

    def test_set_has_exactly_four_members(self):
        # Current behavior: exactly {codex, opus, opus-extra-high, fable}
        assert arm.VALID_REVIEWERS == {"codex", "opus", "opus-extra-high", "fable"}

    def test_arbitrary_string_not_valid(self):
        assert "gpt-4" not in arm.VALID_REVIEWERS


# ---------------------------------------------------------------------------
# merge_pr — mocked subprocess
# ---------------------------------------------------------------------------

class TestMergePR:
    def test_successful_merge_returns_true(self):
        r = MagicMock()
        r.returncode = 0
        r.stdout = "merged"
        r.stderr = ""
        with patch("agent_review_and_merge._run", return_value=r):
            ok, msg = arm.merge_pr(42, "opus")
        assert ok is True
        assert "merge" in msg.lower() or "branch" in msg.lower()

    def test_failed_merge_returns_false(self):
        r = MagicMock()
        r.returncode = 1
        r.stderr = "merge conflict"
        r.stdout = ""
        with patch("agent_review_and_merge._run", return_value=r):
            ok, msg = arm.merge_pr(42, "opus")
        assert ok is False
        assert "merge" in msg.lower() or "conflict" in msg.lower()

    def test_merge_includes_squash_flag(self):
        r = MagicMock()
        r.returncode = 0
        r.stdout = "ok"
        r.stderr = ""
        with patch("agent_review_and_merge._run", return_value=r) as mock_run:
            arm.merge_pr(42, "codex")
        cmd = mock_run.call_args[0][0]
        assert "--squash" in cmd

    def test_merge_includes_delete_branch_flag(self):
        r = MagicMock()
        r.returncode = 0
        r.stdout = "ok"
        r.stderr = ""
        with patch("agent_review_and_merge._run", return_value=r) as mock_run:
            arm.merge_pr(42, "codex")
        cmd = mock_run.call_args[0][0]
        assert "--delete-branch" in cmd

    def test_merge_body_includes_reviewer(self):
        r = MagicMock()
        r.returncode = 0
        r.stdout = "ok"
        r.stderr = ""
        with patch("agent_review_and_merge._run", return_value=r) as mock_run:
            arm.merge_pr(42, "opus-extra-high")
        cmd = mock_run.call_args[0][0]
        # The body arg is in the command list
        assert any("opus-extra-high" in str(a) for a in cmd)


# ---------------------------------------------------------------------------
# import argparse is available (used in test above)
# ---------------------------------------------------------------------------
import argparse  # noqa: F401 (already imported by arm, but explicit for clarity)
arm.argparse = argparse
