#!/usr/bin/env python3
"""woltspace — in-container control CLI (thin client over the localhost API).

This is the CONTROL plane. Lifecycle (start/stop/rebuild/backup) lives in the
host `woltspace` bash launcher — those need docker and only run on the host.
Everything here is just HTTP to the API, so it works identically inside the
container and (later) from anywhere that can reach the API.

Grammar: `woltspace <noun> <verb> [args] [--flags]` (docker/git style).

Phase 1 nouns:
  session list [--wolt X] [--alive] [--json]
  session send <wolt|session> "message" [--from <wolt>]
  session spawn <wolt> ["seed prompt"] [--from <wolt>]

A wolt self-identifies as the sender via $WOLT_NAME / $WOLT_SESSION (exported
into every wolt session). A human posts with --from (e.g. --from jerpint).
"""
import argparse
import json
import os
import re
import shutil
import sys
import urllib.request
import urllib.error

API = os.environ.get("WOLTSPACE_API", "http://localhost:7777")
# Session slugs end in "-<6 hex>" (see SESSION_NOUNS naming). Anything else is
# treated as a wolt name and resolved to that wolt's active session by the API.
_SESSION_RE = re.compile(r"-[0-9a-f]{6}$")


def _req(method, path, body=None):
    url = f"{API}{path}"
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method)
    if data is not None:
        req.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            return r.status, json.loads(r.read().decode() or "{}")
    except urllib.error.HTTPError as e:
        try:
            return e.code, json.loads(e.read().decode() or "{}")
        except Exception:
            return e.code, {"error": str(e)}
    except urllib.error.URLError as e:
        print(f"cannot reach woltspace API at {API}: {e.reason}", file=sys.stderr)
        sys.exit(2)


def _print_sessions(sessions, *, as_json=False):
    if as_json:
        print(json.dumps(sessions, indent=2))
        return
    if not sessions:
        print("no sessions.")
        return
    print(f"{'SESSION':<34} {'WOLT':<12} {'ENGINE':<24} {'POLICY':<8} {'STATE':<9} WORKDIR")
    for s in sessions:
        policy = s.get("execution_policy") or {}
        policy_name = policy.get("mode", "auto") if isinstance(policy, dict) else str(policy)
        state = "running" if s.get("alive") else s.get("status", "offline")
        workdir = s.get("workdir") or s.get("dir", "")
        engine = "/".join(p for p in (s.get("harness", ""), s.get("model", "")) if p)
        print(f"{s.get('name',''):<34} {s.get('wolt_id') or s.get('wolt',''):<12} "
              f"{engine:<24} {policy_name:<8} {state:<9} {workdir}")


def cmd_session_list(args):
    _, sessions = _req("GET", "/sessions")
    if isinstance(sessions, dict):
        sessions = sessions.get("sessions", [])
    if args.wolt:
        sessions = [s for s in sessions if s.get("wolt") == args.wolt]
    if args.alive:
        sessions = [s for s in sessions if s.get("alive")]
    _print_sessions(sessions, as_json=args.json)


def cmd_status(args):
    _, sessions = _req("GET", "/sessions")
    if isinstance(sessions, dict):
        sessions = sessions.get("sessions", [])
    _print_sessions(sessions, as_json=args.json)


def cmd_session_send(args):
    from_wolt = args.from_ or os.environ.get("WOLT_NAME", "")
    from_session = os.environ.get("WOLT_SESSION", "") if not args.from_ else "human"
    payload = {"text": args.message, "from_wolt": from_wolt, "from_session": from_session}
    target = args.target
    if _SESSION_RE.search(target):
        path = f"/sessions/{target}/message"
    else:
        path = f"/wolts/{target}/message"
    status, result = _req("POST", path, payload)
    if args.json:
        print(json.dumps(result, indent=2))
    elif result.get("ok"):
        dest = result.get("session", target)
        print(f"delivered → {dest}" + (f" ({result['harness']})" if result.get("harness") else ""))
    else:
        print(f"not delivered ({result.get('status', status)}): "
              f"{result.get('error', '')}".rstrip(), file=sys.stderr)
        sys.exit(1)


# A seed prompt is a briefing, not a payload — big work orders belong in files
# the child session reads. Keeps the tmux paste path snappy and the intent clear.
MAX_SPAWN_PROMPT = 4000


def cmd_session_spawn(args):
    if len(args.prompt) > MAX_SPAWN_PROMPT:
        print(f"seed prompt too large ({len(args.prompt)} chars, max {MAX_SPAWN_PROMPT}). "
              f"Put the work order in a file the new session can read and pass a short pointer.",
              file=sys.stderr)
        sys.exit(1)
    from_wolt = args.from_ or os.environ.get("WOLT_NAME", "")
    # A human spawner (--from) has no session to reply into — omit it so the
    # child doesn't get a bogus `session send <name>` reply line.
    from_session = "" if args.from_ else os.environ.get("WOLT_SESSION", "")
    payload = {"wolt": args.wolt, "prompt": args.prompt,
               "from_wolt": from_wolt, "from_session": from_session}
    if args.workdir:
        payload["workdir"] = args.workdir
    if args.auto:
        payload["execution_policy"] = "auto"
    status, result = _req("POST", "/sessions/new/lodge", payload)
    if args.json:
        print(json.dumps(result, indent=2))
        if status != 200 or not result.get("name"):
            sys.exit(1)
        return
    if status == 200 and result.get("name"):
        print(f"SESSION={result['name']}")
        print(f"URL={result.get('url', '')}")
        return
    detail = result.get("detail") or result.get("error") or f"HTTP {status}"
    print(f"spawn failed: {detail}", file=sys.stderr)
    if status == 404:
        _, wolts = _req("GET", "/wolts")
        names = sorted(w.get("name", "") for w in wolts if w.get("name")) \
            if isinstance(wolts, list) else []
        if names:
            print("valid wolts: " + ", ".join(names), file=sys.stderr)
    sys.exit(1)


