"""ORCH-4 / M-3: tests for the unified stage engine (src/stage_engine.advance_stage).

These verify the MERGED behavior of what used to be two diverged
_try_advance_stage implementations (launcher sync + plane async):

  * happy-path advance for every stage launches the CORRECT agent
    (the ORCH-4 fix: agent = get_agent_for_stage(current_stage), NOT next_stage);
  * a QG failure does not advance;
  * reviewer REQUEST_CHANGES -> rollback to development + enqueue developer;
  * developer retries > 3 -> telegram alert, no further enqueue;
  * tester FAIL -> rollback to development + enqueue developer;
  * architect conflict (10-conflict.md) -> rollback to analysis + enqueue analyst;
  * launcher AND plane both delegate to the engine.

Network/Plane/Telegram side effects are mocked at the src.stage_engine level so
the engine runs against a real isolated sqlite DB.
"""

import os
import tempfile

import pytest

# Isolated test DB (same convention as the other suites).
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_stage_engine.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")

from unittest.mock import MagicMock, patch  # noqa: E402

import src.db as _db  # noqa: E402
from src.db import init_db, get_db  # noqa: E402
from src import stage_engine  # noqa: E402
from src.stage_engine import advance_stage  # noqa: E402
from src.stages import get_agent_for_stage  # noqa: E402


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch):
    """Fresh isolated DB per test."""
    monkeypatch.setattr(_db.settings, "db_path", _test_db)
    if os.path.exists(_test_db):
        os.unlink(_test_db)
    init_db()
    yield


@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
    """Mock all Plane/Telegram/notification side effects in the engine.

    Everything imported into src.stage_engine that touches the network or sends
    a message becomes a no-op MagicMock so tests are deterministic and offline.
    """
    for name in (
        "notify_stage_change",
        "notify_qg_failure",
        "notify_approve_requested",
        "send_telegram",
        "plane_notify_stage",
        "plane_notify_qg",
        "plane_add_comment",
        "set_issue_in_review",
        "set_issue_needs_input",
        "set_issue_in_progress",
        "set_issue_blocked",
        "set_issue_done",
    ):
        monkeypatch.setattr(stage_engine, name, MagicMock())


def _make_task(stage, repo="enduro-trails", branch="feature/ET-001-x", wi="ET-001"):
    conn = get_db()
    cur = conn.execute(
        "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
        "VALUES (?, ?, ?, ?, ?)",
        (f"plane-{wi}", wi, repo, branch, stage),
    )
    task_id = cur.lastrowid
    conn.commit()
    conn.close()
    return task_id


def _stage(task_id):
    conn = get_db()
    row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
    conn.close()
    return row[0]


def _jobs():
    conn = get_db()
    rows = conn.execute("SELECT agent, repo, task_id FROM jobs ORDER BY id").fetchall()
    conn.close()
    return [dict(r) for r in rows]


def _job_contents():
    """task_content of every enqueued job, oldest first (ORCH-046 task_desc check)."""
    conn = get_db()
    rows = conn.execute("SELECT task_content FROM jobs ORDER BY id").fetchall()
    conn.close()
    return [r[0] for r in rows]


def _add_developer_runs(task_id, n):
    conn = get_db()
    for _ in range(n):
        conn.execute(
            "INSERT INTO agent_runs (task_id, agent) VALUES (?, 'developer')",
            (task_id,),
        )
    conn.commit()
    conn.close()


def _pass(*a, **k):
    return (True, "ok")


def _fail(reason):
    def _f(*a, **k):
        return (False, reason)
    return _f


# ---------------------------------------------------------------------------
# Happy path: each stage advances and launches the CORRECT agent (ORCH-4 fix)
# ---------------------------------------------------------------------------
class TestHappyPathAgentSelection:
    """The fixed agent-selection: when advancing FROM current_stage, the engine
    must enqueue get_agent_for_stage(current_stage), NOT next_stage.
    """

    @pytest.mark.parametrize(
        "current_stage,expected_next,expected_agent",
        [
            ("architecture", "development", "developer"),
            ("development", "review", "reviewer"),
            ("review", "testing", "tester"),
            ("testing", "deploy-staging", "deployer"),
        ],
    )
    def test_advance_launches_current_stage_agent(
        self, monkeypatch, current_stage, expected_next, expected_agent
    ):
        # All QG checks pass for this happy-path suite.
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {k: _pass for k in stage_engine.QG_CHECKS},
        )
        task_id = _make_task(current_stage)

        res = advance_stage(
            task_id, current_stage, "enduro-trails", "ET-001",
            "feature/ET-001-x", finished_agent=None,
        )

        assert res.advanced is True
        assert res.to_stage == expected_next
        assert _stage(task_id) == expected_next
        # The ORCH-4 fix: correct agent == get_agent_for_stage(current_stage).
        assert expected_agent == get_agent_for_stage(current_stage)
        assert res.enqueued_agent == expected_agent
        jobs = _jobs()
        assert len(jobs) == 1
        assert jobs[0]["agent"] == expected_agent

    def test_deploy_to_done_no_agent(self, monkeypatch):
        """deploy -> done advances but launches no agent (terminal-ish)."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {k: _pass for k in stage_engine.QG_CHECKS},
        )
        task_id = _make_task("deploy")
        res = advance_stage(task_id, "deploy", "enduro-trails", "ET-001",
                            "feature/ET-001-x", finished_agent=None)
        assert res.advanced is True
        assert _stage(task_id) == "done"
        assert res.enqueued_agent is None
        assert _jobs() == []

    def test_deploy_success_syncs_plane_to_terminal_done(self, monkeypatch):
        """FIX 3: a successful deploy->done forces the Plane issue to terminal Done.

        Previously the task could stick on In Progress because the merge webhook
        completed it out-of-band. Now the engine drives set_issue_done() on the
        deploy->done success transition.
        """
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {k: _pass for k in stage_engine.QG_CHECKS},
        )
        task_id = _make_task("deploy", wi="ET-012")
        res = advance_stage(
            task_id, "deploy", "enduro-trails", "ET-012",
            "feature/ET-012-x", finished_agent="deployer",
        )
        assert res.advanced is True
        assert _stage(task_id) == "done"
        # The terminal Plane sync was invoked with the work item id.
        stage_engine.set_issue_done.assert_called_once_with("ET-012")

    def test_non_terminal_advance_does_not_force_plane_done(self, monkeypatch):
        """set_issue_done must only fire on the terminal deploy->done transition."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {k: _pass for k in stage_engine.QG_CHECKS},
        )
        task_id = _make_task("review")
        advance_stage(
            task_id, "review", "enduro-trails", "ET-001",
            "feature/ET-001-x", finished_agent=None,
        )
        stage_engine.set_issue_done.assert_not_called()

    def test_done_is_terminal(self):
        task_id = _make_task("done")
        res = advance_stage(task_id, "done", "enduro-trails", "ET-001",
                            "feature/ET-001-x", finished_agent=None)
        assert res.advanced is False
        assert _stage(task_id) == "done"


