#!/usr/bin/env python3
"""mb — headless CLI for MODELBEAST. Zero dependencies (python3 stdlib only),
works from any machine that can reach the server (tailnet included).

  export MB_HOST=http://100.89.131.57:8777   # default: http://localhost:8777

  mb ops [-v]                       list operators (+params with -v)
  mb assets [--kind image]          list assets
  mb upload FILE [FILE...]          upload files, print asset ids
  mb run OP [--asset ID | --file F] [-p k=v ...] [--params-json J]
            [--wait] [--follow] [--download DIR]
  mb job ID                         job status json
  mb jobs [-n 20]                   recent jobs
  mb log ID [-f]                    print job log (-f = follow to completion)
  mb wait ID [--download DIR]       block until job finishes (exit 0/2/3)
  mb get ASSET_ID [-o PATH]         download an asset (folders: all members)
  mb rm-asset ID / mb rm-job ID     delete
  mb retry ID / mb cancel ID        job control
  mb settings                       show settings (secrets masked)
  mb set KEY VALUE                  set a setting (e.g. keys — value visible in
                                    shell history; prefer the web UI for secrets)

Typical: mb run flux_local -p prompt="a brass astrolabe" -p seed=7 --wait --download out/
Exit codes: 0 ok/done, 1 usage/HTTP error, 2 job error, 3 job cancelled.
"""
import argparse
import json
import mimetypes
import os
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path

HOST = os.environ.get("MB_HOST", "http://localhost:8777").rstrip("/")
TOKEN = os.environ.get("MB_TOKEN", "")


def _auth(req):
    if TOKEN:
        req.add_header("Authorization", f"Bearer {TOKEN}")
    return req


def http(method, path, body=None):
    req = _auth(urllib.request.Request(HOST + path, method=method))
    data = None
    if body is not None:
        data = json.dumps(body).encode()
        req.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(req, data=data, timeout=120) as r:
            ct = r.headers.get("content-type", "")
            raw = r.read()
            return json.loads(raw) if "json" in ct else raw
    except urllib.error.HTTPError as e:
        if e.code == 401:
            sys.exit("401 unauthorized — set MB_TOKEN (owner: `mb token create <name>` in the UI, "
                     "or scripts/users.py). Get one from ⚙ Settings → Users → token.")
        sys.exit(f"HTTP {e.code} {method} {path}: {e.read().decode()[:500]}")
    except urllib.error.URLError as e:
        sys.exit(f"cannot reach {HOST} ({e.reason}) — is the server up? set MB_HOST?")


def upload_file(path):
    p = Path(path)
    if not p.is_file():
        sys.exit(f"no such file: {p}")
    boundary = uuid.uuid4().hex
    ctype = mimetypes.guess_type(p.name)[0] or "application/octet-stream"
    body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; "
            f"filename=\"{p.name}\"\r\nContent-Type: {ctype}\r\n\r\n").encode()
    body += p.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
    req = _auth(urllib.request.Request(HOST + "/api/assets", data=body, method="POST"))
    req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
    try:
        with urllib.request.urlopen(req, timeout=600) as r:
            return json.loads(r.read())
    except urllib.error.HTTPError as e:
        sys.exit(f"upload failed: HTTP {e.code}: {e.read().decode()[:300]}")


def coerce(op, key, value):
    """Coerce k=v strings using the operator's JSON schema, else best-effort JSON."""
    prop = (op.get("params_schema", {}).get("properties", {}) or {}).get(key, {})
    t = prop.get("type")
    try:
        if t == "integer":
            return int(value)
        if t == "number":
            return float(value)
        if t == "boolean":
            return value.lower() in ("1", "true", "yes", "on")
        if t == "string":
            return value
        return json.loads(value)
    except (ValueError, json.JSONDecodeError):
        return value


def outputs_of(job_id):
    return [a for a in http("GET", "/api/assets") if a.get("parent_job") == job_id]


def download_asset(asset, dest_dir):
    dest_dir = Path(dest_dir)
    dest_dir.mkdir(parents=True, exist_ok=True)
    url = f"{HOST}/api/assets/{asset['id']}/file"
    with urllib.request.urlopen(_auth(urllib.request.Request(url)), timeout=600) as r:
        ct = r.headers.get("content-type", "")
        data = r.read()
    if "json" in ct:
        listing = json.loads(data)
        if listing.get("folder"):
            sub = dest_dir / asset["name"]
            sub.mkdir(exist_ok=True)
            for member in listing["files"]:
                murl = url + "?member=" + urllib.request.quote(member)
                with urllib.request.urlopen(_auth(urllib.request.Request(murl)), timeout=600) as r:
                    (sub / member).write_bytes(r.read())
            print(f"  ↓ {sub} ({len(listing['files'])} files)")
            return
    out = dest_dir / asset["name"]
    out.write_bytes(data)
    print(f"  ↓ {out} ({len(data)} bytes)")


def wait_for(job_id, follow=False, download=None):
    printed = 0
    last_status = None
    while True:
        j = http("GET", f"/api/jobs/{job_id}")
        if follow:
            log = j.get("log") or ""
            if len(log) > printed:
                sys.stdout.write(log[printed:])
                sys.stdout.flush()
                printed = len(log)
        elif j["status"] != last_status:
            print(f"[{job_id}] {j['status']}")
            last_status = j["status"]
        if j["status"] in ("done", "error", "cancelled"):
            if j["status"] == "error":
                print(f"ERROR: {j.get('error')}\n--- last log ---\n{(j.get('log') or '')[-1500:]}",
                      file=sys.stderr)
                sys.exit(2)
            if j["status"] == "cancelled":
                sys.exit(3)
            outs = outputs_of(job_id)
            print(f"done — {len(outs)} output(s):")
            for a in outs:
                print(f"  {a['id']}  {a['kind']:<14} {a['name']}")
            if download:
                for a in outs:
                    download_asset(a, download)
            return
        time.sleep(2)


