#!/usr/bin/env python3
"""
staging_check.py — Live staging-stand health & e2e check suite (ORCH-33).

Checks:
  Block A — SMOKE (health/queue, correct env)
  Block B — ACCESS (read-only calls to Plane sandbox + Gitea sandbox + registry)
  Block C — E2E   (create task in SANDBOX → trigger pipeline via /webhook/plane
                   → verify branch + job enqueued → CLEANUP in finally)

Usage — CANONICAL: run INSIDE the orchestrator-staging container (ORCH-048, ADR-001)
so B6 reads the registry from the running instance's own env (.env.staging):
    docker exec orchestrator-staging \
      python3 /repos/orchestrator/scripts/staging_check.py \
      --base-url http://localhost:8501 [--mode stub|full-real]

Running from the host leaves ORCH_PROJECTS_JSON unset → B6 falls back to the
default (ET+ORCH) registry → false FAIL. See docs/operations/STAGING_CHECK.md.

Exit code: 0 = all PASS, non-zero = at least one FAIL.

NOTE on modes:
  stub      — default; checks early pipeline artifacts (branch + analyst job
              enqueued) created BEFORE Claude CLI is invoked.
              Fast, deterministic, no LLM spend.
  full-real — additionally waits for the analyst agent to finish (long, costs
              credits). Not the default.

NOTE on Plane comments (403):
  The orchestrator posts the "🔍 Analyst запущен" comment using per-agent bot
  tokens (ORCH_PLANE_BOT_ANALYST). These bot accounts must be added as members
  of every Plane project they comment on. In staging the sandbox project was
  created after the bots were provisioned → the bots are not yet members of
  SANDBOX → add_comment returns 403 Forbidden.

  This is a known infrastructure limitation of the staging sandbox, NOT a bug
  in the pipeline itself. C9b therefore verifies pipeline success via the
  staging job queue (/queue → recent) instead of Plane comments: the analyst
  job is enqueued BEFORE the add_comment call and its presence in the queue
  proves the pipeline ran through correctly.
"""

import argparse
import hashlib
import hmac
import json
import os
import sys
import time
import datetime
import urllib.request
import urllib.error
import urllib.parse
from collections import namedtuple

# ---------------------------------------------------------------------------
# ORCH-061: pure staging-verdict logic (classification + infra-tolerant verdict).
# Imported from src.staging_verdict — a stdlib-only leaf, safe to import inside
# the orchestrator-staging container (PYTHONPATH=/app, pattern B6 / ORCH-048).
# Guarded so the suite still runs (in strict mode) if src is somehow unimportable
# from a host invocation; the fallback NEVER yields a silent green (fail-closed).
# ---------------------------------------------------------------------------
try:
    from src.staging_verdict import (  # type: ignore
        classify_check as _classify_check,
        compute_staging_verdict as _compute_staging_verdict,
        REAL as _REAL,
        SANDBOX_INFRA as _SANDBOX_INFRA,
    )
except Exception:  # pragma: no cover - exercised only on a broken host import
    _classify_check = None
    _compute_staging_verdict = None
    _REAL = "real"
    _SANDBOX_INFRA = "sandbox_infra"

_FallbackVerdict = namedtuple("StagingVerdict", "status exit_code waived summary")


def _classify(label: str) -> str:
    """Classify a check label via staging_verdict; fail-closed to REAL if absent."""
    if _classify_check is not None:
        return _classify_check(label)
    return _REAL


def _verdict(items, infra_tolerant: bool):
    """Compute the suite verdict via staging_verdict; strict fail-closed fallback."""
    if _compute_staging_verdict is not None:
        return _compute_staging_verdict(items, infra_tolerant)
    failed = [lbl for (lbl, ok, _cat) in items if not ok]
    if failed:
        return _FallbackVerdict("FAILED", 1, [], f"FAILED (strict fallback): {failed}")
    return _FallbackVerdict("SUCCESS", 0, [], "SUCCESS (strict fallback): all green")