# ---------------------------------------------------------------------------
# QG failure: do not advance
# ---------------------------------------------------------------------------
class TestQgFailureDoesNotAdvance:
    def test_qg_fail_keeps_stage(self, monkeypatch):
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS, "check_architecture_done": _fail("not done")},
        )
        task_id = _make_task("architecture")
        res = advance_stage(task_id, "architecture", "enduro-trails", "ET-001",
                            "feature/ET-001-x", finished_agent="architect")
        assert res.advanced is False
        assert res.qg_passed is False
        assert _stage(task_id) == "architecture"
        assert _jobs() == []

    def test_webhook_path_emits_qg_failure_notification(self, monkeypatch):
        """finished_agent=None -> generic QG-failure notification fires (plane parity).

        development stage QG is now check_ci_green (was check_tests_local).
        """
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS, "check_ci_green": _fail("ci red")},
        )
        task_id = _make_task("development")
        advance_stage(task_id, "development", "enduro-trails", "ET-001",
                     "feature/ET-001-x", finished_agent=None)
        assert stage_engine.notify_qg_failure.called
        assert stage_engine.plane_notify_qg.called

    def test_launcher_path_no_generic_qg_notification(self, monkeypatch):
        """finished_agent set -> NO generic QG notification (launcher parity)."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS, "check_architecture_done": _fail("not done")},
        )
        task_id = _make_task("architecture")
        advance_stage(task_id, "architecture", "enduro-trails", "ET-001",
                     "feature/ET-001-x", finished_agent="architect")
        assert not stage_engine.notify_qg_failure.called


# ---------------------------------------------------------------------------
# Reviewer REQUEST_CHANGES -> rollback to development + enqueue developer
# ---------------------------------------------------------------------------
class TestReviewerRequestChanges:
    def test_rollback_and_enqueue_developer(self, monkeypatch):
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_reviewer_verdict": _fail("verdict: REQUEST_CHANGES")},
        )
        task_id = _make_task("review")
        res = advance_stage(task_id, "review", "enduro-trails", "ET-001",
                           "feature/ET-001-x", finished_agent="reviewer")
        assert res.advanced is False
        assert res.rolled_back_to == "development"
        assert _stage(task_id) == "development"
        jobs = _jobs()
        assert len(jobs) == 1
        assert jobs[0]["agent"] == "developer"

    def test_retry_over_3_alerts_no_enqueue(self, monkeypatch):
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_reviewer_verdict": _fail("verdict: REQUEST_CHANGES")},
        )
        task_id = _make_task("review")
        _add_developer_runs(task_id, 3)  # already at the max
        res = advance_stage(task_id, "review", "enduro-trails", "ET-001",
                           "feature/ET-001-x", finished_agent="reviewer")
        assert res.rolled_back_to == "development"
        assert res.alerted is True
        assert stage_engine.send_telegram.called
        # No new developer job enqueued past the retry cap.
        assert _jobs() == []


# ---------------------------------------------------------------------------
# Tester FAIL -> rollback to development + enqueue developer
# ---------------------------------------------------------------------------
class TestTesterFail:
    def test_rollback_and_enqueue_developer(self, monkeypatch):
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS, "check_tests_passed": _fail("2 tests failed")},
        )
        task_id = _make_task("testing")
        res = advance_stage(task_id, "testing", "enduro-trails", "ET-001",
                           "feature/ET-001-x", finished_agent="tester")
        assert res.advanced is False
        assert res.rolled_back_to == "development"
        assert _stage(task_id) == "development"
        jobs = _jobs()
        assert len(jobs) == 1
        assert jobs[0]["agent"] == "developer"

    def test_retry_over_3_blocks_and_alerts(self, monkeypatch):
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS, "check_tests_passed": _fail("still failing")},
        )
        task_id = _make_task("testing")
        _add_developer_runs(task_id, 3)
        res = advance_stage(task_id, "testing", "enduro-trails", "ET-001",
                           "feature/ET-001-x", finished_agent="tester")
        assert res.rolled_back_to == "development"
        assert res.alerted is True
        assert stage_engine.set_issue_blocked.called
        assert _jobs() == []


# ---------------------------------------------------------------------------
# ORCH-046: rollback task_desc carries verbatim reviewer/tester must-fix text
# ---------------------------------------------------------------------------
_REVIEW_MD = """---
type: review
work_item_id: ET-001
verdict: REQUEST_CHANGES
version: 1
---

# Review ET-001

## Summary
Есть блокеры.

## Findings

### P0 — Blocker
- [ ] Гонка в claim_next_job: отсутствует guard в WHERE

### P1 — Must fix
- [ ] Нет обработки OSError при чтении отчёта

### P2 — Should fix
- [ ] Переименовать blocks
"""

_REPORT_MD = """---
type: test-report
work_item_id: ET-001
result: FAIL
---

# Test Report — ET-001

## Результаты

| TC ID | Описание | Результат |
|-------|----------|-----------|
| TC-09 | rollback | FAIL |

