"""Tests for the PR outcome tracking module."""

from __future__ import annotations

import importlib
import json
import sqlite3
import subprocess
from collections.abc import Callable, Generator, Sequence
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import cast
from unittest.mock import MagicMock, create_autospec, patch

import pytest
from click.testing import CliRunner

from colonyos.cli import app
from colonyos.memory import MemoryStore
from colonyos.outcomes import (
    OutcomeStore,
    poll_outcomes,
    compute_outcome_stats,
    format_outcome_summary,
)


def _memory_db_path(repo_root: Path) -> Path:
    return repo_root / ".colonyos" / "memory.db"


def _exec_sql(repo_root: Path, sql: str, params: tuple[object, ...] = ()) -> None:
    conn = sqlite3.connect(str(_memory_db_path(repo_root)), timeout=10)
    try:
        _ = conn.execute(sql, params)
        conn.commit()
    finally:
        conn.close()


def _orch_register_pr_outcome() -> Callable[..., None]:
    orch = importlib.import_module("colonyos.orchestrator")
    return cast(Callable[..., None], getattr(orch, "_register_pr_outcome"))


@pytest.fixture
def tmp_repo(tmp_path: Path) -> Path:
    (tmp_path / ".colonyos").mkdir()
    return tmp_path


@pytest.fixture
def store(tmp_repo: Path) -> Generator[OutcomeStore, None, None]:
    s = OutcomeStore(tmp_repo)
    yield s
    s.close()


# ---------------------------------------------------------------------------
# 1.1 OutcomeStore class tests
# ---------------------------------------------------------------------------


class TestOutcomeStoreInit:
    def test_creates_table(self, tmp_repo: Path):
        """OutcomeStore creates pr_outcomes table on init."""
        s = OutcomeStore(tmp_repo)
        # Verify table exists by querying it (separate connection; store may hold one open)
        conn = sqlite3.connect(str(_memory_db_path(tmp_repo)), timeout=10)
        try:
            cur = conn.cursor()
            _ = cur.execute(
                "SELECT name FROM sqlite_master WHERE type='table' AND name='pr_outcomes'"
            )
            assert cur.fetchone() is not None
        finally:
            conn.close()
        s.close()

    def test_idempotent_init(self, tmp_repo: Path):
        """Opening the store twice should not error (schema migration)."""
        s1 = OutcomeStore(tmp_repo)
        s1.close()
        s2 = OutcomeStore(tmp_repo)
        s2.close()

    def test_shares_memory_db(self, tmp_repo: Path):
        """OutcomeStore uses the same memory.db as MemoryStore."""
        s = OutcomeStore(tmp_repo)
        assert _memory_db_path(tmp_repo).exists()
        s.close()

    def test_context_manager(self, tmp_repo: Path):
        """OutcomeStore supports with-statement."""
        with OutcomeStore(tmp_repo) as s:
            s.track_pr("run-1", 42, "https://github.com/o/r/pull/42", "feat/x")
            assert len(s.get_outcomes()) == 1


class TestTrackPr:
    def test_persists_record(self, store: OutcomeStore):
        store.track_pr("run-1", 42, "https://github.com/o/r/pull/42", "feat/x")
        outcomes = store.get_outcomes()
        assert len(outcomes) == 1
        row = outcomes[0]
        assert row["run_id"] == "run-1"
        assert row["pr_number"] == 42
        assert row["pr_url"] == "https://github.com/o/r/pull/42"
        assert row["branch_name"] == "feat/x"
        assert row["status"] == "open"
        assert row["created_at"] is not None
        assert row["merged_at"] is None
        assert row["closed_at"] is None

    def test_multiple_prs(self, store: OutcomeStore):
        store.track_pr("run-1", 42, "https://github.com/o/r/pull/42", "feat/x")
        store.track_pr("run-2", 43, "https://github.com/o/r/pull/43", "feat/y")
        assert len(store.get_outcomes()) == 2


class TestGetOutcomes:
    def test_returns_all_records(self, store: OutcomeStore):
        store.track_pr("run-1", 42, "url1", "b1")
        store.track_pr("run-2", 43, "url2", "b2")
        outcomes = store.get_outcomes()
        assert len(outcomes) == 2

    def test_empty_table(self, store: OutcomeStore):
        assert store.get_outcomes() == []