# ---------------------------------------------------------------------------
# Colour helpers
# ---------------------------------------------------------------------------
_BOLD = "\033[1m"
_GREEN = "\033[32m"
_RED = "\033[31m"
_YELLOW = "\033[33m"
_RESET = "\033[0m"


def _ok(msg: str) -> str:
    return f"  {_GREEN}✓ PASS{_RESET}  {msg}"


def _fail(msg: str) -> str:
    return f"  {_RED}✗ FAIL{_RESET}  {msg}"


def _info(msg: str) -> str:
    return f"  {_YELLOW}·{_RESET}      {msg}"


# ---------------------------------------------------------------------------
# Low-level HTTP helpers (stdlib only — no requests/httpx in scripts/)
# ---------------------------------------------------------------------------

def _http(method: str, url: str, headers: dict | None = None,
          body: bytes | None = None, timeout: int = 15) -> tuple[int, bytes]:
    """Simple HTTP wrapper. Returns (status_code, response_body)."""
    req = urllib.request.Request(url, data=body, headers=headers or {}, method=method)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return resp.status, resp.read()
    except urllib.error.HTTPError as e:
        return e.code, e.read()
    except Exception as e:
        raise RuntimeError(f"{method} {url} → {e}") from e


def _get(url: str, headers: dict | None = None, timeout: int = 15) -> tuple[int, dict]:
    status, body = _http("GET", url, headers=headers, timeout=timeout)
    try:
        data = json.loads(body)
    except Exception:
        data = {"_raw": body.decode(errors="replace")}
    return status, data


def _post(url: str, headers: dict | None = None, payload: dict | None = None,
          raw_body: bytes | None = None, timeout: int = 15) -> tuple[int, dict]:
    if raw_body is not None:
        body = raw_body
        h = dict(headers or {})
        if "Content-Type" not in h:
            h["Content-Type"] = "application/json"
    else:
        body = json.dumps(payload or {}).encode()
        h = dict(headers or {})
        h["Content-Type"] = "application/json"
    status, resp_body = _http("POST", url, headers=h, body=body, timeout=timeout)
    try:
        data = json.loads(resp_body)
    except Exception:
        data = {"_raw": resp_body.decode(errors="replace")}
    return status, data


def _patch(url: str, headers: dict | None = None, payload: dict | None = None,
           timeout: int = 15) -> tuple[int, dict]:
    body = json.dumps(payload or {}).encode()
    h = dict(headers or {})
    h["Content-Type"] = "application/json"
    status, resp_body = _http("PATCH", url, headers=h, body=body, timeout=timeout)
    try:
        data = json.loads(resp_body)
    except Exception:
        data = {"_raw": resp_body.decode(errors="replace")}
    return status, data


def _delete(url: str, headers: dict | None = None, timeout: int = 15) -> int:
    status, _ = _http("DELETE", url, headers=headers, timeout=timeout)
    return status


# ---------------------------------------------------------------------------
# HMAC helper for /webhook/plane
# ---------------------------------------------------------------------------

def _sign_payload(secret: str, body: bytes) -> str:
    """Compute HMAC-SHA256 signature — matches verify_plane_signature in plane.py."""
    return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()


# ---------------------------------------------------------------------------
# Result tracking
# ---------------------------------------------------------------------------