## Вывод pytest
FAILED tests/test_stage_engine.py::TestTaskDescEmbedding - AssertionError
1 failed, 50 passed in 3.01s

## Итог
FAIL
"""


class TestRollbackTaskDescEmbedding:
    """ORCH-046 AC-1/AC-2/AC-3/AC-4: the rollback task_desc embeds verbatim
    must-fix text (reviewer P0/P1, tester reason + report excerpt) plus the link.
    """

    def _patch_worktree(self, monkeypatch, tmp_path, work_item_id, filename, body):
        """Make get_worktree_path resolve to tmp_path and seed the artifact file."""
        artifact = tmp_path / "docs" / "work-items" / work_item_id
        artifact.mkdir(parents=True, exist_ok=True)
        (artifact / filename).write_text(body, encoding="utf-8")
        monkeypatch.setattr(
            stage_engine, "get_worktree_path", lambda repo, branch: str(tmp_path)
        )

    def test_tc09_reviewer_embeds_p0_p1_and_link(self, monkeypatch, tmp_path):
        """TC-09: reviewer REQUEST_CHANGES -> task_desc has verbatim P0/P1 + link."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_reviewer_verdict": _fail("verdict: REQUEST_CHANGES")},
        )
        self._patch_worktree(monkeypatch, tmp_path, "ET-001", "12-review.md", _REVIEW_MD)
        task_id = _make_task("review")
        advance_stage(task_id, "review", "enduro-trails", "ET-001",
                      "feature/ET-001-x", finished_agent="reviewer")
        contents = _job_contents()
        assert len(contents) == 1
        desc = contents[0]
        # AC-1: verbatim P0/P1 findings.
        assert "Гонка в claim_next_job: отсутствует guard в WHERE" in desc
        assert "Нет обработки OSError при чтении отчёта" in desc
        # P2 must not leak.
        assert "Переименовать blocks" not in desc
        # AC-3: link to full file preserved.
        assert "docs/work-items/ET-001/12-review.md" in desc

    def test_tc10_tester_embeds_reason_excerpt_and_link(self, monkeypatch, tmp_path):
        """TC-10: tester FAIL -> task_desc has reason + report excerpt + link."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_tests_passed": _fail("1 test failed")},
        )
        self._patch_worktree(
            monkeypatch, tmp_path, "ET-001", "13-test-report.md", _REPORT_MD
        )
        task_id = _make_task("testing")
        advance_stage(task_id, "testing", "enduro-trails", "ET-001",
                      "feature/ET-001-x", finished_agent="tester")
        contents = _job_contents()
        assert len(contents) == 1
        desc = contents[0]
        # AC-2: gate reason present.
        assert "1 test failed" in desc
        # AC-2: report body excerpt (pytest output) present.
        assert "FAILED tests/test_stage_engine.py::TestTaskDescEmbedding" in desc
        # AC-3: link to full file preserved.
        assert "docs/work-items/ET-001/13-test-report.md" in desc

    def test_tc11_reviewer_graceful_fallback_when_no_file(self, monkeypatch, tmp_path):
        """TC-11: missing/broken 12-review.md -> graceful link-only fallback (AC-4)."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_reviewer_verdict": _fail("verdict: REQUEST_CHANGES")},
        )
        # Worktree resolves but the review file does not exist.
        monkeypatch.setattr(
            stage_engine, "get_worktree_path", lambda repo, branch: str(tmp_path)
        )
        task_id = _make_task("review")
        res = advance_stage(task_id, "review", "enduro-trails", "ET-001",
                            "feature/ET-001-x", finished_agent="reviewer")
        # Rollback still happens exactly as before.
        assert res.rolled_back_to == "development"
        assert _stage(task_id) == "development"
        contents = _job_contents()
        assert len(contents) == 1
        desc = contents[0]
        # Falls back to the previous link-string behavior (no findings block).
        assert "Fix findings in docs/work-items/ET-001/12-review.md" in desc
        assert "Findings (P0/P1):" not in desc

    def test_tc11_tester_graceful_fallback_keeps_reason(self, monkeypatch, tmp_path):
        """AC-2/AC-4: missing report -> reason still present, link fallback."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_tests_passed": _fail("2 tests failed")},
        )
        monkeypatch.setattr(
            stage_engine, "get_worktree_path", lambda repo, branch: str(tmp_path)
        )
        task_id = _make_task("testing")
        advance_stage(task_id, "testing", "enduro-trails", "ET-001",
                      "feature/ET-001-x", finished_agent="tester")
        desc = _job_contents()[0]
        assert "2 tests failed" in desc
        assert "docs/work-items/ET-001/13-test-report.md" in desc

    def test_tc12_retry_and_rollback_behavior_unchanged(self, monkeypatch, tmp_path):
        """TC-12 (AC-6): embedding does not change retry/rollback semantics.

        4th developer attempt still alerts instead of enqueueing, even with a
        valid review file present.
        """
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_reviewer_verdict": _fail("verdict: REQUEST_CHANGES")},
        )
        self._patch_worktree(monkeypatch, tmp_path, "ET-001", "12-review.md", _REVIEW_MD)
        task_id = _make_task("review")
        _add_developer_runs(task_id, 3)  # already at the cap
        res = advance_stage(task_id, "review", "enduro-trails", "ET-001",
                            "feature/ET-001-x", finished_agent="reviewer")
        assert res.rolled_back_to == "development"
        assert res.alerted is True
        assert stage_engine.send_telegram.called
        # No new developer job past the cap, regardless of embedding.
        assert _jobs() == []


# ---------------------------------------------------------------------------
# BUG 8: deploy verdict gates deploy -> done (not the LLM exit code)
# ---------------------------------------------------------------------------
class TestDeployVerdict:
    """deploy -> done must be gated on check_deploy_status (the deployer's
    machine-readable verdict), NOT on the LLM exit code (always 0)."""

    def test_failed_verdict_rolls_back_to_development(self, monkeypatch):
        # deployer finished (exit_code 0 from launcher), but verdict is FAILED.
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_deploy_status": _fail("Deploy status: FAILED")},
        )
        task_id = _make_task("deploy")
        res = advance_stage(task_id, "deploy", "enduro-trails", "ET-011",
                            "feature/ET-011-x", finished_agent="deployer")
        assert res.advanced is False
        assert res.rolled_back_to == "development"
        assert _stage(task_id) == "development"   # NOT done
        assert res.alerted is True
        assert stage_engine.set_issue_blocked.called
        assert stage_engine.send_telegram.called

    def test_no_deploy_log_rolls_back(self, monkeypatch):
        # No frontmatter field / no file -> check returns False -> rollback.
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_deploy_status": _fail("Deploy log not found (14-deploy-log.md)")},
        )
        task_id = _make_task("deploy")
        res = advance_stage(task_id, "deploy", "enduro-trails", "ET-011",
                            "feature/ET-011-x", finished_agent="deployer")
        assert res.advanced is False
        assert _stage(task_id) == "development"

    def test_success_verdict_advances_to_done(self, monkeypatch):
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_deploy_status": _pass},
        )
        task_id = _make_task("deploy")
        res = advance_stage(task_id, "deploy", "enduro-trails", "ET-011",
                            "feature/ET-011-x", finished_agent="deployer")
        assert res.advanced is True
        assert res.to_stage == "done"
        assert _stage(task_id) == "done"
        assert res.enqueued_agent is None   # no agent leaves deploy
        assert _jobs() == []


# ---------------------------------------------------------------------------
# Architect conflict -> rollback to analysis + enqueue analyst
# ---------------------------------------------------------------------------
class TestArchitectConflict:
    def test_conflict_rolls_back_to_analysis(self, monkeypatch, tmp_path):
        # 10-conflict.md must exist in the worktree path the engine inspects.
        wt = tmp_path / "wt"
        conflict_dir = wt / "docs" / "work-items" / "ET-001"
        conflict_dir.mkdir(parents=True)
        (conflict_dir / "10-conflict.md").write_text("conflict with TRZ")

        monkeypatch.setattr(stage_engine, "get_worktree_path", lambda repo, branch: str(wt))
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS, "check_architecture_done": _fail("conflict")},
        )
        task_id = _make_task("architecture")
        res = advance_stage(task_id, "architecture", "enduro-trails", "ET-001",
                           "feature/ET-001-x", finished_agent="architect")
        assert res.advanced is False
        assert res.rolled_back_to == "analysis"
        assert _stage(task_id) == "analysis"
        jobs = _jobs()
        assert len(jobs) == 1
        assert jobs[0]["agent"] == "analyst"

    def test_no_conflict_file_no_rollback(self, monkeypatch, tmp_path):
        wt = tmp_path / "wt"
        (wt / "docs").mkdir(parents=True)
        monkeypatch.setattr(stage_engine, "get_worktree_path", lambda repo, branch: str(wt))
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS, "check_architecture_done": _fail("incomplete")},
        )
        task_id = _make_task("architecture")
        res = advance_stage(task_id, "architecture", "enduro-trails", "ET-001",
                           "feature/ET-001-x", finished_agent="architect")
        assert res.advanced is False
        assert res.rolled_back_to is None
        assert _stage(task_id) == "architecture"
        assert _jobs() == []


# ---------------------------------------------------------------------------
# Analyst approved-flow (analysis gate): never auto-advances
# ---------------------------------------------------------------------------
class TestAnalysisApprovedFlow:
    def test_artifacts_ready_requests_approval_no_advance(self, monkeypatch):
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS, "check_analysis_complete": _pass},
        )
        task_id = _make_task("analysis")
        res = advance_stage(task_id, "analysis", "enduro-trails", "ET-001",
                           "feature/ET-001-x", finished_agent="analyst")
        assert res.advanced is False
        assert _stage(task_id) == "analysis"
        assert stage_engine.set_issue_in_review.called
        assert stage_engine.notify_approve_requested.called
        assert _jobs() == []

    def test_approved_verdict_advances_analysis_to_architecture(self, monkeypatch):
        """BUG 4: a human Approved STATUS (webhook path, finished_agent=None)
        must satisfy the analysis gate and advance analysis -> architecture,
        enqueuing the architect. The status-only approval must NOT re-run
        check_analysis_approved (which looks for an :approved: COMMENT and would
        otherwise wrongly block the advance).
        """
        # Make check_analysis_approved FAIL if it is ever called: the webhook
        # path must bypass it entirely (status == approval). If the engine were
        # to re-run the gate, this would block the advance and fail the test.
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {
                **stage_engine.QG_CHECKS,
                "check_analysis_approved": _fail("no :approved: comment"),
            },
        )
        # Guard: the approval-flow (launcher-only) must NOT be invoked here.
        flow = MagicMock()
        monkeypatch.setattr(stage_engine, "_handle_analysis_approved_flow", flow)

        task_id = _make_task("analysis")
        res = advance_stage(
            task_id, "analysis", "enduro-trails", "ET-001",
            "feature/ET-001-x", finished_agent=None,
        )

        assert res.advanced is True
        assert res.to_stage == "architecture"
        assert _stage(task_id) == "architecture"
        assert res.enqueued_agent == "architect"
        # Sanity: agent for analysis is architect, never analyst (no re-run loop).
        assert get_agent_for_stage("analysis") == "architect"
        jobs = _jobs()
        assert len(jobs) == 1
        assert jobs[0]["agent"] == "architect"
        # The launcher-only approval-flow was NOT called on the webhook path.
        flow.assert_not_called()

    def test_launcher_path_does_not_advance_and_calls_flow(self, monkeypatch):
        """Regression: the launcher path (finished_agent='analyst') still routes
        into _handle_analysis_approved_flow and does NOT advance.
        """
        flow = MagicMock()
        monkeypatch.setattr(stage_engine, "_handle_analysis_approved_flow", flow)

        task_id = _make_task("analysis")
        res = advance_stage(
            task_id, "analysis", "enduro-trails", "ET-001",
            "feature/ET-001-x", finished_agent="analyst",
        )

        assert res.advanced is not True
        assert _stage(task_id) == "analysis"
        assert _jobs() == []
        flow.assert_called_once()




# ---------------------------------------------------------------------------
# ORCH-35: deploy-staging gate — rollback on staging failure
# ---------------------------------------------------------------------------
class TestStagingGate:
    """deploy-staging -> deploy must be gated on check_staging_status.
    FAILED verdict rolls back to development (same as deploy БАГ-8 pattern:
    staging failure = code is bad, needs developer fix)."""

    def test_staging_success_advances_to_deploy(self, monkeypatch):
        """Happy path: staging SUCCESS -> advance to deploy (no agent launched)."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS, "check_staging_status": _pass},
        )
        task_id = _make_task("deploy-staging")
        res = advance_stage(
            task_id, "deploy-staging", "enduro-trails", "ET-035",
            "feature/ET-035-x", finished_agent="deployer",
        )
        assert res.advanced is True
        assert res.to_stage == "deploy"
        assert _stage(task_id) == "deploy"
        # deploy-staging has agent=deployer, so deployer is enqueued for deploy stage
        assert res.enqueued_agent == "deployer"
        jobs = _jobs()
        assert len(jobs) == 1
        assert jobs[0]["agent"] == "deployer"

    def test_staging_failed_rolls_back_to_development(self, monkeypatch):
        """ORCH-35: staging FAILED -> roll back to development, not to testing."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _fail("Staging status: FAILED")},
        )
        task_id = _make_task("deploy-staging")
        res = advance_stage(
            task_id, "deploy-staging", "enduro-trails", "ET-035",
            "feature/ET-035-x", finished_agent="deployer",
        )
        assert res.advanced is False
        assert res.rolled_back_to == "development"
        assert _stage(task_id) == "development"   # NOT deploy, NOT testing
        assert res.alerted is True
        assert stage_engine.set_issue_blocked.called
        assert stage_engine.send_telegram.called

    def test_staging_failed_does_not_reach_deploy(self, monkeypatch):
        """Prod deploy is unreachable if staging gate is not green."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _fail("Staging log not found")},
        )
        task_id = _make_task("deploy-staging")
        res = advance_stage(
            task_id, "deploy-staging", "enduro-trails", "ET-035",
            "feature/ET-035-x", finished_agent="deployer",
        )
        assert res.advanced is False
        # Task must NOT be in deploy stage
        assert _stage(task_id) != "deploy"

    def test_staging_missing_log_rolls_back(self, monkeypatch):
        """Missing 15-staging-log.md -> gate fails -> rollback to development."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _fail("Staging log not found (15-staging-log.md)")},
        )
        task_id = _make_task("deploy-staging")
        res = advance_stage(
            task_id, "deploy-staging", "enduro-trails", "ET-035",
            "feature/ET-035-x", finished_agent="deployer",
        )
        assert res.advanced is False
        assert _stage(task_id) == "development"

    def test_testing_to_deploy_staging_advance(self, monkeypatch):
        """testing -> deploy-staging: deployer is enqueued (ORCH-35 chain check)."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS, "check_tests_passed": _pass},
        )
        task_id = _make_task("testing")
        res = advance_stage(
            task_id, "testing", "enduro-trails", "ET-035",
            "feature/ET-035-x", finished_agent="tester",
        )
        assert res.advanced is True
        assert res.to_stage == "deploy-staging"
        assert _stage(task_id) == "deploy-staging"
        assert res.enqueued_agent == "deployer"

    def test_deploy_still_rolls_back_on_check_deploy_status_fail(self, monkeypatch):
        """Existing БАГ-8 rollback must still work for deploy stage (regression guard)."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_deploy_status": _fail("Deploy status: FAILED")},
        )
        task_id = _make_task("deploy")
        res = advance_stage(
            task_id, "deploy", "enduro-trails", "ET-011",
            "feature/ET-011-x", finished_agent="deployer",
        )
        assert res.advanced is False
        assert res.rolled_back_to == "development"
        assert _stage(task_id) == "development"
        assert res.alerted is True


# ---------------------------------------------------------------------------
# launcher + plane both delegate to the engine
# ---------------------------------------------------------------------------
class TestMergeGate:
    """ORCH-043 / TC-20..23: the merge-gate sub-gate on the deploy-staging -> deploy
    edge. The QG ``check_branch_mergeable`` is monkeypatched on stage_engine.QG_CHECKS
    so we drive the engine's reaction (advance / defer / rollback) deterministically;
    the gate's own composition is covered in test_qg_merge_gate.py.
    """

    def _jobs_full(self):
        conn = get_db()
        rows = conn.execute(
            "SELECT agent, task_content, available_at FROM jobs ORDER BY id"
        ).fetchall()
        conn.close()
        return [dict(r) for r in rows]

    def test_tc20_pass_advances_to_deploy(self, monkeypatch):
        """TC-20 / AC-1: gate PASS (rebased + green) -> advance to deploy, deployer
        enqueued, NO rollback. staging gate must pass first (same edge).

        ORCH-036: disable the manual-approve self-deploy interception so this test
        keeps exercising the merge-gate in isolation (the executable self-deploy
        Phase A path is covered separately in test_deploy_approve.py)."""
        monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", False)
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _pass,
             "check_security_gate": _pass,
             "check_branch_mergeable": _pass,
             "check_staging_image_fresh": _pass},
        )
        task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
                             branch="feature/ORCH-043-x")
        res = advance_stage(
            task_id, "deploy-staging", "orchestrator", "ORCH-043",
            "feature/ORCH-043-x", finished_agent="deployer",
        )
        assert res.advanced is True
        assert res.to_stage == "deploy"
        assert _stage(task_id) == "deploy"
        assert res.rolled_back_to is None
        jobs = _jobs()
        assert len(jobs) == 1
        assert jobs[0]["agent"] == "deployer"

    def test_tc21_busy_lock_defers_without_rollback(self, monkeypatch):
        """TC-21 / AC-5: 'merge-lock busy' -> DEFER: task stays on deploy-staging,
        deployer re-queued with a delay (available_at set), no rollback, no alert."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _pass,
             "check_security_gate": _pass,
             "check_branch_mergeable": _fail("merge-lock busy")},
        )
        monkeypatch.setattr(stage_engine.settings, "merge_defer_delay_s", 30)
        monkeypatch.setattr(stage_engine.settings, "merge_defer_max_attempts", 5)
        task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
                             branch="feature/ORCH-043-x")
        res = advance_stage(
            task_id, "deploy-staging", "orchestrator", "ORCH-043",
            "feature/ORCH-043-x", finished_agent="deployer",
        )
        assert res.advanced is False
        assert res.rolled_back_to is None
        assert res.note == "merge-gate-deferred"
        assert _stage(task_id) == "deploy-staging"   # stays put
        jobs = self._jobs_full()
        assert len(jobs) == 1
        assert jobs[0]["agent"] == "deployer"
        assert "merge-gate defer" in jobs[0]["task_content"]
        assert jobs[0]["available_at"] is not None   # delayed re-pickup
        assert stage_engine.set_issue_blocked.called is False

    def test_tc21_defer_exhausted_blocks_and_alerts(self, monkeypatch):
        """AC-5: after merge_defer_max_attempts defers -> block + Telegram, no new job."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _pass,
             "check_security_gate": _pass,
             "check_branch_mergeable": _fail("merge-lock busy")},
        )
        monkeypatch.setattr(stage_engine.settings, "merge_defer_max_attempts", 3)
        task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
                             branch="feature/ORCH-043-x")
        # Pre-seed 3 prior defer jobs (the restart-safe counter reads task_content).
        conn = get_db()
        for _ in range(3):
            conn.execute(
                "INSERT INTO jobs (agent, repo, task_id, task_content) "
                "VALUES ('deployer','orchestrator',?, 'Note: merge-gate defer')",
                (task_id,),
            )
        conn.commit()
        conn.close()
        res = advance_stage(
            task_id, "deploy-staging", "orchestrator", "ORCH-043",
            "feature/ORCH-043-x", finished_agent="deployer",
        )
        assert res.advanced is False
        assert res.note == "merge-gate-defer-exhausted"
        assert res.alerted is True
        assert stage_engine.set_issue_blocked.called
        assert stage_engine.send_telegram.called
        # No NEW defer job past the cap (still the 3 we seeded).
        assert len(self._jobs_full()) == 3

    def test_tc22_conflict_rolls_back_to_development(self, monkeypatch):
        """TC-22 / AC-3: rebase conflict -> rollback to development + developer retry."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _pass,
             "check_security_gate": _pass,
             "check_branch_mergeable": _fail("rebase conflict: src/db.py")},
        )
        task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
                             branch="feature/ORCH-043-x")
        res = advance_stage(
            task_id, "deploy-staging", "orchestrator", "ORCH-043",
            "feature/ORCH-043-x", finished_agent="deployer",
        )
        assert res.advanced is False
        assert res.rolled_back_to == "development"
        assert _stage(task_id) == "development"
        assert res.qg_name == "check_branch_mergeable"
        jobs = _jobs()
        assert len(jobs) == 1
        assert jobs[0]["agent"] == "developer"
        assert stage_engine.set_issue_in_progress.called

    def test_tc22_red_retest_rolls_back_to_development(self, monkeypatch):
        """AC-2/AC-3: red re-test after rebase -> rollback to development."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _pass,
             "check_security_gate": _pass,
             "check_branch_mergeable": _fail("re-test failed after rebase: 1 failed")},
        )
        task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
                             branch="feature/ORCH-043-x")
        res = advance_stage(
            task_id, "deploy-staging", "orchestrator", "ORCH-043",
            "feature/ORCH-043-x", finished_agent="deployer",
        )
        assert res.rolled_back_to == "development"
        assert _stage(task_id) == "development"
        jobs = _jobs()
        assert len(jobs) == 1
        assert jobs[0]["agent"] == "developer"
        # The rollback task_desc carries the gate reason for the developer.
        assert "re-test failed after rebase: 1 failed" in _job_contents()[0]

    def test_tc23_rollback_respects_max_developer_retries(self, monkeypatch):
        """TC-23 / AC-11: merge-gate rollback is capped by MAX_DEVELOPER_RETRIES —
        no infinite bounce. 4th attempt -> block + alert, no new developer job."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _pass,
             "check_security_gate": _pass,
             "check_branch_mergeable": _fail("rebase conflict: src/db.py")},
        )
        task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
                             branch="feature/ORCH-043-x")
        _add_developer_runs(task_id, 3)  # already at the cap
        res = advance_stage(
            task_id, "deploy-staging", "orchestrator", "ORCH-043",
            "feature/ORCH-043-x", finished_agent="deployer",
        )
        assert res.rolled_back_to == "development"
        assert stage_engine.set_issue_blocked.called
        assert stage_engine.send_telegram.called
        assert _jobs() == []   # no developer job past the cap

    def test_non_self_hosting_repo_skips_merge_gate(self, monkeypatch):
        """Regression: for a non-self-hosting repo the REAL gate is a no-op, so
        deploy-staging -> deploy advances exactly as before ORCH-043."""
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS, "check_staging_status": _pass},
        )  # check_branch_mergeable left REAL -> N/A for enduro-trails
        task_id = _make_task("deploy-staging", repo="enduro-trails", wi="ET-035",
                             branch="feature/ET-035-x")
        res = advance_stage(
            task_id, "deploy-staging", "enduro-trails", "ET-035",
            "feature/ET-035-x", finished_agent="deployer",
        )
        assert res.advanced is True
        assert _stage(task_id) == "deploy"


