#!/usr/bin/env python3
"""mugi-kanban: command-line client for the Mugi kanban backend.

Hits the local mugi_backend HTTP API; no in-process state. Useful from
shells, hooks, or another machine on a tunneled port.

Examples:

    # List ready tasks for the default profile.
    mugi_kanban.py list --status ready

    # Create a task.
    mugi_kanban.py create --title "Triage logs" \\
        --assignee alice --body "Look at /tmp/mugi.log"

    # Watch the live event stream.
    mugi_kanban.py watch

Auth + connection:

    MUGI_BACKEND_PORT=8321 (default)
    MUGI_BACKEND_HOST=127.0.0.1 (default)
    MUGI_AUTH_TOKEN=...  (sent as X-Auth-Token; required if backend auth is on)
"""
from __future__ import annotations

import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any


def _base_url() -> str:
    host = os.environ.get("MUGI_BACKEND_HOST", "127.0.0.1")
    port = os.environ.get("MUGI_BACKEND_PORT", "8321")
    return f"http://{host}:{port}"


def _auth_token() -> str | None:
    return os.environ.get("MUGI_AUTH_TOKEN") or None


def _request(
    method: str,
    path: str,
    *,
    body: dict | None = None,
    timeout: float = 10.0,
) -> Any:
    url = _base_url() + path
    data: bytes | None = None
    headers = {"Accept": "application/json"}
    token = _auth_token()
    if token:
        headers["X-Auth-Token"] = token
    if body is not None:
        data = json.dumps(body).encode("utf-8")
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8")
            return json.loads(raw) if raw else {}
    except urllib.error.HTTPError as exc:
        try:
            payload = json.loads(exc.read().decode("utf-8"))
        except Exception:
            payload = {"error": exc.reason}
        print(f"HTTP {exc.code}: {payload}", file=sys.stderr)
        sys.exit(2)
    except urllib.error.URLError as exc:
        print(f"connection failed: {exc}", file=sys.stderr)
        sys.exit(3)


def _print(obj: Any, args: argparse.Namespace) -> None:
    if getattr(args, "json", False):
        print(json.dumps(obj, indent=2, default=str))
    else:
        if isinstance(obj, list):
            for item in obj:
                _print_one(item)
        else:
            _print_one(obj)


def _print_one(obj: Any) -> None:
    if not isinstance(obj, dict):
        print(obj)
        return
    if "id" in obj and "status" in obj and "title" in obj:
        parents = obj.get("parent_ids") or ([obj["parent_id"]] if obj.get("parent_id") else [])
        parent_str = (",".join(parents) if parents else "-")
        print(
            f"{obj['id']:24}  {obj['status']:11}  "
            f"a={obj.get('assignee') or '-':10}  "
            f"p={obj.get('priority', '-'):>3}  "
            f"par={parent_str:24}  "
            f"{obj.get('title', '')[:60]}"
        )
    else:
        print(json.dumps(obj, default=str))


# ---------------------------------------------------------------------------
# subcommands
# ---------------------------------------------------------------------------

def cmd_list(args: argparse.Namespace) -> None:
    qs: dict[str, str] = {}
    if args.status:
        qs["status"] = args.status
    if args.assignee:
        qs["assignee"] = args.assignee
    if args.tenant:
        qs["tenant"] = args.tenant
    if args.limit:
        qs["limit"] = str(args.limit)
    suffix = ("?" + urllib.parse.urlencode(qs)) if qs else ""
    res = _request("GET", "/kanban/tasks" + suffix)
    tasks = res.get("tasks") if isinstance(res, dict) else res
    _print(tasks or [], args)


def cmd_search(args: argparse.Namespace) -> None:
    qs: dict[str, str] = {"q": args.query}
    if args.tenant:
        qs["tenant"] = args.tenant
    if args.limit:
        qs["limit"] = str(args.limit)
    if getattr(args, "semantic", False):
        qs["semantic"] = "true"
    suffix = "?" + urllib.parse.urlencode(qs)
    res = _request("GET", "/kanban/tasks/search" + suffix)
    tasks = res.get("tasks") if isinstance(res, dict) else res
    _print(tasks or [], args)


def cmd_show(args: argparse.Namespace) -> None:
    res = _request("GET", f"/kanban/tasks/{args.task_id}")
    _print(res, args)