class Results:
    def __init__(self):
        # _items keeps the (label, passed, detail) 3-tuple shape that existing
        # ORCH-048 B6 tests unpack — categories live in a PARALLEL list so the
        # public tuple contract is unchanged.
        self._items: list[tuple[str, bool, str]] = []  # (label, passed, detail)
        self._categories: list[str] = []               # ORCH-061: REAL | SANDBOX_INFRA

    def add(self, label: str, passed: bool, detail: str = "", category: str | None = None):
        # ORCH-061: every check carries a category. None -> auto-classify by label
        # (C9a/C9b -> SANDBOX_INFRA, everything else -> REAL). Fail-closed: an
        # unknown label is REAL, so it still counts toward the safety net.
        if category is None:
            category = _classify(label)
        self._items.append((label, passed, detail))
        self._categories.append(category)
        line = _ok(label) if passed else _fail(label)
        if detail:
            line += f"  [{detail}]"
        print(line)

    def categorized_items(self) -> list[tuple[str, bool, str]]:
        """Rows as ``(label, passed, category)`` for ``compute_staging_verdict``."""
        return [
            (label, passed, cat)
            for (label, passed, _detail), cat in zip(self._items, self._categories)
        ]

    def summary(self) -> bool:
        passed = sum(1 for _, ok, _ in self._items if ok)
        total = len(self._items)
        all_ok = passed == total
        colour = _GREEN if all_ok else _RED
        # ORCH-061: per-category breakdown so an operator can tell a REAL failure
        # (regression — fail-closed) from a SANDBOX_INFRA one (waivable).
        rows = self.categorized_items()
        real_fail = [lbl for lbl, ok, cat in rows if not ok and cat == _REAL]
        infra_fail = [lbl for lbl, ok, cat in rows if not ok and cat == _SANDBOX_INFRA]
        print()
        print(f"{_BOLD}{'='*60}{_RESET}")
        print(f"{colour}{_BOLD}  RESULT: {passed}/{total} checks PASS{_RESET}")
        print(f"  REAL failed         : {real_fail or 'none'}")
        print(f"  SANDBOX_INFRA failed: {infra_fail or 'none'}")
        print(f"{_BOLD}{'='*60}{_RESET}")
        return all_ok


# ---------------------------------------------------------------------------
# Block A — SMOKE
# ---------------------------------------------------------------------------

def block_a(base: str, results: Results):
    print(f"\n{_BOLD}[Block A] SMOKE{_RESET}")

    # A1 — /health
    try:
        status, data = _get(f"{base}/health")
        ok = status == 200 and data.get("status") == "ok"
        results.add("A1 GET /health → 200 status=ok", ok,
                    f"HTTP {status}, body={data}")
    except Exception as e:
        results.add("A1 GET /health → 200 status=ok", False, str(e))

    # A2 — /queue
    try:
        status, data = _get(f"{base}/queue")
        ok = (status == 200
              and "counts" in data
              and "max_concurrency" in data
              and "resilience" in data)
        results.add("A2 GET /queue → 200 with counts/max_concurrency/resilience", ok,
                    f"HTTP {status}, keys={list(data.keys())}")
    except Exception as e:
        results.add("A2 GET /queue → 200 with counts/max_concurrency/resilience", False, str(e))

    # A3 — ORCH_STAGING=true in env (guard against hitting prod)
    staging_flag = os.environ.get("ORCH_STAGING", "").lower()
    ok = staging_flag == "true"
    results.add("A3 ORCH_STAGING=true (not prod)", ok,
                f"ORCH_STAGING={os.environ.get('ORCH_STAGING', '<unset>')}")
    if not ok:
        print(_fail("  ⛔ Safety abort: ORCH_STAGING is not 'true'. "
                    "This might be prod. Skipping destructive blocks B/C."))
        sys.exit(2)


# ---------------------------------------------------------------------------
# Block B — ACCESS
# ---------------------------------------------------------------------------

SANDBOX_PROJECT_ID = "8c5a3025-4f9d-4190-b79f-fa06276bb27e"
PROD_ET_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
PROD_ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"

B6_LABEL = "B6 Registry: sandbox present, prod ET/ORCH absent"


def _evaluate_b6(known: set[str]) -> tuple[bool, str]:
    """Pure verdict logic for the B6 registry-isolation check (ORCH-048).

    PASS ⟺ SANDBOX ∈ known ∧ PROD_ET ∉ known ∧ PROD_ORCH ∉ known (TR-2).
    ``detail`` keeps the human-readable ``sandbox=…, prod-ET=…, prod-ORCH=…``
    format (TR-3). Isolated from any I/O so both outcomes are unit-testable
    without a live staging instance or docker (02-trz §9, ADR-001).
    """
    sandbox_present = SANDBOX_PROJECT_ID in known
    et_absent = PROD_ET_PROJECT_ID not in known
    orch_absent = PROD_ORCH_PROJECT_ID not in known
    passed = sandbox_present and et_absent and orch_absent
    detail = (
        f"sandbox={'YES' if sandbox_present else 'NO'}, "
        f"prod-ET={'NO(good)' if et_absent else 'YES(BAD!)'}, "
        f"prod-ORCH={'NO(good)' if orch_absent else 'YES(BAD!)'}"
    )
    return passed, detail