class TestGetOpenOutcomes:
    def test_returns_only_open(self, store: OutcomeStore):
        store.track_pr("run-1", 42, "url1", "b1")
        store.track_pr("run-2", 43, "url2", "b2")
        # Manually close one
        store.update_outcome(pr_number=42, status="merged")
        open_outcomes = store.get_open_outcomes()
        assert len(open_outcomes) == 1
        assert open_outcomes[0]["pr_number"] == 43


class TestUpdateOutcome:
    def test_update_status_merged(self, store: OutcomeStore):
        store.track_pr("run-1", 42, "url1", "b1")
        now = datetime.now(timezone.utc).isoformat()
        store.update_outcome(
            pr_number=42,
            status="merged",
            merged_at=now,
            review_comment_count=3,
            ci_passed=True,
            labels="bug,fix",
        )
        outcomes = store.get_outcomes()
        assert outcomes[0]["status"] == "merged"
        assert outcomes[0]["merged_at"] == now
        assert outcomes[0]["review_comment_count"] == 3
        assert outcomes[0]["ci_passed"] == 1  # SQLite stores bool as int
        assert outcomes[0]["labels"] == "bug,fix"

    def test_update_status_closed_with_context(self, store: OutcomeStore):
        store.track_pr("run-1", 42, "url1", "b1")
        now = datetime.now(timezone.utc).isoformat()
        store.update_outcome(
            pr_number=42,
            status="closed",
            closed_at=now,
            close_context="Too large, please split",
        )
        outcomes = store.get_outcomes()
        assert outcomes[0]["status"] == "closed"
        assert outcomes[0]["closed_at"] == now
        assert outcomes[0]["close_context"] == "Too large, please split"


# ---------------------------------------------------------------------------
# 1.2 poll_outcomes() tests
# ---------------------------------------------------------------------------


def _make_gh_result(
    state: str = "OPEN",
    merged_at: str | None = None,
    closed_at: str | None = None,
    reviews: list[dict[str, object]] | None = None,
    comments: list[dict[str, object]] | None = None,
    status_checks: list[dict[str, object]] | None = None,
    labels: list[dict[str, object]] | None = None,
) -> dict[str, object]:
    """Build a dict matching `gh pr view --json` output."""
    return {
        "state": state,
        "mergedAt": merged_at,
        "closedAt": closed_at,
        "reviews": reviews or [],
        "comments": comments or [],
        "statusCheckRollup": status_checks or [],
        "labels": labels or [],
    }