def cmd_runs(args: argparse.Namespace) -> None:
    res = _request("GET", f"/kanban/tasks/{args.task_id}/runs")
    runs = res.get("runs") if isinstance(res, dict) else res
    enriched: list[dict[str, Any]] = []
    for run in (runs or []):
        if not isinstance(run, dict):
            continue
        row = dict(run)
        md = row.get("metadata")
        if isinstance(md, dict):
            if isinstance(md.get("failure_reason"), str) and md.get("failure_reason"):
                row["typed_reason"] = md.get("failure_reason")
            if isinstance(md.get("failure_class"), str) and md.get("failure_class"):
                row["failure_class"] = md.get("failure_class")
            elif row.get("typed_reason"):
                from utils.kanban_context import classify_failure_class

                row["failure_class"] = classify_failure_class(str(row["typed_reason"]))
            if isinstance(md.get("next_action"), str) and md.get("next_action"):
                row["next_action"] = md.get("next_action")
        enriched.append(row)
    _print(enriched, args)


def cmd_create(args: argparse.Namespace) -> None:
    body: dict[str, Any] = {
        "title": args.title,
        "body": args.body or "",
        "assignee": args.assignee,
    }
    if args.priority is not None:
        body["priority"] = args.priority
    if args.tenant:
        body["tenant"] = args.tenant
    if args.parent_id:
        body["parent_id"] = args.parent_id
    if args.parent_ids:
        body["parent_ids"] = args.parent_ids
    if args.tags:
        body["tags"] = args.tags
    if args.metadata:
        try:
            body["metadata"] = json.loads(args.metadata)
        except json.JSONDecodeError as exc:
            print(f"--metadata must be JSON: {exc}", file=sys.stderr)
            sys.exit(1)
    if getattr(args, "specifier_profile", None):
        body["specifier_profile"] = args.specifier_profile
    if getattr(args, "tool_names", None):
        names = [t.strip() for t in args.tool_names.split(",") if t.strip()]
        if names:
            meta = body.get("metadata") or {}
            meta["tool_names"] = names
            body["metadata"] = meta
    res = _request("POST", "/kanban/tasks", body=body)
    _print(res, args)


def cmd_cancel(args: argparse.Namespace) -> None:
    res = _request(
        "POST",
        f"/kanban/tasks/{args.task_id}/cancel",
        body={"cascade": bool(args.cascade)},
    )
    _print(res, args)


def cmd_block(args: argparse.Namespace) -> None:
    res = _request(
        "POST",
        f"/kanban/tasks/{args.task_id}/block",
        body={"reason": args.reason or "manual"},
    )
    _print(res, args)


def cmd_unblock(args: argparse.Namespace) -> None:
    res = _request("POST", f"/kanban/tasks/{args.task_id}/unblock")
    _print(res, args)


def cmd_reassign(args: argparse.Namespace) -> None:
    body: dict[str, Any] = {"assignee": args.assignee}
    if getattr(args, "force", False):
        body["force"] = True
    res = _request("PATCH", f"/kanban/tasks/{args.task_id}", body=body)
    _print(res, args)


def cmd_update(args: argparse.Namespace) -> None:
    body: dict[str, Any] = {}
    if args.status:
        body["status"] = args.status
    if args.priority is not None:
        body["priority"] = args.priority
    if args.parent_ids is not None:
        body["parent_ids"] = args.parent_ids
    if getattr(args, "force", False):
        body["force"] = True
    if not body:
        print("Nothing to update. Supply --status, --priority, or --parent-ids.", file=sys.stderr)
        sys.exit(1)
    res = _request("PATCH", f"/kanban/tasks/{args.task_id}", body=body)
    _print(res, args)


def cmd_delete(args: argparse.Namespace) -> None:
    res = _request("DELETE", f"/kanban/tasks/{args.task_id}")
    _print(res, args)


def cmd_clear(args: argparse.Namespace) -> None:
    body: dict[str, Any] = {"status": args.status}
    if args.assignee:
        body["assignee"] = args.assignee
    if getattr(args, "tenant", None):
        body["tenant"] = args.tenant
    res = _request("POST", "/kanban/tasks/clear", body=body)
    _print(res, args)


def cmd_subscribe(args: argparse.Namespace) -> None:
    body = {
        "channel": args.channel,
        "target": args.target,
        "fire_on": args.fire_on,
    }
    res = _request("POST", f"/kanban/tasks/{args.task_id}/subscribe", body=body)
    _print(res, args)


def cmd_unsubscribe(args: argparse.Namespace) -> None:
    res = _request("DELETE", f"/kanban/subscriptions/{args.subscription_id}")
    _print(res, args)


def cmd_nudge(args: argparse.Namespace) -> None:
    res = _request("POST", "/kanban/dispatcher/nudge")
    _print(res, args)