class TestImageFreshnessGate:
    """ORCH-058 TC-10/11: the image-freshness sub-gate on the deploy-staging ->
    deploy edge. It runs AFTER staging-status + merge-gate, BEFORE Phase A."""

    def _jobs_full(self):
        conn = get_db()
        rows = conn.execute(
            "SELECT agent, task_content FROM jobs ORDER BY id"
        ).fetchall()
        conn.close()
        return [dict(r) for r in rows]

    def test_tc10_stale_image_fails_fast_and_rolls_back(self, monkeypatch):
        """TC-10 / AC-1/AC-4: staging status + merge-gate green but the staging
        image is STALE -> fail-fast: rollback to development, developer re-queued,
        prod NEVER reached (no advance to deploy)."""
        monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", False)
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _pass,
             "check_security_gate": _pass,
             "check_branch_mergeable": _pass,
             "check_staging_image_fresh": _fail(
                 "staging rebuild failed: health FAILED")},
        )
        task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-058",
                             branch="feature/ORCH-058-x")
        res = advance_stage(
            task_id, "deploy-staging", "orchestrator", "ORCH-058",
            "feature/ORCH-058-x", finished_agent="deployer",
        )
        assert res.advanced is False
        assert res.rolled_back_to == "development"
        assert _stage(task_id) == "development"   # never reached deploy
        jobs = self._jobs_full()
        assert len(jobs) == 1
        assert jobs[0]["agent"] == "developer"
        # The rollback task_desc carries the freshness reason for the developer.
        assert "staging rebuild failed" in jobs[0]["task_content"]

    def test_tc10_stale_rollback_respects_max_retries(self, monkeypatch):
        """AC-1: image-freshness rollback is capped by MAX_DEVELOPER_RETRIES —
        4th attempt -> block + alert, no new developer job."""
        monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", False)
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _pass,
             "check_security_gate": _pass,
             "check_branch_mergeable": _pass,
             "check_staging_image_fresh": _fail("provenance mismatch")},
        )
        task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-058",
                             branch="feature/ORCH-058-x")
        _add_developer_runs(task_id, 3)  # already at the cap
        res = advance_stage(
            task_id, "deploy-staging", "orchestrator", "ORCH-058",
            "feature/ORCH-058-x", finished_agent="deployer",
        )
        assert res.rolled_back_to == "development"
        assert stage_engine.set_issue_blocked.called
        assert stage_engine.send_telegram.called
        assert _jobs() == []   # no developer job past the cap

    def test_tc11_fresh_image_advances_to_deploy(self, monkeypatch):
        """TC-11 / AC-1/AC-4: all three sub-checks green -> advance to deploy,
        deployer enqueued, NO rollback (happy path)."""
        monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", False)
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _pass,
             "check_security_gate": _pass,
             "check_branch_mergeable": _pass,
             "check_staging_image_fresh": _pass},
        )
        task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-058",
                             branch="feature/ORCH-058-x")
        res = advance_stage(
            task_id, "deploy-staging", "orchestrator", "ORCH-058",
            "feature/ORCH-058-x", finished_agent="deployer",
        )
        assert res.advanced is True
        assert res.to_stage == "deploy"
        assert _stage(task_id) == "deploy"
        assert res.rolled_back_to is None
        jobs = _jobs()
        assert len(jobs) == 1
        assert jobs[0]["agent"] == "deployer"

    def test_tc11_non_self_repo_skips_freshness_gate(self, monkeypatch):
        """Regression: for a non-self repo the REAL freshness gate is a no-op
        (N/A), so deploy-staging -> deploy advances exactly as before ORCH-058."""
        monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", False)
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _pass,
             "check_security_gate": _pass,
             "check_branch_mergeable": _pass},
        )  # check_staging_image_fresh left REAL -> N/A for enduro-trails
        task_id = _make_task("deploy-staging", repo="enduro-trails", wi="ET-099",
                             branch="feature/ET-099-x")
        res = advance_stage(
            task_id, "deploy-staging", "enduro-trails", "ET-099",
            "feature/ET-099-x", finished_agent="deployer",
        )
        assert res.advanced is True
        assert _stage(task_id) == "deploy"