class TestPollOutcomes:
    def test_open_to_merged(self, store: OutcomeStore, tmp_repo: Path):
        store.track_pr("run-1", 42, "url1", "b1")
        gh_data: dict[str, object] = _make_gh_result(
            state="MERGED",
            merged_at="2025-01-15T10:00:00Z",
            reviews=[{"body": "LGTM"}, {"body": "Nice work"}],
            status_checks=[{"conclusion": "SUCCESS"}],
            labels=[{"name": "bugfix"}],
        )
        with patch("colonyos.outcomes._call_gh_pr_view", return_value=gh_data):
            poll_outcomes(tmp_repo)
        outcomes = store.get_outcomes()
        assert outcomes[0]["status"] == "merged"
        assert outcomes[0]["merged_at"] == "2025-01-15T10:00:00Z"
        assert outcomes[0]["review_comment_count"] == 2
        assert outcomes[0]["ci_passed"] == 1
        assert outcomes[0]["labels"] == "bugfix"

    def test_open_to_closed_with_context(self, store: OutcomeStore, tmp_repo: Path):
        store.track_pr("run-1", 42, "url1", "b1")
        gh_data: dict[str, object] = _make_gh_result(
            state="CLOSED",
            closed_at="2025-01-15T10:00:00Z",
            comments=[{"body": "This PR is too large, please split into smaller PRs."}],
        )
        with patch("colonyos.outcomes._call_gh_pr_view", return_value=gh_data):
            poll_outcomes(tmp_repo)
        outcomes = store.get_outcomes()
        assert outcomes[0]["status"] == "closed"
        raw_close: object = outcomes[0]["close_context"]
        close_ctx = str(raw_close)
        assert "too large" in close_ctx.lower()

    def test_close_context_sanitized(self, store: OutcomeStore, tmp_repo: Path):
        """close_context is sanitized with sanitize_ci_logs."""
        store.track_pr("run-1", 42, "url1", "b1")
        gh_data: dict[str, object] = _make_gh_result(
            state="CLOSED",
            closed_at="2025-01-15T10:00:00Z",
            comments=[{"body": "Bad token: ghp_abc123def456 found in code"}],
        )
        with patch("colonyos.outcomes._call_gh_pr_view", return_value=gh_data):
            poll_outcomes(tmp_repo)
        outcomes = store.get_outcomes()
        # Secret should be redacted
        assert "ghp_" not in outcomes[0]["close_context"]
        assert "[REDACTED]" in outcomes[0]["close_context"]

    def test_close_context_capped_at_500_chars(self, store: OutcomeStore, tmp_repo: Path):
        store.track_pr("run-1", 42, "url1", "b1")
        long_comment = "x" * 1000
        gh_data: dict[str, object] = _make_gh_result(
            state="CLOSED",
            closed_at="2025-01-15T10:00:00Z",
            comments=[{"body": long_comment}],
        )
        with patch("colonyos.outcomes._call_gh_pr_view", return_value=gh_data):
            poll_outcomes(tmp_repo)
        outcomes = store.get_outcomes()
        raw_ctx: object = outcomes[0]["close_context"]
        assert len(str(raw_ctx)) <= 500

    def test_gh_cli_failure_logs_and_continues(self, store: OutcomeStore, tmp_repo: Path):
        store.track_pr("run-1", 42, "url1", "b1")
        store.track_pr("run-2", 43, "url2", "b2")
        call_count = 0

        def mock_gh(pr_number: int, _repo_root: Path) -> dict[str, object]:
            nonlocal call_count
            call_count += 1
            if pr_number == 42:
                raise subprocess.CalledProcessError(1, "gh")
            return _make_gh_result(
                state="MERGED",
                merged_at="2025-01-15T10:00:00Z",
            )

        with patch("colonyos.outcomes._call_gh_pr_view", side_effect=mock_gh):
            poll_outcomes(tmp_repo)  # Should not raise

        outcomes = store.get_outcomes()
        # PR 42 should still be open (gh failed), PR 43 should be merged
        pr42 = [o for o in outcomes if o["pr_number"] == 42][0]
        pr43 = [o for o in outcomes if o["pr_number"] == 43][0]
        assert pr42["status"] == "open"
        assert pr43["status"] == "merged"

    def test_skips_already_resolved(self, store: OutcomeStore, tmp_repo: Path):
        """Already merged/closed PRs should not be polled."""
        store.track_pr("run-1", 42, "url1", "b1")
        store.update_outcome(pr_number=42, status="merged")

        with patch("colonyos.outcomes._call_gh_pr_view") as mock_gh:
            poll_outcomes(tmp_repo)
            mock_gh.assert_not_called()


# ---------------------------------------------------------------------------
# 1.3 compute_outcome_stats() tests
# ---------------------------------------------------------------------------


class TestComputeOutcomeStats:
    def test_empty_outcomes(self, tmp_repo: Path):
        stats = compute_outcome_stats(tmp_repo)
        assert stats["total_tracked"] == 0
        assert stats["merge_rate"] == 0.0
        assert stats["avg_time_to_merge_hours"] == 0.0

    def test_merge_rate(self, store: OutcomeStore, tmp_repo: Path):
        # 2 merged, 1 closed, 1 open = merge rate = 2/3 (exclude open)
        for i, (status, merged_at, closed_at) in enumerate([
            ("merged", "2025-01-15T12:00:00Z", None),
            ("merged", "2025-01-15T14:00:00Z", None),
            ("closed", None, "2025-01-15T10:00:00Z"),
            ("open", None, None),
        ]):
            store.track_pr(f"run-{i}", 40 + i, f"url{i}", f"b{i}")
            if status != "open":
                store.update_outcome(
                    pr_number=40 + i,
                    status=status,
                    merged_at=merged_at,
                    closed_at=closed_at,
                )
        stats = compute_outcome_stats(tmp_repo)
        assert stats["total_tracked"] == 4
        assert stats["merged_count"] == 2
        assert stats["closed_count"] == 1
        assert stats["open_count"] == 1
        # merge_rate = merged / (merged + closed) = 2/3
        merge_rate = cast(float, stats["merge_rate"])
        assert abs(merge_rate - 2.0 / 3.0) < 0.01

    def test_avg_time_to_merge(self, store: OutcomeStore, tmp_repo: Path):
        base = datetime(2025, 1, 15, 10, 0, 0, tzinfo=timezone.utc)
        # PR merged after 2 hours
        store.track_pr("run-1", 42, "url1", "b1")
        _exec_sql(
            tmp_repo,
            "UPDATE pr_outcomes SET status='merged', created_at=?, merged_at=? WHERE pr_number=42",
            (base.isoformat(), (base + timedelta(hours=2)).isoformat()),
        )
        # PR merged after 4 hours
        store.track_pr("run-2", 43, "url2", "b2")
        _exec_sql(
            tmp_repo,
            "UPDATE pr_outcomes SET status='merged', created_at=?, merged_at=? WHERE pr_number=43",
            (base.isoformat(), (base + timedelta(hours=4)).isoformat()),
        )

        stats = compute_outcome_stats(tmp_repo)
        # avg = (2 + 4) / 2 = 3.0 hours
        avg_hours = cast(float, stats["avg_time_to_merge_hours"])
        assert abs(avg_hours - 3.0) < 0.1