def cmd_health(args: argparse.Namespace) -> None:
    res = _request("GET", "/kanban/dispatcher/health")
    _print(res, args)


def cmd_clear_web_act(args: argparse.Namespace) -> None:
    """Remove web_runs artifacts for a kanban task or scope key (local storage)."""
    import pathlib

    repo_root = pathlib.Path(__file__).resolve().parents[1]
    if str(repo_root) not in sys.path:
        sys.path.insert(0, str(repo_root))
    from utils.web_act_retention import clear_web_act_scope

    target = (args.target or "").strip()
    if target.startswith("kt_"):
        scope_key = f"kanban_{target}"
    elif target.startswith("kanban_"):
        scope_key = target
    else:
        scope_key = target
    ok, msg = clear_web_act_scope(scope_key)
    if args.json:
        print(json.dumps({"ok": ok, "message": msg}))
    else:
        print(msg)
    if not ok:
        sys.exit(2)


def cmd_watch(args: argparse.Namespace) -> None:
    """Stream /kanban/stream as SSE. Reconnects forever with backoff."""
    backoff = 1.0
    while True:
        url = _base_url() + "/kanban/stream"
        headers = {"Accept": "text/event-stream"}
        token = _auth_token()
        if token:
            headers["X-Auth-Token"] = token
        req = urllib.request.Request(url, headers=headers)
        try:
            with urllib.request.urlopen(req, timeout=None) as resp:
                backoff = 1.0
                for raw_line in resp:
                    line = raw_line.decode("utf-8", errors="replace").rstrip()
                    if not line:
                        continue
                    if line.startswith(":"):
                        if args.heartbeats:
                            print(line)
                        continue
                    if line.startswith("data:"):
                        payload = line[5:].lstrip()
                        try:
                            evt = json.loads(payload)
                        except json.JSONDecodeError:
                            print(payload)
                            continue
                        if args.json:
                            print(json.dumps(evt, default=str))
                        else:
                            print(
                                f"[{evt.get('kanban_event', '?'):11}] "
                                f"task={evt.get('task_id', '-')} "
                                f"{evt.get('from_status', '?')} -> "
                                f"{evt.get('to_status', '?')} "
                                f"a={evt.get('assignee', '-')}"
                            )
                            sys.stdout.flush()
        except KeyboardInterrupt:
            return
        except Exception as exc:
            print(f"stream interrupted ({exc}); reconnecting in {backoff:.1f}s",
                  file=sys.stderr)
            time.sleep(backoff)
            backoff = min(backoff * 2, 30.0)


# ---------------------------------------------------------------------------
# argv parser
# ---------------------------------------------------------------------------