def main():
    ap = argparse.ArgumentParser(prog="mb", add_help=True)
    sub = ap.add_subparsers(dest="cmd", required=True)

    sub.add_parser("ops").add_argument("-v", action="store_true")
    p = sub.add_parser("assets"); p.add_argument("--kind")
    p = sub.add_parser("upload"); p.add_argument("files", nargs="+")
    p = sub.add_parser("run")
    p.add_argument("operator")
    p.add_argument("--asset"); p.add_argument("--file")
    p.add_argument("-p", "--param", action="append", default=[])
    p.add_argument("--params-json")
    p.add_argument("--wait", action="store_true")
    p.add_argument("--follow", action="store_true")
    p.add_argument("--download")
    p = sub.add_parser("job"); p.add_argument("id")
    p = sub.add_parser("jobs"); p.add_argument("-n", type=int, default=20)
    p = sub.add_parser("log"); p.add_argument("id"); p.add_argument("-f", action="store_true")
    p = sub.add_parser("wait"); p.add_argument("id"); p.add_argument("--download")
    p = sub.add_parser("get"); p.add_argument("id"); p.add_argument("-o", default=".")
    p = sub.add_parser("rm-asset"); p.add_argument("id")
    p = sub.add_parser("rm-job"); p.add_argument("id")
    p = sub.add_parser("retry"); p.add_argument("id")
    p = sub.add_parser("cancel"); p.add_argument("id")
    sub.add_parser("settings")
    p = sub.add_parser("set"); p.add_argument("key"); p.add_argument("value")
    a = ap.parse_args()

    if a.cmd == "ops":
        for op in http("GET", "/api/operators"):
            gate = f"  [needs {','.join(op['requires_env'])}]" if op.get("requires_env") else ""
            inputs = ",".join(op["accepts"]) or "none (prompt-driven)"
            print(f"{op['id']:<20} {op.get('category','?'):<11} in:{inputs:<18} "
                  f"lane:{op.get('resources','cpu')}{gate}")
            if a.v:
                for k, d in (op.get("params_schema", {}).get("properties", {}) or {}).items():
                    dv = f" (default {d['default']})" if "default" in d else ""
                    en = f" one of {d['enum']}" if "enum" in d else ""
                    print(f"    -p {k}=<{d.get('type','any')}>{en}{dv}  {d.get('description','')}")
    elif a.cmd == "assets":
        for x in http("GET", "/api/assets"):
            if a.kind and x["kind"] != a.kind:
                continue
            print(f"{x['id']}  {x['kind']:<14} {x['size']:>10}  {x['name']}")
    elif a.cmd == "upload":
        for f in a.files:
            asset = upload_file(f)
            print(f"{asset['id']}  {asset['kind']}  {asset['name']}")
    elif a.cmd == "run":
        ops = {o["id"]: o for o in http("GET", "/api/operators")}
        if a.operator not in ops:
            sys.exit(f"unknown operator '{a.operator}'. Run: mb ops")
        op = ops[a.operator]
        asset_id = a.asset
        if a.file:
            asset_id = upload_file(a.file)["id"]
            print(f"uploaded → {asset_id}")
        if op["accepts"] and not asset_id:
            sys.exit(f"{a.operator} needs an input ({','.join(op['accepts'])}): --asset ID or --file F")
        params = {}
        if a.params_json:
            params.update(json.loads(a.params_json))
        for kv in a.param:
            if "=" not in kv:
                sys.exit(f"bad -p '{kv}' (want k=v)")
            k, v = kv.split("=", 1)
            params[k] = coerce(op, k, v)
        job = http("POST", "/api/jobs",
                   {"operator": a.operator, "asset_id": asset_id, "params": params})
        print(f"job {job['id']} queued")
        if a.wait or a.follow or a.download:
            wait_for(job["id"], follow=a.follow, download=a.download)
    elif a.cmd == "job":
        print(json.dumps(http("GET", f"/api/jobs/{a.id}"), indent=2))
    elif a.cmd == "jobs":
        for j in http("GET", "/api/jobs")[: a.n]:
            print(f"{j['id']}  {j['status']:<9} {j['operator']}")
    elif a.cmd == "log":
        if a.f:
            wait_for(a.id, follow=True)
        else:
            print(http("GET", f"/api/jobs/{a.id}").get("log") or "(empty)")
    elif a.cmd == "wait":
        wait_for(a.id, download=a.download)
    elif a.cmd == "get":
        assets = {x["id"]: x for x in http("GET", "/api/assets")}
        if a.id not in assets:
            sys.exit("no such asset")
        download_asset(assets[a.id], a.o)
    elif a.cmd == "rm-asset":
        http("DELETE", f"/api/assets/{a.id}"); print("deleted")
    elif a.cmd == "rm-job":
        http("DELETE", f"/api/jobs/{a.id}"); print("deleted")
    elif a.cmd == "retry":
        j = http("POST", f"/api/jobs/{a.id}/retry"); print(f"new job {j['id']}")
    elif a.cmd == "cancel":
        http("POST", f"/api/jobs/{a.id}/cancel"); print("cancel requested")
    elif a.cmd == "settings":
        print(json.dumps(http("GET", "/api/settings"), indent=2))
    elif a.cmd == "set":
        http("PUT", "/api/settings", {a.key: a.value}); print("saved")


if __name__ == "__main__":
    main()