# ---------------------------------------------------------------------------
# 1.4 format_outcome_summary() tests
# ---------------------------------------------------------------------------


class TestFormatOutcomeSummary:
    def test_empty_outcomes_returns_empty(self, tmp_repo: Path):
        result = format_outcome_summary(tmp_repo)
        assert result == ""

    def test_compact_string_output(self, store: OutcomeStore, tmp_repo: Path):
        base = datetime(2025, 1, 15, 10, 0, 0, tzinfo=timezone.utc)
        for i in range(5):
            store.track_pr(f"run-{i}", 40 + i, f"url{i}", f"b{i}")
        # Merge 3, close 1, leave 1 open
        for i in range(3):
            _exec_sql(
                tmp_repo,
                "UPDATE pr_outcomes SET status='merged', created_at=?, merged_at=? WHERE pr_number=?",
                (base.isoformat(), (base + timedelta(hours=2)).isoformat(), 40 + i),
            )
        _exec_sql(
            tmp_repo,
            "UPDATE pr_outcomes SET status='closed', closed_at=?, close_context='too large' WHERE pr_number=44",
            ((base + timedelta(hours=1)).isoformat(),),
        )

        result = format_outcome_summary(tmp_repo)
        assert "merged" in result.lower()
        assert result != ""

    def test_token_budget(self, store: OutcomeStore, tmp_repo: Path):
        """Summary should stay under ~500 tokens (~2000 chars)."""
        base = datetime(2025, 1, 15, 10, 0, 0, tzinfo=timezone.utc)
        for i in range(50):
            store.track_pr(f"run-{i}", 100 + i, f"url{i}", f"branch-with-long-name-{i}")
            if i % 2 == 0:
                _exec_sql(
                    tmp_repo,
                    "UPDATE pr_outcomes SET status='merged', created_at=?, merged_at=? WHERE pr_number=?",
                    (base.isoformat(), (base + timedelta(hours=i + 1)).isoformat(), 100 + i),
                )

        result = format_outcome_summary(tmp_repo)
        # ~500 tokens ≈ ~2000 chars
        assert len(result) <= 2000


# ---------------------------------------------------------------------------
# 3.1 Deliver phase integration tests
# ---------------------------------------------------------------------------


