#!/usr/bin/env python3
"""End-to-end kanban smoke harness — exercise the dispatcher seam headlessly.

The bugs we keep chasing live at the dispatcher seam (claim -> worker ->
completion contract -> verify -> storage). This harness drives that whole seam
in-process so you do NOT have to restart the SwiftUI app or read MCP traces to
reproduce or confirm a fix.

Two modes:

  --scripted  (default) Fully isolated temp board + a *canned* worker output.
              Deterministic, no model, no GUI, runs in milliseconds. Drives the
              REAL dispatcher (``tick_once``) so verification, salvage, marker
              checks, AND-promotion and the completion contract are all real —
              only the model is stubbed. This reproduces the scaffolding class
              of bugs (metadata size, task_id coercion, loop salvage, marker
              verification) without touching your real board.

  --live      Real worker + real model against your REAL board. Reuses the
              app's already-running MLX server (it NEVER starts a second model —
              double-loading MLX is what helped hard-crash the machine). Seeds
              one task under a unique tenant and drives ticks until it is
              terminal. Requires the app / local MLX server to be up.

Examples:
  # Fast: a clean worker handoff should land "done"
  python scripts/kanban_e2e_smoke.py --output-file /tmp/good.txt

  # Fast: a worker that omits a required marker must NOT land done
  python scripts/kanban_e2e_smoke.py \
      --markers "documented peer discovery, sync, and security requirements" \
      --output 'Did research.

```kanban-result
{"summary": "Researched the mesh; no public spec exists for it at all.", "metadata": {}}
```'

  # Live (app must be running):
  python scripts/kanban_e2e_smoke.py --live --title "Smoke research" \
      --body "Summarize three durability tactics for local-model agents."

The scripted runner (``run_scripted_task``) is importable and is reused by
``tests/test_kanban_e2e_dispatcher.py`` so the CLI and the regression suite
share one code path.
"""
from __future__ import annotations

import argparse
import contextlib
import json
import os
import shutil
import sys
import tempfile
import time
import uuid
from typing import Any, Iterator
from unittest.mock import patch

# Ensure project root on path when run as a script.
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_PROJECT_ROOT = os.path.dirname(_SCRIPT_DIR)
if _PROJECT_ROOT not in sys.path:
    sys.path.insert(0, _PROJECT_ROOT)

_TERMINAL_STATES = {"done", "blocked", "gave_up", "cancelled"}


def _make_subagent_result(
    *, success: bool = True, output: str = "", error: str | None = None, timed_out: bool = False
) -> object:
    """Build a SubagentResult-shaped object (imported lazily to avoid heavy deps at load)."""
    from subagent_runner import SubagentResult

    return SubagentResult(success=success, output=output, error=error, timed_out=timed_out)


def _drain_workers(kd: Any, timeout_s: float = 10.0) -> None:
    """Block until no kanban worker threads are in flight (or timeout)."""
    deadline = time.monotonic() + max(0.0, timeout_s)
    while time.monotonic() < deadline:
        try:
            snap = kd.dispatcher_health()
        except Exception:
            return
        if int(snap.get("dispatcher", {}).get("inflight_workers", 0) or 0) == 0:
            return
        time.sleep(0.05)


def summarize_task(task_id: str) -> dict[str, Any]:
    """Collect the terminal-facing state of a task + its latest run."""
    import kanban_storage as ks

    task = ks.get_task(task_id) or {}
    runs = ks.list_runs(task_id, limit=1)
    latest = runs[0] if runs else {}
    md = latest.get("metadata") if isinstance(latest.get("metadata"), dict) else {}
    return {
        "task_id": task_id,
        "status": task.get("status"),
        "failure_count": task.get("failure_count"),
        "run_outcome": latest.get("outcome"),
        "run_summary": latest.get("summary"),
        "typed_reason": latest.get("typed_reason") or md.get("failure_reason"),
        "handoff_source": md.get("handoff_source"),
        "blockers": md.get("blockers"),
        "error": latest.get("error"),
        "tail_excerpt": (latest.get("metadata") or {}).get("tail_excerpt")
        if isinstance(latest.get("metadata"), dict)
        else None,
    }


def _drive_to_terminal(task_id: str, *, max_ticks: int, drain_timeout_s: float) -> dict[str, Any]:
    """Tick the dispatcher until *task_id* reaches a terminal state or ticks run out."""
    import kanban_storage as ks
    import utils.kanban_dispatcher as kd

    for _ in range(max(1, max_ticks)):
        kd.tick_once()              # claim ready work + spawn worker threads
        _drain_workers(kd, drain_timeout_s)
        kd.tick_once()              # record completion / AND-promote children
        task = ks.get_task(task_id) or {}
        if str(task.get("status") or "") in _TERMINAL_STATES:
            break
    return summarize_task(task_id)