def _known_project_ids_from_registry() -> set[str]:
    """Registry of the *running staging instance* — its own process-env (ORCH-048).

    The suite is canonically run INSIDE ``orchestrator-staging`` via
    ``docker exec`` (ADR-001), so ``src.projects`` resolves through the
    container's ``PYTHONPATH=/app`` to ``/app/src/projects.py`` and reads
    ``ORCH_PROJECTS_JSON`` from ``.env.staging``. This reflects exactly the
    registry the live instance serves webhooks with — no host-path hack, no HTTP
    bootstrap dependency.
    """
    from src.projects import known_plane_project_ids
    return known_plane_project_ids()


def _run_b6(results: Results) -> None:
    """Run the B6 registry-isolation check and record its verdict.

    Builds the known-id set from the running instance's registry and applies
    ``_evaluate_b6``. Any failure to obtain the registry yields a deterministic
    FAIL with a clear detail (TR-4) — never an unhandled exception and never a
    false PASS.
    """
    try:
        known = _known_project_ids_from_registry()
    except Exception as e:
        results.add(B6_LABEL, False, f"registry source unavailable: {e}")
        return
    passed, detail = _evaluate_b6(known)
    results.add(B6_LABEL, passed, detail)


def block_b(results: Results):
    print(f"\n{_BOLD}[Block B] ACCESS{_RESET}")

    plane_token = os.environ.get("ORCH_PLANE_API_TOKEN", "")
    plane_base_env = os.environ.get("ORCH_PLANE_API_URL", "http://localhost:8091")
    # env stores URL WITHOUT /api/v1 — add it ourselves
    plane_base = plane_base_env.rstrip("/") + "/api/v1"
    workspace = os.environ.get("ORCH_PLANE_WORKSPACE_SLUG", "ag_proj")
    gitea_token = os.environ.get("ORCH_GITEA_TOKEN", "")
    gitea_base = os.environ.get("ORCH_GITEA_URL", "http://localhost:3000")

    plane_headers = {"X-API-Key": plane_token}
    gitea_headers = {"Authorization": f"token {gitea_token}"}

    # B4 — Plane: list projects, sandbox id present
    try:
        url = f"{plane_base}/workspaces/{workspace}/projects/"
        status, data = _get(url, headers=plane_headers)
        if status == 200:
            # API may return a list or {"results": [...]}
            projects = data.get("results", data) if isinstance(data, dict) else data
            if isinstance(projects, list):
                ids = {p.get("id", "") for p in projects}
            else:
                ids = set()
            ok = SANDBOX_PROJECT_ID in ids
            results.add("B4 Plane: sandbox project accessible", ok,
                        f"HTTP {status}, found {len(ids)} project(s), sandbox={'YES' if ok else 'NO'}")
        else:
            results.add("B4 Plane: sandbox project accessible", False,
                        f"HTTP {status}")
    except Exception as e:
        results.add("B4 Plane: sandbox project accessible", False, str(e))

    # B5 — Gitea: sandbox repo accessible, push=true
    try:
        url = f"{gitea_base}/api/v1/repos/admin/orchestrator-sandbox"
        status, data = _get(url, headers=gitea_headers)
        push_ok = data.get("permissions", {}).get("push", False) if status == 200 else False
        ok = status == 200 and push_ok
        results.add("B5 Gitea: orchestrator-sandbox accessible, push=true", ok,
                    f"HTTP {status}, permissions={data.get('permissions')}")
    except Exception as e:
        results.add("B5 Gitea: orchestrator-sandbox accessible, push=true", False, str(e))

    # B6 — Registry: sandbox in known IDs, prod ET/ORCH NOT in known IDs (ORCH-048).
    # Reads the registry of the running staging instance from its own process-env
    # (canonical: docker exec inside orchestrator-staging — ADR-001). No host-path
    # hack; deterministic FAIL if the registry source is unavailable (TR-4).
    _run_b6(results)