class TestRegisterPrOutcome:
    """Tests for _register_pr_outcome helper used by the deliver phase."""

    def test_registers_pr_with_correct_args(self, tmp_repo: Path):
        """track_pr is called with run_id, pr_number, pr_url, branch_name."""
        register_pr = _orch_register_pr_outcome()

        with patch("colonyos.orchestrator.OutcomeStore") as MockStore:
            mock_instance = cast(OutcomeStore, create_autospec(OutcomeStore, instance=True))
            MockStore.return_value.__enter__ = MagicMock(return_value=mock_instance)
            MockStore.return_value.__exit__ = MagicMock(return_value=False)

            register_pr(
                repo_root=tmp_repo,
                run_id="run-abc",
                pr_url="https://github.com/org/repo/pull/99",
                branch_name="colonyos/my-feature",
            )

            cast(MagicMock, mock_instance.track_pr).assert_called_once_with(
                "run-abc", 99, "https://github.com/org/repo/pull/99", "colonyos/my-feature",
            )

    def test_not_called_when_no_pr_url(self, tmp_repo: Path):
        """_register_pr_outcome is a no-op when pr_url is empty."""
        register_pr = _orch_register_pr_outcome()

        with patch("colonyos.orchestrator.OutcomeStore") as MockStore:
            register_pr(
                repo_root=tmp_repo,
                run_id="run-abc",
                pr_url="",
                branch_name="colonyos/my-feature",
            )
            MockStore.assert_not_called()

    def test_not_called_when_pr_number_not_extractable(self, tmp_repo: Path):
        """_register_pr_outcome is a no-op when PR number can't be parsed."""
        register_pr = _orch_register_pr_outcome()

        with patch("colonyos.orchestrator.OutcomeStore") as MockStore:
            register_pr(
                repo_root=tmp_repo,
                run_id="run-abc",
                pr_url="https://github.com/org/repo/not-a-pull-url",
                branch_name="colonyos/my-feature",
            )
            MockStore.assert_not_called()

    def test_handles_track_pr_exception_gracefully(self, tmp_repo: Path):
        """Exceptions from track_pr are caught and logged, not raised."""
        register_pr = _orch_register_pr_outcome()

        with patch("colonyos.orchestrator.OutcomeStore") as MockStore:
            mock_instance = cast(OutcomeStore, create_autospec(OutcomeStore, instance=True))
            cast(MagicMock, mock_instance.track_pr).side_effect = Exception("DB locked")
            MockStore.return_value.__enter__ = MagicMock(return_value=mock_instance)
            MockStore.return_value.__exit__ = MagicMock(return_value=False)

            # Should NOT raise
            register_pr(
                repo_root=tmp_repo,
                run_id="run-abc",
                pr_url="https://github.com/org/repo/pull/99",
                branch_name="colonyos/my-feature",
            )


@pytest.fixture
def cli_runner():
    return CliRunner()


class TestOutcomesCLI:
    """Tests for `colonyos outcomes` and `colonyos outcomes poll` commands."""

    def test_outcomes_displays_table(self, cli_runner: CliRunner, tmp_repo: Path):
        """outcomes command displays a Rich table with correct columns."""
        # Seed some data
        with OutcomeStore(tmp_repo) as store:
            store.track_pr("run-1", 42, "https://github.com/o/r/pull/42", "feat/x")
            store.track_pr("run-2", 43, "https://github.com/o/r/pull/43", "feat/y")

        with patch("colonyos.cli._find_repo_root", return_value=tmp_repo):
            result = cli_runner.invoke(app, ["outcomes"])

        assert result.exit_code == 0
        # Check column headers are present
        assert "PR#" in result.output or "PR" in result.output
        assert "Status" in result.output
        assert "Branch" in result.output
        # Check data rows are present
        assert "42" in result.output
        assert "43" in result.output
        assert "feat/x" in result.output

    def test_outcomes_empty_shows_message(self, cli_runner: CliRunner, tmp_repo: Path):
        """Empty outcomes shows a helpful message instead of an empty table."""
        # Ensure .colonyos dir exists for OutcomeStore
        (tmp_repo / ".colonyos").mkdir(exist_ok=True)

        with patch("colonyos.cli._find_repo_root", return_value=tmp_repo):
            result = cli_runner.invoke(app, ["outcomes"])

        assert result.exit_code == 0
        assert "no tracked" in result.output.lower() or "no pr" in result.output.lower()

    def test_outcomes_status_colored(self, cli_runner: CliRunner, tmp_repo: Path):
        """Status values appear in the output (color applied via Rich)."""
        with OutcomeStore(tmp_repo) as store:
            store.track_pr("run-1", 42, "url1", "feat/x")
            store.update_outcome(pr_number=42, status="merged")

        with patch("colonyos.cli._find_repo_root", return_value=tmp_repo):
            result = cli_runner.invoke(app, ["outcomes"])

        assert result.exit_code == 0
        assert "merged" in result.output.lower()

    def test_outcomes_poll_calls_poll_and_displays(self, cli_runner: CliRunner, tmp_repo: Path):
        """outcomes poll triggers poll_outcomes() then shows the updated table."""
        with OutcomeStore(tmp_repo) as store:
            store.track_pr("run-1", 42, "url1", "feat/x")

        with (
            patch("colonyos.cli._find_repo_root", return_value=tmp_repo),
            patch("colonyos.outcomes.poll_outcomes") as mock_poll,
        ):
            result = cli_runner.invoke(app, ["outcomes", "poll"])

        assert result.exit_code == 0
        mock_poll.assert_called_once_with(tmp_repo)
        # Table should still be displayed after polling
        assert "42" in result.output

    def test_outcomes_poll_handles_error(self, cli_runner: CliRunner, tmp_repo: Path):
        """outcomes poll handles poll_outcomes failures gracefully."""
        (tmp_repo / ".colonyos").mkdir(exist_ok=True)

        with (
            patch("colonyos.cli._find_repo_root", return_value=tmp_repo),
            patch("colonyos.outcomes.poll_outcomes", side_effect=Exception("gh not found")),
        ):
            result = cli_runner.invoke(app, ["outcomes", "poll"])

        # Should not crash — should show warning and still try to display table
        assert result.exit_code == 0

    def test_outcomes_no_subcommand_shows_table(self, cli_runner: CliRunner, tmp_repo: Path):
        """Bare `colonyos outcomes` (no subcommand) shows the table."""
        with OutcomeStore(tmp_repo) as store:
            store.track_pr("run-1", 42, "url1", "feat/x")

        with patch("colonyos.cli._find_repo_root", return_value=tmp_repo):
            result = cli_runner.invoke(app, ["outcomes"])

        assert result.exit_code == 0
        assert "42" in result.output