def _build_acceptance(markers: list[str] | None, min_summary_chars: int) -> dict[str, Any]:
    acceptance: dict[str, Any] = {
        "min_summary_chars": int(min_summary_chars),
        "reject_deferred_prose": True,
    }
    if markers:
        acceptance["require_transcript_markers"] = list(markers)
    return acceptance


def run_scripted_task(
    *,
    worker_output: str,
    title: str = "Smoke task",
    body: str = "Do the smoke work.",
    assignee: str = "general",
    task_kind: str = "",
    markers: list[str] | None = None,
    min_summary_chars: int = 48,
    success: bool = True,
    timed_out: bool = False,
    error: str | None = None,
    extra_metadata: dict[str, Any] | None = None,
    max_ticks: int = 8,
    drain_timeout_s: float = 10.0,
) -> dict[str, Any]:
    """Seed one task, run it through the dispatcher with a *canned* worker output,
    and return the terminal outcome.

    Assumes kanban storage is already isolated/available — the CLI wraps this in
    ``isolated_kanban_home()``; tests call it inside ``KanbanTestCase`` (which
    provides its own isolation). Only the model is stubbed; verification,
    salvage, and the completion contract are exercised for real.
    """
    import kanban_storage as ks

    metadata: dict[str, Any] = {"acceptance": _build_acceptance(markers, min_summary_chars)}
    if task_kind:
        metadata["task_kind"] = task_kind
    if extra_metadata:
        metadata.update(extra_metadata)

    task = ks.create_task(title=title, body=body, assignee=assignee, metadata=metadata)
    task_id = task["id"]

    result = _make_subagent_result(
        success=success, output=worker_output, error=error, timed_out=timed_out
    )
    with patch("subagent_runner.run_subagent_structured", return_value=result):
        return _drive_to_terminal(
            task_id, max_ticks=max_ticks, drain_timeout_s=drain_timeout_s
        )


@contextlib.contextmanager
def isolated_kanban_home() -> Iterator[str]:
    """Stand up a throwaway encrypted kanban board in a temp dir.

    Mirrors ``tests/_kanban_test_base.KanbanTestCase`` so the scripted CLI never
    touches the operator's real board. Yields the temp dir.
    """
    try:
        from cryptography.fernet import Fernet
    except ImportError as exc:  # pragma: no cover - environment guard
        raise SystemExit("cryptography is required for the scripted harness") from exc

    import sys as _sys
    import types as _types

    # patch('keyring.*') needs an importable module even on minimal venvs.
    if "keyring" not in _sys.modules:
        kr = _types.ModuleType("keyring")
        kr.get_password = lambda *a, **k: None  # type: ignore[attr-defined]
        kr.set_password = lambda *a, **k: None  # type: ignore[attr-defined]
        _sys.modules["keyring"] = kr

    import chat_storage as cs

    cs._fernet = None
    cs._key_just_created = False
    cs._migrations_done = False
    cs._schema_initialized_paths.clear()
    with contextlib.suppress(Exception):
        import kanban_storage as ks

        ks._migrations_applied = False

    temp_dir = tempfile.mkdtemp(prefix="mugi_kanban_e2e_")
    db_path = os.path.join(temp_dir, "chats.db")
    workspace = os.path.join(temp_dir, "workspace")
    mugi_dir = os.path.join(temp_dir, ".mugi")
    os.makedirs(workspace, exist_ok=True)
    os.makedirs(mugi_dir, exist_ok=True)

    store: dict = {("Mugi", "chat_encryption_key"): Fernet.generate_key().decode("ascii")}

    import core.config as _core_config

    _real_get_kanban_config = _core_config.get_kanban_config

    def _enabled_kanban_config() -> dict[str, Any]:
        cfg = dict(_real_get_kanban_config())
        cfg["enabled"] = True
        return cfg

    patches = [
        patch("chat_storage.DB_PATH", db_path),
        patch("keyring.get_password", lambda service, account: store.get((service, account))),
        patch("keyring.set_password", lambda service, account, pw: store.__setitem__((service, account), pw)),
        patch("core.get_workspace_path", return_value=workspace),
        patch("persistence.MUGI_DIR", mugi_dir),
        patch("core.config.get_kanban_config", _enabled_kanban_config),
    ]
    for p in patches:
        p.start()
    with contextlib.suppress(Exception):
        from utils.kanban_activity import reset_kanban_create_rate

        reset_kanban_create_rate()
    with contextlib.suppress(Exception):
        import utils.kanban_events as ke

        ke._subscribers.clear()
        ke._event_sink = None
    with contextlib.suppress(Exception):
        import utils.kanban_dispatcher as kd

        kd._singleton = None
    try:
        yield temp_dir
    finally:
        with contextlib.suppress(Exception):
            import utils.kanban_dispatcher as kd

            if kd._singleton is not None:
                kd.stop_dispatcher()
                kd._singleton = None
        for p in patches:
            with contextlib.suppress(Exception):
                p.stop()
        shutil.rmtree(temp_dir, ignore_errors=True)