def cmd_auto_check(args):
    _, result = _req("POST", "/auto-grants/check", {
        "wolt_id": args.wolt, "workdir": args.workdir,
    })
    if args.json:
        print(json.dumps(result, indent=2))
    else:
        target = result.get("target", {}).get("canonical_workdir", args.workdir)
        print(f"{'approved' if result.get('approved') else 'not approved'}: {args.wolt} · {target}")
    if not result.get("approved"):
        sys.exit(1)


def cmd_auto_grant(args):
    canonical = os.path.realpath(os.path.expanduser(args.workdir))
    if not args.yes:
        print(f"refusing to grant Auto without --yes for exact directory: {canonical}", file=sys.stderr)
        sys.exit(1)
    status, result = _req("POST", "/auto-grants/grant", {
        "wolt_id": args.wolt, "workdir": args.workdir, "confirm": canonical,
    })
    if status != 200:
        print(result.get("error", f"HTTP {status}"), file=sys.stderr)
        sys.exit(1)
    print(json.dumps(result, indent=2) if args.json else
          f"Auto approved: {args.wolt} · {result['grant']['canonical_workdir']}")


def cmd_auto_revoke(args):
    status, result = _req("POST", "/auto-grants/revoke", {
        "wolt_id": args.wolt, "workdir": args.workdir,
    })
    if status != 200:
        print(result.get("error", f"HTTP {status}"), file=sys.stderr)
        sys.exit(1)
    print(json.dumps(result, indent=2) if args.json else
          f"Auto {'revoked' if result.get('revoked') else 'was not granted'}: "
          f"{args.wolt} · {result['target']['canonical_workdir']}")


LIFECYCLE = {"start", "stop", "rebuild", "backup", "shell", "chat", "logs", "init", "update"}


def main():
    p = argparse.ArgumentParser(prog="woltspace", description="woltspace control CLI")
    sub = p.add_subparsers(dest="noun")

    sess = sub.add_parser("session", help="sessions (conversations)")
    ssub = sess.add_subparsers(dest="verb")

    sl = ssub.add_parser("list", help="list sessions")
    sl.add_argument("--wolt", help="filter by wolt")
    sl.add_argument("--alive", action="store_true", help="only live sessions")
    sl.add_argument("--json", action="store_true", help="raw JSON output")
    sl.set_defaults(func=cmd_session_list)

    ss = ssub.add_parser("send", help="message a wolt or session")
    ss.add_argument("target", help="wolt name (resolves to active session) or a session id")
    ss.add_argument("message", help="the message text")
    ss.add_argument("--from", dest="from_", default="",
                    help="sender identity (default: $WOLT_NAME); use e.g. --from jerpint as a human")
    ss.add_argument("--json", action="store_true", help="raw JSON output")
    ss.set_defaults(func=cmd_session_send)

    sp = ssub.add_parser("spawn", help="spawn a new session for a wolt")
    sp.add_argument("wolt", help="wolt name to spawn a session for")
    sp.add_argument("prompt", nargs="?", default="",
                    help="seed prompt for the new session (a briefing, not a payload)")
    sp.add_argument("--from", dest="from_", default="",
                    help="spawner identity (default: $WOLT_NAME); use e.g. --from jerpint as a human")
    sp.add_argument("--json", action="store_true", help="raw JSON output")
    sp.add_argument("--workdir", default="",
                    help="existing working directory (native runtime)")
    sp.add_argument("--auto", action="store_true",
                    help="request Auto policy (host requires an exact-path grant)")
    sp.set_defaults(func=cmd_session_spawn)

    status = sub.add_parser("status", help="show sessions with workdir and policy")
    status.add_argument("--json", action="store_true", help="raw JSON output")
    status.set_defaults(func=cmd_status)

    auto = sub.add_parser("auto", help="manage repository-scoped Auto consent")
    asub = auto.add_subparsers(dest="verb")
    for verb, func in (("check", cmd_auto_check), ("grant", cmd_auto_grant),
                       ("revoke", cmd_auto_revoke)):
        ap = asub.add_parser(verb)
        ap.add_argument("wolt")
        ap.add_argument("workdir")
        ap.add_argument("--json", action="store_true")
        if verb == "grant":
            ap.add_argument("--yes", action="store_true",
                            help="confirm Auto for this exact canonical directory")
        ap.set_defaults(func=func)

    argv = sys.argv[1:]
    # tui: exec the terminal cockpit (same app the host launcher runs; it
    # auto-detects in-container mode - native tmux attach, localhost API).
    if argv and argv[0] == "tui":
        # `woltspace-tui`, the bin of the @woltspace/tui npm package the image
        # installs (and the same artifact `woltspace tui` resolves natively).
        tui = shutil.which("woltspace-tui")
        if not tui:
            print("woltspace-tui is not installed in this image - "
                  "needs a rebuild, or `npm install -g @woltspace/tui`",
                  file=sys.stderr)
            sys.exit(1)
        os.execvp(tui, [tui, *argv[1:]])

    # Friendly hint: lifecycle verbs belong to the host launcher, not in-container.
    if argv and argv[0] in LIFECYCLE:
        print(f"'{argv[0]}' is a host lifecycle command — run it on the host "
              f"where docker lives, not inside the container.", file=sys.stderr)
        sys.exit(2)

    args = p.parse_args(argv)
    if not getattr(args, "func", None):
        p.print_help()
        sys.exit(1)
    args.func(args)


if __name__ == "__main__":
    main()