# ---------------------------------------------------------------------------
# 7.1 Memory capture tests
# ---------------------------------------------------------------------------


class TestMemoryCapture:
    """Tests for memory capture when poll_outcomes detects PR closure."""

    def test_closed_pr_creates_failure_memory(self, store: OutcomeStore, tmp_repo: Path):
        """When poll_outcomes detects open→closed, a FAILURE MemoryEntry is created."""
        store.track_pr("run-1", 42, "url1", "feat/x")
        gh_data: dict[str, object] = _make_gh_result(
            state="CLOSED",
            closed_at="2025-01-15T10:00:00Z",
            comments=[{"body": "This PR is too large, please split."}],
        )
        with (
            patch("colonyos.outcomes._call_gh_pr_view", return_value=gh_data),
            patch("colonyos.outcomes.MemoryStore") as MockMemStore,
        ):
            mock_mem_instance = cast(MemoryStore, create_autospec(MemoryStore, instance=True))
            MockMemStore.return_value.__enter__ = MagicMock(return_value=mock_mem_instance)
            MockMemStore.return_value.__exit__ = MagicMock(return_value=False)

            poll_outcomes(tmp_repo)

            add_mem = cast(MagicMock, mock_mem_instance.add_memory)
            add_mem.assert_called_once()
            call_kwargs = add_mem.call_args
            assert call_kwargs is not None
            # Check category is FAILURE
            from colonyos.memory import MemoryCategory

            kwargs = cast(dict[str, object], call_kwargs.kwargs)
            assert kwargs["category"] == MemoryCategory.FAILURE
            # Check phase is "deliver"
            assert kwargs["phase"] == "deliver"
            # Check text contains PR number and reviewer feedback
            text = str(kwargs["text"])
            assert "PR #42" in text
            assert "closed without merge" in text
            assert "too large" in text.lower()

    def test_merged_pr_does_not_create_memory(self, store: OutcomeStore, tmp_repo: Path):
        """No memory entry is created for merged PRs."""
        store.track_pr("run-1", 42, "url1", "feat/x")
        gh_data: dict[str, object] = _make_gh_result(
            state="MERGED",
            merged_at="2025-01-15T10:00:00Z",
            reviews=[{"body": "LGTM"}],
        )
        with (
            patch("colonyos.outcomes._call_gh_pr_view", return_value=gh_data),
            patch("colonyos.outcomes.MemoryStore") as MockMemStore,
        ):
            poll_outcomes(tmp_repo)
            MockMemStore.assert_not_called()

    def test_closed_pr_without_comments_no_memory(self, store: OutcomeStore, tmp_repo: Path):
        """No memory entry for PRs closed without any reviewer comment."""
        store.track_pr("run-1", 42, "url1", "feat/x")
        gh_data: dict[str, object] = _make_gh_result(
            state="CLOSED",
            closed_at="2025-01-15T10:00:00Z",
            # No comments or reviews — close_context will be empty
            comments=[],
            reviews=[],
        )
        with (
            patch("colonyos.outcomes._call_gh_pr_view", return_value=gh_data),
            patch("colonyos.outcomes.MemoryStore") as MockMemStore,
        ):
            poll_outcomes(tmp_repo)
            MockMemStore.assert_not_called()

    def test_memory_capture_failure_does_not_break_poll(self, store: OutcomeStore, tmp_repo: Path):
        """If MemoryStore.add_memory raises, poll_outcomes continues without crashing."""
        store.track_pr("run-1", 42, "url1", "feat/x")
        store.track_pr("run-2", 43, "url2", "feat/y")
        gh_data_closed: dict[str, object] = _make_gh_result(
            state="CLOSED",
            closed_at="2025-01-15T10:00:00Z",
            comments=[{"body": "Rejected: duplicate of another PR"}],
        )
        gh_data_merged: dict[str, object] = _make_gh_result(
            state="MERGED",
            merged_at="2025-01-15T12:00:00Z",
        )

        def mock_gh(pr_number: int, _repo_root: Path) -> dict[str, object]:
            if pr_number == 42:
                return gh_data_closed
            return gh_data_merged

        with (
            patch("colonyos.outcomes._call_gh_pr_view", side_effect=mock_gh),
            patch("colonyos.outcomes.MemoryStore") as MockMemStore,
        ):
            mock_mem_instance = cast(MemoryStore, create_autospec(MemoryStore, instance=True))
            cast(MagicMock, mock_mem_instance.add_memory).side_effect = Exception("DB locked")
            MockMemStore.return_value.__enter__ = MagicMock(return_value=mock_mem_instance)
            MockMemStore.return_value.__exit__ = MagicMock(return_value=False)

            # Should NOT raise despite memory store failure
            poll_outcomes(tmp_repo)

        # PR 43 should still be updated to merged
        outcomes = store.get_outcomes()
        pr43 = [o for o in outcomes if o["pr_number"] == 43][0]
        assert pr43["status"] == "merged"