def run_live_task(
    *,
    title: str,
    body: str,
    assignee: str = "general",
    task_kind: str = "",
    markers: list[str] | None = None,
    min_summary_chars: int = 48,
    max_ticks: int = 12,
    drain_timeout_s: float = 900.0,
) -> dict[str, Any]:
    """Seed ONE task on the REAL board (unique tenant) and run it through the
    real worker + model, reusing the already-running MLX server.

    WARNING: this runs against your live board. While ticking, the dispatcher
    may also claim other ready tasks. Run it when the board is otherwise idle,
    with the app (and its MLX server) up. Never starts a second MLX model.
    """
    import kanban_storage as ks

    tenant = f"e2e_live_{uuid.uuid4().hex[:8]}"
    metadata: dict[str, Any] = {"acceptance": _build_acceptance(markers, min_summary_chars)}
    if task_kind:
        metadata["task_kind"] = task_kind
    task = ks.create_task(
        title=title, body=body, assignee=assignee, tenant=tenant, metadata=metadata
    )
    task_id = task["id"]
    print(f"[live] seeded {task_id} in tenant {tenant}; driving real worker…", file=sys.stderr)
    out = _drive_to_terminal(task_id, max_ticks=max_ticks, drain_timeout_s=drain_timeout_s)
    out["tenant"] = tenant
    return out


def _read_output(args: argparse.Namespace) -> str:
    if args.output_file:
        with open(args.output_file, "r", encoding="utf-8") as fh:
            return fh.read()
    if args.output:
        return args.output
    # Default: a clean, verifiable success handoff.
    summary = "Completed the requested smoke work with verified deliverable evidence documented."
    if args.markers:
        summary = summary + " " + " ".join(args.markers)
    return "Did the work.\n\n```kanban-result\n" + json.dumps({"summary": summary, "metadata": {}}) + "\n```\n"


def main() -> int:
    parser = argparse.ArgumentParser(description="Headless end-to-end kanban dispatcher smoke harness.")
    parser.add_argument("--live", action="store_true", help="Run the real worker+model against the live board.")
    parser.add_argument("--title", default="Smoke task")
    parser.add_argument("--body", default="Do the smoke work.")
    parser.add_argument("--assignee", default="general")
    parser.add_argument("--task-kind", default="", dest="task_kind")
    parser.add_argument(
        "--markers",
        action="append",
        default=[],
        help="Required transcript marker (repeatable). Verifier checks each as a literal substring.",
    )
    parser.add_argument("--min-summary-chars", type=int, default=48, dest="min_summary_chars")
    parser.add_argument("--output", default="", help="Scripted worker output (final assistant message).")
    parser.add_argument("--output-file", default="", dest="output_file", help="Read scripted worker output from a file.")
    parser.add_argument("--worker-failed", action="store_true", help="Scripted: mark the worker run as failed.")
    parser.add_argument("--worker-timed-out", action="store_true", help="Scripted: mark the worker run as timed out.")
    parser.add_argument("--max-ticks", type=int, default=8, dest="max_ticks")
    args = parser.parse_args()

    if args.live:
        print(
            "[live] WARNING: runs the real model against your live board and may claim other "
            "ready tasks. Ensure the app/MLX is up and the board is otherwise idle.",
            file=sys.stderr,
        )
        result = run_live_task(
            title=args.title,
            body=args.body,
            assignee=args.assignee,
            task_kind=args.task_kind,
            markers=args.markers or None,
            min_summary_chars=args.min_summary_chars,
            max_ticks=args.max_ticks,
        )
    else:
        worker_output = _read_output(args)
        with isolated_kanban_home():
            result = run_scripted_task(
                worker_output=worker_output,
                title=args.title,
                body=args.body,
                assignee=args.assignee,
                task_kind=args.task_kind,
                markers=args.markers or None,
                min_summary_chars=args.min_summary_chars,
                success=not args.worker_failed,
                timed_out=args.worker_timed_out,
                max_ticks=args.max_ticks,
            )

    print(json.dumps(result, indent=2, default=str))
    # Exit non-zero when the task did not complete cleanly, so CI / shell loops can gate on it.
    return 0 if result.get("status") == "done" else 1


if __name__ == "__main__":
    raise SystemExit(main())
