#!/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> [--from <wolt>]  # message on stdin
  session spawn <wolt> [--from <wolt>]  # optional seed prompt on stdin

A wolt self-identifies as the sender via $WOLTSPACE_WOLT_NAME /
$WOLTSPACE_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
from pathlib import Path

# The env namespace helper, resolved beside this script so the dev clone drives
# its own copy. Nothing else here needs the runtime tree.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
from env_compat import get_env  # noqa: E402
from notify_prompt import (  # noqa: E402
    MessageInputError,
    read_message_input,
    session_send_argv,
    session_spawn_argv,
)

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):
    try:
        command = session_send_argv(
            args.target, from_wolt=args.from_, as_json=args.json
        )
    except ValueError as exc:
        print(f"woltspace: {exc}", file=sys.stderr)
        raise SystemExit(2)
    try:
        message = read_message_input(
            command,
            args.message,
            sys.stdin,
            prefix="WOLTSPACE_IWCL",
        )
    except MessageInputError as exc:
        print(str(exc), file=sys.stderr, end="")
        raise SystemExit(2)
    from_wolt = args.from_ or get_env("WOLTSPACE_WOLT_NAME", "")
    from_session = get_env("WOLTSPACE_WOLT_SESSION", "") if not args.from_ else "human"
    payload = {"text": 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):
    try:
        command = session_spawn_argv(
            args.wolt,
            from_wolt=args.from_,
            as_json=args.json,
            workdir=args.workdir,
            auto=args.auto,
        )
        prompt = read_message_input(
            command,
            args.prompt,
            sys.stdin,
            prefix="WOLTSPACE_IWCL",
            allow_empty=True,
        )
    except (MessageInputError, ValueError) as exc:
        end = "" if isinstance(exc, MessageInputError) else "\n"
        print(str(exc), file=sys.stderr, end=end)
        raise SystemExit(2)
    if len(prompt) > MAX_SPAWN_PROMPT:
        print(f"seed prompt too large ({len(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 get_env("WOLTSPACE_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 get_env("WOLTSPACE_WOLT_SESSION", "")
    payload = {"wolt": args.wolt, "prompt": 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"}

# The nouns this client actually serves over HTTP. Everything else belongs to
# the real CLI — `doctor`, `paths`, `serve`, `restore`, and the whole lifecycle
# set. In the container "elsewhere" means the host; natively it means the python
# CLI sitting a few directories up from this script.
LOCAL_NOUNS = {"session", "status", "auto", "tui"}


def _running_in_container():
    """Whether this process is inside the woltspace image.

    Not answerable from this script's own path: the image pins the wheel bundle
    at /workspace/woltspace with a symlink, so `__file__` resolves into
    `site-packages/woltspace/_bundle/container/bin` in *both* worlds. The
    control plane stamps WOLTSPACE_ISOLATION into every session and connector
    environment, and the image's fixed mount points answer for the bare
    `docker exec` shells that carry no environment at all.
    """
    isolation = os.environ.get("WOLTSPACE_ISOLATION", "").strip().lower()
    if isolation == "external":
        return True
    if isolation == "host":
        return False
    return os.path.isdir("/workspace/woltspace") and os.path.isdir("/workspace/wolts")


def _is_python_program(path):
    """Whether a file is a python entry point rather than a shell launcher.

    The container-era bash launcher is also called `woltspace` and is also on
    plenty of PATHs. Handing it our arguments is the whole accident this guard
    exists to prevent, so a candidate has to look like the console script the
    wheel installs.
    """
    try:
        with open(path, "rb") as handle:
            return b"python" in handle.readline()
    except OSError:
        return False


def _native_cli():
    """Argv prefix for the native CLI installed beside this bundle, or None.

    Preferred answer is arithmetic, not search: this script lives at
    `<venv>/lib/pythonX.Y/site-packages/woltspace/_bundle/container/bin/woltspace`,
    so the console script is `<venv>/bin/woltspace` — the exact same version,
    with no PATH guessing and no chance of picking up a different install.
    """
    here = os.path.realpath(__file__)
    bin_dir = os.path.dirname(here)
    bundle = os.path.dirname(os.path.dirname(bin_dir))
    site_packages = os.path.dirname(os.path.dirname(bundle))
    if os.path.basename(bundle) == "_bundle" and os.path.basename(site_packages) == "site-packages":
        # <venv>/lib/pythonX.Y/site-packages -> <venv>
        venv = os.path.dirname(os.path.dirname(os.path.dirname(site_packages)))
        script = os.path.join(venv, "bin", "woltspace")
        if os.access(script, os.X_OK) and os.path.realpath(script) != here:
            return [script]
        for name in ("python", "python3"):
            interpreter = os.path.join(venv, "bin", name)
            if os.access(interpreter, os.X_OK):
                return [interpreter, "-m", "woltspace"]
    return _native_cli_on_path(here)


def _native_cli_on_path(here):
    """Fallback for installs that are not venv-shaped (pip --user, a checkout).

    Skips this script, any other `container/bin/woltspace`, and anything that
    is not a python program — which is how the bash docker launcher gets
    excluded even when it sits first on PATH.
    """
    for entry in os.environ.get("PATH", "").split(os.pathsep):
        if not entry:
            continue
        candidate = os.path.join(entry, "woltspace")
        if os.path.isdir(candidate) or not os.access(candidate, os.X_OK):
            continue
        real = os.path.realpath(candidate)
        if real == here:
            continue
        parent = os.path.dirname(real)
        if os.path.basename(parent) == "bin" and \
                os.path.basename(os.path.dirname(parent)) == "container":
            continue
        if _is_python_program(real):
            return [candidate]
    return None


def _delegate(argv):
    """Hand a verb we do not serve to whoever actually owns it.

    In the container that is the host, and the message is the one this client
    has always printed. Natively it is the python CLI, and we *exec* it: the
    old message ("run it on the host where docker lives") is not just useless
    on a native colony, it is the instruction that boots a second one.
    """
    verb = argv[0]
    if _running_in_container():
        if verb in LIFECYCLE:
            print(f"'{verb}' is a host lifecycle command — run it on the host "
                  f"where docker lives, not inside the container.", file=sys.stderr)
            sys.exit(2)
        return
    cli = _native_cli()
    if cli:
        os.execv(cli[0], [*cli, *argv])
    print(
        f"'{verb}' belongs to the native woltspace CLI, not to this bundled "
        f"control client ({os.path.realpath(__file__)}), which is on your PATH "
        f"so that `notify` and `push-view` resolve inside sessions.\n"
        f"The native CLI was not found beside this bundle. Install it with "
        f"`uv tool install woltspace` and call it by full path, e.g.\n"
        f"  ~/.local/bin/woltspace {' '.join(argv)}\n"
        f"Do not reach for the container-era bash launcher on a native host: it "
        f"boots a second colony against the same data root, the same port and "
        f"the same bot token.",
        file=sys.stderr,
    )
    sys.exit(2)


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", nargs="*", help="legacy message argument (rejected; use stdin)")
    ss.add_argument("--from", dest="from_", default="",
                    help="sender identity (default: $WOLTSPACE_WOLT_NAME); use --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="*",
                    help="legacy seed prompt argument (rejected; use stdin)")
    sp.add_argument("--from", dest="from_", default="",
                    help="spawner identity (default: $WOLTSPACE_WOLT_NAME); use --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:]])

    # Anything we do not serve ourselves goes to whoever does — the host
    # launcher in the container, the native CLI on a native install.
    if argv and (argv[0] == "--version" or
                 (not argv[0].startswith("-") and argv[0] not in LOCAL_NOUNS)):
        _delegate(argv)
    elif not argv or argv[0] in ("--help", "-h"):
        # Help is a question about the whole CLI, not about this client. A
        # native user whose PATH puts the bundle first was getting a help page
        # listing four nouns and no lifecycle at all — as though `start`,
        # `doctor` and `backup` did not exist. In the container the thin
        # client's own help is still the honest answer: the rest really does
        # live on the host.
        if not _running_in_container():
            _delegate(argv or ["--help"])

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


if __name__ == "__main__":
    main()