# ---------------------------------------------------------------------------
# Block C — E2E
# ---------------------------------------------------------------------------

IN_PROGRESS_STATE_ID = "b873d9eb-993c-48cd-97ac-99a9b1623967"

# Path to staging SQLite DB inside the container
STAGING_DB_PATH = os.environ.get("ORCH_DB_PATH", "/app/data/orchestrator.db")


def _make_webhook_payload(issue_id: str, issue_name: str, issue_desc: str) -> dict:
    """Build the minimal webhook payload that triggers start_pipeline."""
    return {
        "event": "issue",
        "action": "updated",
        "data": {
            "id": issue_id,
            "name": issue_name,
            "description_stripped": issue_desc,
            "project": SANDBOX_PROJECT_ID,
            "state": {
                "id": IN_PROGRESS_STATE_ID,
                "name": "In Progress",
                "group": "started",
            },
        },
    }


def _poll(fn, timeout: int = 60, interval: int = 3, label: str = ""):
    """Poll fn() until it returns truthy or timeout expires."""
    deadline = time.time() + timeout
    while time.time() < deadline:
        result = fn()
        if result:
            return result
        if label:
            print(_info(f"  waiting... ({label})"))
        time.sleep(interval)
    return None


def _cleanup_staging_db(plane_issue_id: str):
    """Delete the test task row from staging SQLite DB."""
    if not plane_issue_id:
        print(_info("CLEANUP DB: no issue_id to clean"))
        return
    try:
        import sqlite3
        conn = sqlite3.connect(STAGING_DB_PATH)
        cur = conn.execute(
            "DELETE FROM tasks WHERE plane_id = ?", (plane_issue_id,)
        )
        deleted = cur.rowcount
        conn.commit()
        conn.close()
        if deleted:
            print(_ok(f"CLEANUP DB: deleted {deleted} task row(s) for plane_id={plane_issue_id}"))
        else:
            print(_info(f"CLEANUP DB: no task row found for plane_id={plane_issue_id}"))
    except Exception as e:
        print(_fail(f"CLEANUP DB: error: {e}"))


def _cleanup_staging_jobs(plane_issue_id: str):
    """Delete job queue rows for the test task from staging SQLite DB."""
    if not plane_issue_id:
        return
    try:
        import sqlite3
        conn = sqlite3.connect(STAGING_DB_PATH)
        # Find task ids for this plane_id first
        task_rows = conn.execute(
            "SELECT id FROM tasks WHERE plane_id = ?", (plane_issue_id,)
        ).fetchall()
        if task_rows:
            task_ids = [r[0] for r in task_rows]
            placeholders = ",".join("?" * len(task_ids))
            cur = conn.execute(
                f"DELETE FROM jobs WHERE task_id IN ({placeholders})", task_ids
            )
            deleted = cur.rowcount
            conn.commit()
            if deleted:
                print(_ok(f"CLEANUP DB: deleted {deleted} job row(s) for task_ids={task_ids}"))
        conn.close()
    except Exception as e:
        print(_fail(f"CLEANUP DB jobs: error: {e}"))


def _cleanup_dedup(plane_issue_id: str, wh_body_sha: str | None = None):
    """Remove dedup event entries for the test webhook delivery."""
    if not wh_body_sha:
        return
    try:
        import sqlite3
        conn = sqlite3.connect(STAGING_DB_PATH)
        cur = conn.execute(
            "DELETE FROM events_dedup WHERE delivery_id = ?", (wh_body_sha,)
        )
        deleted = cur.rowcount
        conn.commit()
        conn.close()
        if deleted:
            print(_ok(f"CLEANUP DB: removed {deleted} dedup entry"))
    except Exception as e:
        # dedup table might not exist or different schema — not critical
        print(_info(f"CLEANUP DB dedup: {e}"))