# ---------------------------------------------------------------------------
# 2.1 Sync tracking columns tests
# ---------------------------------------------------------------------------


class TestSyncColumns:
    """Tests for last_sync_at / sync_failures columns and related methods."""

    def test_new_columns_exist_after_init(self, store: OutcomeStore, tmp_repo: Path):
        """pr_outcomes table has last_sync_at and sync_failures columns."""
        conn = sqlite3.connect(str(_memory_db_path(tmp_repo)), timeout=10)
        try:
            cur = conn.cursor()
            _ = cur.execute("PRAGMA table_info(pr_outcomes)")
            rows = cast(Sequence[sqlite3.Row], cur.fetchall())
            columns = {cast(str, row[1]) for row in rows}
        finally:
            conn.close()
        assert "last_sync_at" in columns
        assert "sync_failures" in columns

    def test_sync_failures_defaults_to_zero(self, store: OutcomeStore):
        """New PR records have sync_failures = 0."""
        store.track_pr("run-1", 42, "url1", "colonyos/feat-x")
        outcomes = store.get_outcomes()
        assert outcomes[0]["sync_failures"] == 0

    def test_last_sync_at_defaults_to_none(self, store: OutcomeStore):
        """New PR records have last_sync_at = None."""
        store.track_pr("run-1", 42, "url1", "colonyos/feat-x")
        outcomes = store.get_outcomes()
        assert outcomes[0]["last_sync_at"] is None

    def test_update_sync_status_sets_fields(self, store: OutcomeStore):
        """update_sync_status correctly sets last_sync_at and sync_failures."""
        store.track_pr("run-1", 42, "url1", "colonyos/feat-x")
        now = datetime.now(timezone.utc).isoformat()
        store.update_sync_status(pr_number=42, last_sync_at=now, sync_failures=0)

        outcomes = store.get_outcomes()
        assert outcomes[0]["last_sync_at"] == now
        assert outcomes[0]["sync_failures"] == 0

    def test_update_sync_status_increments_failures(self, store: OutcomeStore):
        """update_sync_status can increment sync_failures."""
        store.track_pr("run-1", 42, "url1", "colonyos/feat-x")
        now = datetime.now(timezone.utc).isoformat()
        store.update_sync_status(pr_number=42, last_sync_at=now, sync_failures=1)

        outcomes = store.get_outcomes()
        assert outcomes[0]["sync_failures"] == 1

        # Increment again
        store.update_sync_status(pr_number=42, last_sync_at=now, sync_failures=2)
        outcomes = store.get_outcomes()
        assert outcomes[0]["sync_failures"] == 2

    def test_get_sync_candidates_returns_open_prs_under_max(self, store: OutcomeStore):
        """get_sync_candidates returns only open PRs with sync_failures < max."""
        store.track_pr("run-1", 42, "url1", "colonyos/feat-x")
        store.track_pr("run-2", 43, "url2", "colonyos/feat-y")
        store.track_pr("run-3", 44, "url3", "colonyos/feat-z")

        # PR 43 has too many failures
        store.update_sync_status(pr_number=43, last_sync_at="2025-01-01T00:00:00Z", sync_failures=3)
        # PR 44 is merged (not open)
        store.update_outcome(pr_number=44, status="merged")

        candidates = store.get_sync_candidates(max_failures=3)
        pr_numbers = [c["pr_number"] for c in candidates]
        assert 42 in pr_numbers
        assert 43 not in pr_numbers  # too many failures
        assert 44 not in pr_numbers  # not open

    def test_get_sync_candidates_ordered_by_last_sync_at_nulls_first(self, store: OutcomeStore):
        """get_sync_candidates orders by last_sync_at ASC, NULLs first."""
        store.track_pr("run-1", 42, "url1", "colonyos/feat-x")
        store.track_pr("run-2", 43, "url2", "colonyos/feat-y")
        store.track_pr("run-3", 44, "url3", "colonyos/feat-z")

        # PR 43 was synced recently, PR 44 was synced earlier, PR 42 never synced
        store.update_sync_status(pr_number=43, last_sync_at="2025-01-15T12:00:00Z", sync_failures=0)
        store.update_sync_status(pr_number=44, last_sync_at="2025-01-15T10:00:00Z", sync_failures=0)

        candidates = store.get_sync_candidates(max_failures=3)
        pr_numbers = [c["pr_number"] for c in candidates]
        # 42 (NULL) first, then 44 (earlier), then 43 (later)
        assert pr_numbers == [42, 44, 43]

    def test_schema_migration_existing_db(self, tmp_repo: Path):
        """ALTER TABLE migration handles existing databases gracefully."""
        # Create a store with the old schema (no sync columns)
        s1 = OutcomeStore(tmp_repo)
        s1.track_pr("run-1", 42, "url1", "colonyos/feat-x")
        s1.close()

        # Re-open — migration should add columns without error
        s2 = OutcomeStore(tmp_repo)
        outcomes = s2.get_outcomes()
        assert outcomes[0]["sync_failures"] == 0
        assert outcomes[0]["last_sync_at"] is None
        s2.close()

    def test_get_sync_candidates_empty_when_no_open_prs(self, store: OutcomeStore):
        """get_sync_candidates returns empty list when no open PRs exist."""
        store.track_pr("run-1", 42, "url1", "colonyos/feat-x")
        store.update_outcome(pr_number=42, status="merged")
        assert store.get_sync_candidates(max_failures=3) == []