def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="mugi-kanban",
        description="Command-line client for the Mugi kanban backend.",
    )
    parser.add_argument(
        "--json", action="store_true",
        help="Emit raw JSON instead of human-formatted lines.",
    )
    sub = parser.add_subparsers(dest="cmd", required=True)

    p_list = sub.add_parser("list", help="List tasks (filter by status/assignee).")
    p_list.add_argument("--status", default=None)
    p_list.add_argument("--assignee", default=None)
    p_list.add_argument("--tenant", default=None)
    p_list.add_argument("--limit", type=int, default=None)
    p_list.set_defaults(func=cmd_list)

    p_search = sub.add_parser("search", help="Search tasks by keyword.")
    p_search.add_argument("query", help="Search query string.")
    p_search.add_argument("--tenant", default=None)
    p_search.add_argument("--limit", type=int, default=None)
    p_search.add_argument("--semantic", action="store_true", help="Enable semantic (vector) search.")
    p_search.set_defaults(func=cmd_search)

    p_show = sub.add_parser("show", help="Show one task.")
    p_show.add_argument("task_id")
    p_show.set_defaults(func=cmd_show)

    p_runs = sub.add_parser("runs", help="Show task run history.")
    p_runs.add_argument("task_id")
    p_runs.set_defaults(func=cmd_runs)

    p_create = sub.add_parser("create", help="Create a new task.")
    p_create.add_argument("--title", required=True)
    p_create.add_argument("--assignee", required=True)
    p_create.add_argument("--body", default="")
    p_create.add_argument("--priority", type=int, default=None)
    p_create.add_argument("--tenant", default=None)
    p_create.add_argument("--parent-id", dest="parent_id", default=None)
    p_create.add_argument(
        "--parent-ids", dest="parent_ids", nargs="*", default=None,
        help="One or more parent task ids for multi-parent dependencies.",
    )
    p_create.add_argument("--tag", dest="tags", action="append", default=None)
    p_create.add_argument("--metadata", default=None,
                          help="JSON object string of extra metadata.")
    p_create.add_argument("--specifier-profile", dest="specifier_profile", default=None,
                          help="Intended profile after triage (forces assignee=triage).")
    p_create.add_argument(
        "--tools", dest="tool_names", default=None,
        help="Comma-separated tool names for the worker (e.g. 'hyperframes_create_video,hyperframes_render').",
    )
    p_create.set_defaults(func=cmd_create)

    p_cancel = sub.add_parser("cancel", help="Cancel a task (cascades by default).")
    p_cancel.add_argument("task_id")
    p_cancel.add_argument("--no-cascade", dest="cascade", action="store_false")
    p_cancel.set_defaults(cascade=True, func=cmd_cancel)

    p_delete = sub.add_parser("delete", help="Delete a done/cancelled task permanently.")
    p_delete.add_argument("task_id")
    p_delete.set_defaults(func=cmd_delete)

    p_clear = sub.add_parser("clear", help="Bulk-delete all tasks with a terminal status.")
    p_clear.add_argument("--status", required=True, choices=["done", "cancelled"])
    p_clear.add_argument("--assignee", default=None)
    p_clear.add_argument("--tenant", default=None)
    p_clear.set_defaults(func=cmd_clear)

    p_update = sub.add_parser("update", help="Update task fields (status, priority, parent-ids).")
    p_update.add_argument("task_id")
    p_update.add_argument("--status", default=None, help="New status (operator-allowed transitions only).")
    p_update.add_argument("--priority", type=int, default=None, help="New priority (1-9).")
    p_update.add_argument("--parent-ids", dest="parent_ids", nargs="*", default=None,
                          help="Replace parent set (space-separated task ids, or empty for none).")
    p_update.add_argument("--force", action="store_true")
    p_update.set_defaults(func=cmd_update)

    p_reassign = sub.add_parser("reassign", help="Reassign a task to a different profile.")
    p_reassign.add_argument("task_id")
    p_reassign.add_argument("--assignee", required=True, help="New assignee profile.")
    p_reassign.add_argument("--force", action="store_true", help="Force reassign even if in_progress.")
    p_reassign.set_defaults(func=cmd_reassign)

    p_block = sub.add_parser("block", help="Mark a task blocked.")
    p_block.add_argument("task_id")
    p_block.add_argument("--reason", default="manual")
    p_block.set_defaults(func=cmd_block)

    p_unblock = sub.add_parser("unblock", help="Move blocked task back to ready.")
    p_unblock.add_argument("task_id")
    p_unblock.set_defaults(func=cmd_unblock)

    p_sub = sub.add_parser("subscribe", help="Subscribe to terminal lifecycle events on a task.")
    p_sub.add_argument("task_id")
    p_sub.add_argument("--channel", choices=["thread", "notification", "webhook", "discord", "slack"],
                       default="notification")
    p_sub.add_argument("--target", required=True)
    p_sub.add_argument(
        "--fire-on", dest="fire_on", nargs="+", default=["gave_up", "done"],
        choices=["gave_up", "done"],
    )
    p_sub.set_defaults(func=cmd_subscribe)

    p_uns = sub.add_parser("unsubscribe", help="Remove a subscription.")
    p_uns.add_argument("subscription_id")
    p_uns.set_defaults(func=cmd_unsubscribe)

    p_nudge = sub.add_parser("nudge", help="Wake the dispatcher loop early.")
    p_nudge.set_defaults(func=cmd_nudge)

    p_health = sub.add_parser("health", help="Show dispatcher + storage health.")
    p_health.set_defaults(func=cmd_health)

    p_clear_wa = sub.add_parser(
        "clear-web-act",
        help="Delete web_runs artifacts for a task id (kt_…) or scope key (kanban_… / thread id).",
    )
    p_clear_wa.add_argument(
        "target",
        help="Task id (kt_abc) or scope key (kanban_kt_abc or chat thread id).",
    )
    p_clear_wa.set_defaults(func=cmd_clear_web_act)

    p_watch = sub.add_parser("watch", help="Stream live kanban events (SSE).")
    p_watch.add_argument("--heartbeats", action="store_true",
                         help="Print SSE heartbeat comments too.")
    p_watch.set_defaults(func=cmd_watch)

    return parser


def main(argv: list[str] | None = None) -> int:
    parser = _build_parser()
    args = parser.parse_args(argv)
    args.func(args)
    return 0


if __name__ == "__main__":
    sys.exit(main())