def block_c(base: str, results: Results, mode: str):
    print(f"\n{_BOLD}[Block C] E2E  (mode={mode}){_RESET}")

    plane_token = os.environ.get("ORCH_PLANE_API_TOKEN", "")
    plane_base_env = os.environ.get("ORCH_PLANE_API_URL", "http://localhost:8091")
    plane_base = plane_base_env.rstrip("/") + "/api/v1"
    workspace = os.environ.get("ORCH_PLANE_WORKSPACE_SLUG", "ag_proj")
    gitea_token = os.environ.get("ORCH_GITEA_TOKEN", "")
    gitea_base = os.environ.get("ORCH_GITEA_URL", "http://localhost:3000")
    webhook_secret = os.environ.get("ORCH_PLANE_WEBHOOK_SECRET", "")

    plane_headers = {"X-API-Key": plane_token}
    gitea_headers = {"Authorization": f"token {gitea_token}"}

    ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S")
    issue_name = f"[staging-check] e2e {ts}"
    issue_desc = (
        "Automated e2e check created by staging_check.py. "
        "This task tests the live staging pipeline end-to-end. "
        "Safe to delete — cleanup runs in finally block."
    )

    issue_id = None
    branch_name = None
    wh_body_bytes = None

    try:
        # C7 — Create task in Plane SANDBOX
        print(_info(f"C7: Creating issue in SANDBOX project..."))
        url = f"{plane_base}/workspaces/{workspace}/projects/{SANDBOX_PROJECT_ID}/issues/"
        status, data = _post(url, headers=plane_headers, payload={
            "name": issue_name,
            "description_html": f"<p>{issue_desc}</p>",
            "description_stripped": issue_desc,
        })
        issue_id = data.get("id")
        ok = status in (200, 201) and bool(issue_id)
        results.add("C7 Create issue in Plane SANDBOX", ok,
                    f"HTTP {status}, issue_id={issue_id}")
        if not ok:
            print(_fail(f"  Cannot continue C8-C9 without issue. body={data}"))
            results.add("C8 Trigger pipeline via /webhook/plane", False, "skipped: C7 failed")
            results.add("C9a Branch appears in orchestrator-sandbox", False, "skipped")
            results.add("C9b Analyst job enqueued in staging queue", False, "skipped")
            return

        # Small delay to let Plane finish persisting the issue
        time.sleep(2)

        # C8 — Trigger pipeline via direct POST to /webhook/plane
        print(_info(f"C8: Triggering pipeline via POST /webhook/plane ..."))
        wh_payload = _make_webhook_payload(issue_id, issue_name, issue_desc)
        wh_body_bytes = json.dumps(wh_payload).encode()

        wh_headers = {"Content-Type": "application/json"}
        if webhook_secret:
            sig = _sign_payload(webhook_secret, wh_body_bytes)
            wh_headers["X-Plane-Signature"] = sig
            print(_info(f"  Using HMAC signature (secret len={len(webhook_secret)})"))
        else:
            print(_info("  No webhook secret configured, sending without signature"))

        status, resp = _post(f"{base}/webhook/plane",
                             headers=wh_headers,
                             raw_body=wh_body_bytes)
        ok = status == 200 and resp.get("status") in ("accepted",)
        results.add("C8 Trigger pipeline via /webhook/plane", ok,
                    f"HTTP {status}, resp={resp}")
        if not ok:
            print(_fail(f"  Pipeline trigger failed. Cannot verify C9."))
            results.add("C9a Branch appears in orchestrator-sandbox", False, "skipped: C8 failed")
            results.add("C9b Analyst job enqueued in staging queue", False, "skipped: C8 failed")
            return

        # C9a — Poll for branch in Gitea orchestrator-sandbox
        print(_info("C9a: Polling for branch in orchestrator-sandbox (up to 60s)..."))

        def _check_branch():
            try:
                burl = f"{gitea_base}/api/v1/repos/admin/orchestrator-sandbox/branches"
                s, bdata = _get(burl, headers=gitea_headers)
                if s != 200:
                    return None
                branches = bdata if isinstance(bdata, list) else bdata.get("results", [])
                for b in branches:
                    bname = b.get("name", "")
                    # Branch name: feature/SANDBOX-NNN-staging-check-...
                    if "feature/" in bname and "staging-check" in bname:
                        return bname
                return None
            except Exception:
                return None

        branch_name = _poll(_check_branch, timeout=60, interval=3,
                             label="waiting for branch")
        ok = bool(branch_name)
        results.add("C9a Branch appears in orchestrator-sandbox", ok,
                    f"branch={branch_name or 'not found'}")

        # C9b — Verify analyst job was enqueued via staging /queue
        # NOTE: The orchestrator posts a "🔍 Analyst запущен" comment to Plane using
        # per-agent bot tokens (ORCH_PLANE_BOT_ANALYST). In staging, the sandbox
        # project was created after the bot accounts were provisioned, so the bots are
        # not yet members of the SANDBOX project → add_comment returns 403 Forbidden.
        # This is a known staging infrastructure limitation (not a pipeline bug).
        # We therefore verify pipeline success via /queue (recent jobs): the analyst
        # job is enqueued BEFORE the add_comment call, so its presence in the queue
        # confirms the pipeline ran through to job dispatch.
        print(_info("C9b: Checking staging job queue for analyst job (up to 30s)..."))
        print(_info("  (Plane comment check skipped: bot-tokens not added to SANDBOX project)"))

        def _check_queue():
            try:
                s, qdata = _get(f"{base}/queue")
                if s != 200:
                    return None
                recent = qdata.get("recent", [])
                for job in recent:
                    if (job.get("agent") == "analyst"
                            and job.get("repo") == "orchestrator-sandbox"
                            and issue_name in (job.get("task_content") or "")):
                        return job
                return None
            except Exception:
                return None

        analyst_job = _poll(_check_queue, timeout=30, interval=2,
                             label="waiting for analyst job in queue")
        ok = bool(analyst_job)
        detail = ""
        if analyst_job:
            detail = (f"job_id={analyst_job.get('id')}, "
                      f"status={analyst_job.get('status')}, "
                      f"agent={analyst_job.get('agent')}")
        results.add("C9b Analyst job enqueued in staging queue", ok, detail)

    finally:
        # C10 — CLEANUP (always runs)
        print(f"\n{_BOLD}[CLEANUP]{_RESET}")
        _cleanup(
            plane_base=plane_base,
            workspace=workspace,
            gitea_base=gitea_base,
            plane_headers=plane_headers,
            gitea_headers=gitea_headers,
            issue_id=issue_id,
            branch_name=branch_name,
            wh_body_bytes=wh_body_bytes,
        )