class TestMergeStateStatusField:
    """Tests for mergeStateStatus in _call_gh_pr_view."""

    def test_merge_state_status_in_json_fields(self):
        """_call_gh_pr_view requests mergeStateStatus in --json fields."""
        with patch("colonyos.outcomes.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(
                stdout=json.dumps({
                    "state": "OPEN",
                    "mergedAt": None,
                    "closedAt": None,
                    "reviews": [],
                    "comments": [],
                    "statusCheckRollup": [],
                    "labels": [],
                    "mergeStateStatus": "BEHIND",
                }),
                returncode=0,
            )
            outcomes_mod = importlib.import_module("colonyos.outcomes")
            call_gh = cast(
                Callable[[int, Path], dict[str, object]],
                getattr(outcomes_mod, "_call_gh_pr_view"),
            )
            result = call_gh(42, Path("/tmp/repo"))

            # Verify the --json arg includes mergeStateStatus
            assert mock_run.call_args is not None
            call_args = cast(list[object], mock_run.call_args[0])[0]
            argv = cast(list[str], call_args)
            json_fields_arg = argv[argv.index("--json") + 1]
            assert "mergeStateStatus" in json_fields_arg

            # Verify the returned dict includes mergeStateStatus
            assert result["mergeStateStatus"] == "BEHIND"