class TestDelegation:
    def test_launcher_calls_engine(self):
        from src.agents.launcher import AgentLauncher
        task_id = _make_task("development", branch="feature/ET-777-deleg")
        with patch("src.stage_engine.advance_stage") as m:
            AgentLauncher()._try_advance_stage(
                run_id=1, agent="developer", repo="enduro-trails",
                branch="feature/ET-777-deleg",
            )
        m.assert_called_once()
        kwargs = m.call_args.kwargs
        assert kwargs["task_id"] == task_id
        assert kwargs["current_stage"] == "development"
        assert kwargs["finished_agent"] == "developer"

    def test_plane_calls_engine(self):
        import asyncio
        from src.webhooks import plane as plane_mod
        with patch("src.stage_engine.advance_stage") as m:
            asyncio.run(
                plane_mod._try_advance_stage(
                    task_id=5, current_stage="analysis", repo="enduro-trails",
                    work_item_id="ET-001", branch="feature/ET-001-x",
                )
            )
        m.assert_called_once()
        # plane passes positional args; finished_agent (last positional) is None.
        args = m.call_args.args
        assert args[0] == 5
        assert args[1] == "analysis"
        assert args[-1] is None


# ---------------------------------------------------------------------------
# ORCH-061: no deploy-staging loop on a healthy self-deploy; the ORCH-35 safety
# net (real staging FAIL -> rollback) stays intact; the new logic never raises
# into advance_stage; and "green with an infra allowance" is distinguishable from
# an honest green (observability).
# ---------------------------------------------------------------------------
class TestStagingInfraTolerance:
    """The verdict that produces ``staging_status:`` is computed in the suite
    BEFORE the gate (ORCH-061 ADR-001 §4: check_staging_status is unchanged). At
    the engine level we therefore assert the REACTION to the resulting verdict:
    SUCCESS advances (no loop), a REAL FAILED rolls back (safety net)."""

    def _patch_self_deploy_state(self, monkeypatch, tmp_path):
        # Phase A writes restart-safe markers under repos_dir — keep them in tmp.
        monkeypatch.setattr(stage_engine.self_deploy.settings, "repos_dir", str(tmp_path))
        monkeypatch.setattr(stage_engine.self_deploy.settings, "host_repos_dir", str(tmp_path))

    def test_tc01_healthy_self_deploy_advances_no_rollback(self, monkeypatch, tmp_path):
        """TC-01 / AC-1: staging SUCCESS (infra-FAIL already waived in the suite)
        + green merge/freshness sub-gates -> deploy-staging advances to `deploy`
        (Phase A approval-pending). NO rollback to development (loop is gone)."""
        self._patch_self_deploy_state(monkeypatch, tmp_path)
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _pass,
             "check_security_gate": _pass,
             "check_branch_mergeable": _pass,
             "check_staging_image_fresh": _pass},
        )
        task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-061",
                             branch="feature/ORCH-061-x")
        res = advance_stage(
            task_id, "deploy-staging", "orchestrator", "ORCH-061",
            "feature/ORCH-061-x", finished_agent="deployer",
        )
        assert res.advanced is True
        assert res.to_stage == "deploy"
        assert _stage(task_id) == "deploy"            # Phase A advanced the stage
        assert res.rolled_back_to is None             # NO loop back to development
        assert res.note == "self-deploy-approval-pending"

    def test_tc02_real_staging_failed_rolls_back(self, monkeypatch, tmp_path):
        """TC-02 / AC-3: a REAL staging failure (verdict FAILED) still rolls
        deploy-staging back to development + set_issue_blocked + alert — the
        ORCH-35 safety net is NOT weakened by the infra tolerance (FR-4)."""
        self._patch_self_deploy_state(monkeypatch, tmp_path)
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _fail("Staging status: FAILED")},
        )
        task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-061",
                             branch="feature/ORCH-061-x")
        res = advance_stage(
            task_id, "deploy-staging", "orchestrator", "ORCH-061",
            "feature/ORCH-061-x", finished_agent="deployer",
        )
        assert res.advanced is False
        assert res.rolled_back_to == "development"
        assert _stage(task_id) == "development"
        assert res.alerted is True
        assert stage_engine.set_issue_blocked.called
        assert stage_engine.send_telegram.called

    def test_tc12_gate_exception_never_crashes_advance(self, monkeypatch, tmp_path):
        """TC-12 / AC-10 never-raise: if the staging gate raises (io/parse/docker
        hiccup), advance_stage catches it deterministically — no exception escapes,
        the task does NOT advance and is NOT falsely rolled back to development."""
        self._patch_self_deploy_state(monkeypatch, tmp_path)

        def _boom(*a, **k):
            raise RuntimeError("staging gate blew up")

        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS, "check_staging_status": _boom},
        )
        task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-061",
                             branch="feature/ORCH-061-x")
        # Must NOT raise.
        res = advance_stage(
            task_id, "deploy-staging", "orchestrator", "ORCH-061",
            "feature/ORCH-061-x", finished_agent="deployer",
        )
        assert res.advanced is False
        assert res.rolled_back_to is None             # exception != gate FAILED
        assert _stage(task_id) == "deploy-staging"    # stays put, no loop
        assert res.note and "error" in res.note

    def test_tc13_end_to_end_self_deploy_no_single_rollback(self, monkeypatch, tmp_path):
        """TC-13 / AC-1+AC-4 integration: a healthy self-deploy goes
        deploy-staging -> deploy (Phase A) -> (approve/finalize SUCCESS) -> done
        WITHOUT a single rollback to development in the transition log."""
        self._patch_self_deploy_state(monkeypatch, tmp_path)
        monkeypatch.setattr(
            stage_engine, "QG_CHECKS",
            {**stage_engine.QG_CHECKS,
             "check_staging_status": _pass,
             "check_security_gate": _pass,
             "check_branch_mergeable": _pass,
             "check_staging_image_fresh": _pass,
             "check_deploy_status": _pass},
        )
        task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-061",
                             branch="feature/ORCH-061-x")

        seen_stages = []

        # 1) deploy-staging -> deploy (Phase A approval-pending).
        r1 = advance_stage(
            task_id, "deploy-staging", "orchestrator", "ORCH-061",
            "feature/ORCH-061-x", finished_agent="deployer",
        )
        seen_stages.append(_stage(task_id))
        assert r1.advanced is True
        assert _stage(task_id) == "deploy"

        # 2) finalizer (Phase C): deploy verdict SUCCESS -> done.
        r2 = advance_stage(
            task_id, "deploy", "orchestrator", "ORCH-061",
            "feature/ORCH-061-x", finished_agent="deployer",
        )
        seen_stages.append(_stage(task_id))
        assert r2.advanced is True
        assert _stage(task_id) == "done"

        # Not a single rollback to development anywhere in the path.
        assert "development" not in seen_stages
        assert r1.rolled_back_to is None and r2.rolled_back_to is None

    def test_tc14_waived_green_distinguishable_from_honest_green(self):
        """TC-14 / AC-11 observability: the staging verdict makes "green with an
        infra allowance" distinguishable from an honest green — the waived list is
        populated and the summary says so, vs an empty waived list + plain summary
        for an all-green run."""
        from src.staging_verdict import REAL, SANDBOX_INFRA, compute_staging_verdict

        waived = compute_staging_verdict(
            [("C7", True, REAL),
             ("C9a", False, SANDBOX_INFRA)],
            infra_tolerant=True,
        )
        honest = compute_staging_verdict(
            [("C7", True, REAL),
             ("C9a", True, SANDBOX_INFRA)],
            infra_tolerant=True,
        )
        # Both advance...
        assert waived.status == honest.status == "SUCCESS"
        # ...but only the waived one carries the explicit allowance marker.
        assert waived.waived == ["C9a"]
        assert "infra-waived" in waived.summary.lower()
        assert honest.waived == []
        assert "infra-waived" not in honest.summary.lower()