def _cleanup(plane_base, workspace, gitea_base, plane_headers, gitea_headers,
             issue_id, branch_name, wh_body_bytes=None):
    """Delete test branch in Gitea, test issue in Plane SANDBOX, and DB rows."""

    # Delete branch in Gitea
    if branch_name:
        try:
            burl = (f"{gitea_base}/api/v1/repos/admin/orchestrator-sandbox"
                    f"/branches/{urllib.parse.quote(branch_name, safe='')}")
            s = _delete(burl, headers=gitea_headers)
            if s in (200, 204, 404):
                print(_ok(f"CLEANUP: deleted branch {branch_name!r} (HTTP {s})"))
            else:
                print(_fail(f"CLEANUP: delete branch returned HTTP {s}"))
        except Exception as e:
            print(_fail(f"CLEANUP: delete branch error: {e}"))
    else:
        print(_info("CLEANUP: no branch to delete"))

    # Delete issue in Plane SANDBOX
    if issue_id:
        try:
            iurl = (f"{plane_base}/workspaces/{workspace}/projects/"
                    f"{SANDBOX_PROJECT_ID}/issues/{issue_id}/")
            s = _delete(iurl, headers=plane_headers)
            if s in (200, 204, 404):
                print(_ok(f"CLEANUP: deleted Plane issue {issue_id} (HTTP {s})"))
            else:
                print(_fail(f"CLEANUP: delete Plane issue returned HTTP {s}"))
        except Exception as e:
            print(_fail(f"CLEANUP: delete Plane issue error: {e}"))
    else:
        print(_info("CLEANUP: no issue to delete"))

    # Delete task + jobs from staging DB
    if issue_id:
        _cleanup_staging_jobs(issue_id)
        _cleanup_staging_db(issue_id)

    # Remove dedup entry so future re-runs with same body don't get "duplicate"
    if wh_body_bytes is not None:
        import hashlib as _hl
        dedup_id = "plane" + _hl.sha256(b"plane" + wh_body_bytes).hexdigest()
        _cleanup_dedup(issue_id, dedup_id)


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def _resolve_tolerance(cli_strict: bool) -> bool:
    """Resolve whether the infra-FAIL waiver is active (ORCH-061).

    Precedence: an explicit ``--strict`` CLI flag forces it OFF (for honest manual
    runs). Otherwise read ``settings.staging_infra_tolerance_enabled`` from the
    running instance's own config (same pattern as B6's src.* import inside the
    container). On ANY import/read error -> STRICT (False): we never waive when the
    config is unreadable (fail-safe), and we say so.
    """
    if cli_strict:
        print(_info("tolerance: DISABLED via --strict (honest run)"))
        return False
    try:
        from src.config import settings  # noqa: WPS433 - lazy, mirrors B6
        enabled = bool(settings.staging_infra_tolerance_enabled)
        print(_info(f"tolerance: staging_infra_tolerance_enabled={enabled}"))
        return enabled
    except Exception as e:
        print(_info(f"tolerance: config unavailable, defaulting to STRICT: {e}"))
        return False


def main():
    parser = argparse.ArgumentParser(
        description="Live staging-stand check suite (ORCH-33)"
    )
    parser.add_argument(
        "--base-url",
        default="http://localhost:8501",
        help="Base URL of the staging orchestrator (default: http://localhost:8501)",
    )
    parser.add_argument(
        "--mode",
        choices=["stub", "full-real"],
        default="stub",
        help=(
            "stub (default): check early pipeline artifacts only (branch+job), "
            "no LLM spend. "
            "full-real: also wait for the analyst agent (slow, costs credits)."
        ),
    )
    parser.add_argument(
        "--strict",
        action="store_true",
        help=(
            "ORCH-061: force strict suite — disable the sandbox-infra (C9a/C9b) "
            "FAIL waiver even if staging_infra_tolerance_enabled=True. Use for an "
            "honest 10/10 run once the sandbox bot accounts are provisioned."
        ),
    )
    args = parser.parse_args()

    base = args.base_url.rstrip("/")

    print(f"{_BOLD}{'='*60}{_RESET}")
    print(f"{_BOLD}  ORCH-33 Staging Check Suite{_RESET}")
    print(f"  base_url : {base}")
    print(f"  mode     : {args.mode}")
    print(f"  utc_time : {datetime.datetime.now(datetime.timezone.utc).isoformat()}")
    print(f"{_BOLD}{'='*60}{_RESET}")

    results = Results()

    block_a(base, results)
    block_b(results)
    block_c(base, results, args.mode)

    results.summary()

    # ORCH-061: the EXIT CODE (which drives the deployer's staging_status verdict)
    # comes from the infra-tolerant verdict, NOT a raw passed==total count. A run
    # whose only failures are known sandbox-infra checks (C9a/C9b) is waived to
    # exit 0 when tolerance is on; ANY real check failure still exits 1 (FR-4).
    infra_tolerant = _resolve_tolerance(args.strict)
    verdict = _verdict(results.categorized_items(), infra_tolerant)
    if verdict.waived:
        # FR-7 observability: make "green with an allowance" distinguishable from
        # an honest green in the logs / captured deployer output.
        print(f"{_YELLOW}{_BOLD}INFRA-WAIVED:{_RESET} "
              f"{', '.join(verdict.waived)} "
              f"(known sandbox-infra; real checks green)")
    print(f"{_BOLD}VERDICT:{_RESET} {verdict.status} "
          f"(exit {verdict.exit_code}) — {verdict.summary}")
    sys.exit(verdict.exit_code)


if __name__ == "__main__":
    main()
