#!/usr/bin/env python3
"""jm — JM Tech Power command-line client (like git / gh).

Manage your apps, releases, JMDrive files and chat from the terminal.
Zero dependencies: standard-library Python 3.7+ only.

Quick start:
    jm auth login          # log in with your JM Tech Power account
    jm whoami
    jm apps list --mine
"""
import argparse
import getpass
import json
import mimetypes
import os
import sys
import urllib.error
import urllib.parse
import urllib.request

DEFAULT_HOST = "https://www.jmtechpower.com"
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "jm")
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
VERSION = "1.2.2"

# Full documentation, embedded so `jm readme` / `jm help` works even after the
# one-line install (which ships only this binary). This is the single source of
# truth — seed_jm_app.py regenerates README.md from it for the download zip.
README = r"""# jm — JM Tech Power CLI

Control your JM Tech Power account from the terminal, like `git` / `gh`.
Pure Python 3.7+, **no dependencies**.

## Install

Requires **Python 3** (the installers check for it and tell you how to get it).

**Linux / macOS** — one line (installs to `/usr/local/bin`, cleans up after itself):

    curl -fsSL https://www.jmtechpower.com/jm/install.sh | sudo bash

**Windows** — in PowerShell:

    irm https://www.jmtechpower.com/jm/install.ps1 | iex

Or from the downloaded zip: `sudo ./install.sh` (Linux/macOS) or `.\install.ps1` (Windows).

## Log in

    jm auth login          # opens your browser — click "Authorize" and you're in (like gh)

Works with any sign-in method, including **Google / GitHub** (no password needed).
Alternatives:

    jm auth login --with-token   # paste a token from https://www.jmtechpower.com/settings/tokens
    jm auth login --password     # username + password (accounts that have one)

Config lives in `~/.config/jm/config.json`. Env overrides: `JM_HOST`, `JM_TOKEN`.

## Commands

    jm whoami
    jm readme | help                         # show this document

    jm apps list [--mine] [--search QUERY]
    jm app view <id>
    jm app create --name N --description D --version V --category C --os OS --license L [--file PATH | --url URL] [--notes ...]
    jm app delete <id>
    jm release create <app_id> --version V [--file PATH | --url URL] [--notes ...] [--highlight "..."]
    jm release download <release_id> [-o out]

    jm articles list [--mine] [--search QUERY] [--category C]
    jm article view <id> [--raw]
    jm article create --title T --category C (--content "..." | --file body.html | stdin) [--summary ...] [--tags a,b] [--draft]
    jm article edit <id> [--title ...] [--content ... | --file ...] [--tags ...] [--publish | --unpublish]
    jm article delete <id>

    jm drive ls [folder_id]
    jm drive upload PATH... [--folder ID] [--public]
    jm drive mkdir NAME [--parent ID] [--public]
    jm drive download <id> [-o out]          # a folder downloads as a zip
    jm drive share <id> [--password P] [--limit N]
    jm drive rm <id>

    jm chat groups
    jm chat read (--with USER_ID | --group ID) [--limit N]
    jm chat send "message" (--to USERNAME | --group ID)

    jm token list | create [--name N] | revoke <id>

## Examples

    jm app create --name "My Tool" --description "Does things" --version 1.0 \
      --category Utilities --os "Windows,Linux" --license MIT --file ./mytool.zip

    echo "<p>Hello world</p>" | jm article create --title "Hi" --category News --draft

    jm drive upload report.pdf --public          # prints a https://.../d/<token> link
"""


# --------------------------------------------------------------------------- #
# config
# --------------------------------------------------------------------------- #
def load_config():
    try:
        with open(CONFIG_PATH) as fh:
            return json.load(fh)
    except (OSError, ValueError):
        return {}


def save_config(cfg):
    os.makedirs(CONFIG_DIR, exist_ok=True)
    with open(CONFIG_PATH, "w") as fh:
        json.dump(cfg, fh, indent=2)
    try:
        os.chmod(CONFIG_PATH, 0o600)
    except OSError:
        pass


def get_host():
    return os.environ.get("JM_HOST") or load_config().get("host") or DEFAULT_HOST


def get_token():
    return os.environ.get("JM_TOKEN") or load_config().get("token")


def die(msg, code=1):
    sys.stderr.write("jm: %s\n" % msg)
    sys.exit(code)


def need_token():
    tok = get_token()
    if not tok:
        die("not logged in. Run: jm auth login")
    return tok


# --------------------------------------------------------------------------- #
# http
# --------------------------------------------------------------------------- #
def _encode_multipart(fields, files):
    boundary = "----jmcli" + os.urandom(16).hex()
    body = bytearray()

    def add(line=b""):
        body.extend(line)
        body.extend(b"\r\n")

    for name, value in (fields or {}).items():
        if value is None:
            continue
        add(("--%s" % boundary).encode())
        add(('Content-Disposition: form-data; name="%s"' % name).encode())
        add()
        add(str(value).encode())

    for name, path in files:
        fname = os.path.basename(path)
        ctype = mimetypes.guess_type(fname)[0] or "application/octet-stream"
        with open(path, "rb") as fh:
            content = fh.read()
        add(("--%s" % boundary).encode())
        add(('Content-Disposition: form-data; name="%s"; filename="%s"' % (name, fname)).encode())
        add(("Content-Type: %s" % ctype).encode())
        add()
        body.extend(content)
        body.extend(b"\r\n")

    add(("--%s--" % boundary).encode())
    return "multipart/form-data; boundary=%s" % boundary, bytes(body)


def request(method, path, auth=True, json_body=None, params=None, files=None, fields=None):
    url = get_host().rstrip("/") + path
    if params:
        clean = {k: v for k, v in params.items() if v is not None}
        if clean:
            url += "?" + urllib.parse.urlencode(clean)
    headers = {"Accept": "application/json", "User-Agent": "jm-cli/%s" % VERSION}
    data = None
    if auth:
        headers["Authorization"] = "Bearer " + need_token()
    if files is not None:
        ctype, data = _encode_multipart(fields or {}, files)
        headers["Content-Type"] = ctype
    elif json_body is not None:
        data = json.dumps(json_body).encode()
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        resp = urllib.request.urlopen(req)
    except urllib.error.HTTPError as exc:
        raw = exc.read()
        try:
            err = json.loads(raw)
            msg = err.get("message") or err.get("error") or ("HTTP %d" % exc.code)
            if err.get("fields"):
                msg += " (%s)" % ", ".join(err["fields"])
        except ValueError:
            msg = "HTTP %d" % exc.code
        die(msg)
    except urllib.error.URLError as exc:
        die("cannot reach %s (%s)" % (get_host(), exc.reason))
    return resp


def api(method, path, **kw):
    resp = request(method, path, **kw)
    raw = resp.read()
    if not raw:
        return {}
    return json.loads(raw)


def download(path, out=None, params=None):
    """GET a file endpoint. Writes to disk, or prints a download_url if the server returns one."""
    resp = request("GET", path, params=params)
    ctype = resp.headers.get("Content-Type", "")
    if "application/json" in ctype:
        data = json.loads(resp.read() or b"{}")
        if data.get("download_url"):
            print(data["download_url"])
            return
        die(data.get("message") or data.get("error") or "no file")
    name = out
    if not name:
        disp = resp.headers.get("Content-Disposition", "")
        if "filename=" in disp:
            name = disp.split("filename=", 1)[1].strip().strip('"')
        name = name or "download.bin"
    with open(name, "wb") as fh:
        while True:
            chunk = resp.read(65536)
            if not chunk:
                break
            fh.write(chunk)
    print("saved %s" % name)


# --------------------------------------------------------------------------- #
# output helpers
# --------------------------------------------------------------------------- #
def table(rows, headers):
    if not rows:
        print("(none)")
        return
    cols = list(zip(*([headers] + [[str(c) for c in r] for r in rows])))
    widths = [max(len(c) for c in col) for col in cols]
    line = "  ".join(h.ljust(w) for h, w in zip(headers, widths))
    print(line)
    print("  ".join("-" * w for w in widths))
    for r in rows:
        print("  ".join(str(c).ljust(w) for c, w in zip(r, widths)))


# --------------------------------------------------------------------------- #
# commands: auth
# --------------------------------------------------------------------------- #
def _login_browser(args, cfg):
    """Open the browser, wait for the user to click Authorize, receive the token (device flow)."""
    import socket
    import time
    import webbrowser

    name = args.name or (socket.gethostname() or "jm cli")
    res = api("POST", "/api/v1/cli/auth/start", auth=False, json_body={"device_name": name})
    device_code = res["device_code"]
    url = res["authorize_url"]
    interval = res.get("interval", 3)
    deadline = time.time() + res.get("expires_in", 600)

    print("Opening your browser to authorize jm…")
    print("  %s" % url)
    opened = False
    try:
        opened = webbrowser.open(url)
    except Exception:
        opened = False
    if not opened:
        print("(couldn't open a browser automatically — open the URL above)")
    print("Waiting for you to click Authorize…  (Ctrl-C to cancel)")

    while time.time() < deadline:
        time.sleep(interval)
        poll = api("POST", "/api/v1/cli/auth/poll", auth=False, json_body={"device_code": device_code})
        status = poll.get("status")
        if status == "approved":
            cfg["token"] = poll["token"]
            save_config(cfg)
            print("Authorized! Logged in as %s on %s" % (poll["user"]["username"], get_host()))
            return
        if status == "denied":
            die("authorization denied in the browser")
        if status in ("expired", "invalid"):
            die("authorization %s — run `jm auth login` again" % status)
    die("timed out waiting for authorization")


def cmd_auth_login(args):
    cfg = load_config()
    if args.host:
        cfg["host"] = args.host
        save_config(cfg)

    # Paste an existing token (from /settings/tokens).
    if args.with_token:
        token = getpass.getpass("Paste your token: ").strip()
        if not token:
            die("no token entered")
        cfg["token"] = token
        save_config(cfg)
        me = api("GET", "/api/v1/user")
        print("Logged in as %s on %s" % (me["username"], get_host()))
        return

    # Username + password (for accounts that have a password).
    if args.password or args.username:
        username = args.username or input("Username or email: ").strip()
        password = getpass.getpass("Password: ")
        res = api("POST", "/api/v1/auth/login", auth=False,
                  json_body={"username": username, "password": password, "name": args.name or "jm cli"})
        cfg["token"] = res["token"]
        save_config(cfg)
        print("Logged in as %s on %s" % (res["user"]["username"], get_host()))
        return

    # Default: browser "Authorize" flow — works with Google/GitHub or any sign-in.
    _login_browser(args, cfg)


def cmd_auth_logout(args):
    cfg = load_config()
    cfg.pop("token", None)
    save_config(cfg)
    print("Logged out (local token cleared).")


def cmd_readme(args):
    print(README)


def cmd_whoami(args):
    me = api("GET", "/api/v1/user")
    st = me.get("storage", {})
    print("%s <%s>  (id %s)" % (me.get("display_name") or me["username"], me["email"], me["id"]))
    print("host: %s" % get_host())
    if st:
        print("storage: %.2f / %d MB (%.1f%%)" % (st["used_mb"], st["total_mb"], st["usage_percentage"]))


# --------------------------------------------------------------------------- #
# commands: apps & releases
# --------------------------------------------------------------------------- #
def cmd_apps_list(args):
    res = api("GET", "/api/v1/apps", params={"mine": 1 if args.mine else None, "search": args.search})
    rows = [[a["id"], a["name"], "v" + a["version"], a["category"],
             "yes" if a["is_approved"] else "pending",
             "ok" if a["has_file"] or a["download_url"] else "no-file"]
            for a in res["apps"]]
    table(rows, ["ID", "NAME", "VERSION", "CATEGORY", "APPROVED", "DOWNLOAD"])


def cmd_app_view(args):
    a = api("GET", "/api/v1/apps/%d" % args.id)["app"]
    print("%s  v%s   [%s]" % (a["name"], a["version"], "approved" if a["is_approved"] else "pending"))
    print(a["url"])
    print("%s · %s · %s" % (a["category"], a["supported_os"], a["license_type"]))
    print("by %s" % a["developer_name"])
    print()
    print(a["description"])
    print()
    print("Releases:")
    rows = [[r["id"], "v" + r["version"], r.get("file_size") or "-",
             r["download_count"], "missing" if (not r["has_file"] and not r["download_url"]) else "ok",
             r["created_at"][:10]] for r in a.get("releases", [])]
    table(rows, ["REL_ID", "VERSION", "SIZE", "DOWNLOADS", "FILE", "DATE"])


def _file_or_url(args):
    if args.file:
        if not os.path.isfile(args.file):
            die("file not found: %s" % args.file)
    return


def cmd_app_create(args):
    _file_or_url(args)
    fields = {"name": args.name, "description": args.description, "version": args.version,
              "category": args.category, "supported_os": args.os, "license_type": args.license,
              "developer_name": args.developer_name, "developer_email": args.developer_email,
              "download_url": args.url, "notes": args.notes}
    if args.file:
        res = api("POST", "/api/v1/apps", files=[("file", args.file)], fields=fields)
    else:
        res = api("POST", "/api/v1/apps", json_body={k: v for k, v in fields.items() if v is not None})
    a = res["app"]
    print("Created app #%d \"%s\". %s" % (a["id"], a["name"], res.get("message", "")))


def cmd_app_delete(args):
    if not args.yes:
        if input("Delete app #%d and all its releases/files? [y/N] " % args.id).strip().lower() != "y":
            die("aborted")
    api("DELETE", "/api/v1/apps/%d" % args.id)
    print("Deleted app #%d" % args.id)


def cmd_release_create(args):
    _file_or_url(args)
    fields = {"version": args.version, "notes": args.notes,
              "highlights": ("\n".join(args.highlight) if args.highlight else None),
              "download_url": args.url}
    if args.file:
        res = api("POST", "/api/v1/apps/%d/releases" % args.app_id, files=[("file", args.file)], fields=fields)
    else:
        res = api("POST", "/api/v1/apps/%d/releases" % args.app_id,
                  json_body={k: v for k, v in fields.items() if v is not None})
    print("Published release v%s (app now v%s)" % (res["release"]["version"], res.get("app_version")))


def cmd_release_download(args):
    download("/api/v1/releases/%d/download" % args.id, out=args.out)


# --------------------------------------------------------------------------- #
# commands: drive
# --------------------------------------------------------------------------- #
def cmd_drive_ls(args):
    res = api("GET", "/api/v1/drive", params={"folder": args.folder})
    rows = []
    for d in res["files"]:
        icon = "DIR " if d["kind"] == "folder" else "file"
        rows.append([d["id"], icon, d["name"], "" if d["kind"] == "folder" else "%.2f MB" % d["size_mb"],
                     d["visibility"], d.get("share_url", "")])
    table(rows, ["ID", "TYPE", "NAME", "SIZE", "VIS", "SHARE_URL"])


def cmd_drive_upload(args):
    for p in args.paths:
        if not os.path.isfile(p):
            die("file not found: %s" % p)
    res = api("POST", "/api/v1/drive/upload",
              files=[("file", p) for p in args.paths],
              fields={"folder": args.folder, "visibility": "public" if args.public else "private"})
    for d in res.get("uploaded", []):
        line = "uploaded #%d %s" % (d["id"], d["name"])
        if d.get("share_url"):
            line += "  " + d["share_url"]
        print(line)
    for e in res.get("errors", []):
        sys.stderr.write("skipped %s (%s)\n" % (e["name"], e["error"]))


def cmd_drive_mkdir(args):
    d = api("POST", "/api/v1/drive/folder",
            json_body={"name": args.name, "parent": args.parent,
                       "visibility": "public" if args.public else "private"})["folder"]
    print("created folder #%d %s" % (d["id"], d["name"]))


def cmd_drive_download(args):
    download("/api/v1/drive/%d/download" % args.id, out=args.out)


def cmd_drive_share(args):
    res = api("POST", "/api/v1/drive/%d/share" % args.id,
              json_body={"password": args.password, "limit": args.limit})
    print(res["share_url"])


def cmd_drive_rm(args):
    if not args.yes:
        if input("Delete drive item #%d (folders delete everything inside)? [y/N] " % args.id).strip().lower() != "y":
            die("aborted")
    api("DELETE", "/api/v1/drive/%d" % args.id)
    print("deleted #%d" % args.id)


# --------------------------------------------------------------------------- #
# commands: chat
# --------------------------------------------------------------------------- #
def cmd_chat_groups(args):
    res = api("GET", "/api/v1/chat/groups")
    table([[g["id"], g["name"]] for g in res["groups"]], ["ID", "NAME"])


def cmd_chat_read(args):
    res = api("GET", "/api/v1/chat/messages",
              params={"group": args.group, "with": args.with_user, "limit": args.limit})
    for m in res["messages"]:
        att = ("  [%s]" % m["attachment"]) if m.get("attachment") else ""
        print("%s  %-12s %s%s" % (m["timestamp"][:16].replace("T", " "), m.get("from_name") or m["from"], m["message"], att))


def cmd_chat_send(args):
    if not args.to and not args.group:
        die("specify --to USER or --group ID")
    body = {"message": args.message}
    if args.group:
        body["group"] = args.group
    else:
        body["to"] = args.to
    api("POST", "/api/v1/chat/send", json_body=body)
    print("sent")


# --------------------------------------------------------------------------- #
# commands: articles
# --------------------------------------------------------------------------- #
def cmd_articles_list(args):
    res = api("GET", "/api/v1/articles",
              params={"mine": 1 if args.mine else None, "search": args.search, "category": args.category})
    rows = [[a["id"], a["title"][:42], a["category"],
             "published" if a["is_published"] else "draft",
             a["view_count"], a["like_count"], a["created_at"][:10]]
            for a in res["articles"]]
    table(rows, ["ID", "TITLE", "CATEGORY", "STATUS", "VIEWS", "LIKES", "DATE"])


def cmd_article_view(args):
    a = api("GET", "/api/v1/articles/%d" % args.id)["article"]
    print("%s   [%s]" % (a["title"], "published" if a["is_published"] else "draft"))
    print(a["url"])
    print("%s · tags: %s · %d views · %d likes"
          % (a["category"], ", ".join(a["tags"]) or "-", a["view_count"], a["like_count"]))
    print()
    content = a.get("content", "")
    if not args.raw:
        import re as _re
        content = _re.sub(r"<[^>]+>", "", content)
    print(content.strip())


def _read_article_content(args, allow_stdin_pipe=False):
    f = getattr(args, "file", None)
    if f == "-":                          # explicit stdin
        return sys.stdin.read()
    if f:
        if not os.path.isfile(f):
            die("file not found: %s" % f)
        with open(f, encoding="utf-8") as fh:
            return fh.read()
    if getattr(args, "content", None) is not None:
        return args.content
    if allow_stdin_pipe and not sys.stdin.isatty():   # body piped into `create`
        data = sys.stdin.read()
        return data if data.strip() else None
    return None


def cmd_article_create(args):
    content = _read_article_content(args, allow_stdin_pipe=True)
    if not content:
        die("provide --content, --file PATH, or pipe the body on stdin")
    body = {"title": args.title, "category": args.category, "content": content,
            "summary": args.summary, "tags": args.tags, "published": (not args.draft)}
    a = api("POST", "/api/v1/articles", json_body={k: v for k, v in body.items() if v is not None})["article"]
    print("Created article #%d \"%s\" [%s]" % (a["id"], a["title"], "published" if a["is_published"] else "draft"))
    print(a["url"])


def cmd_article_edit(args):
    content = _read_article_content(args)
    body = {}
    for k in ("title", "category", "tags", "summary"):
        v = getattr(args, k)
        if v is not None:
            body[k] = v
    if content is not None:
        body["content"] = content
    if args.publish:
        body["published"] = True
    if args.unpublish:
        body["published"] = False
    if not body:
        die("nothing to change — pass --title/--content/--file/--publish/etc.")
    a = api("PATCH", "/api/v1/articles/%d" % args.id, json_body=body)["article"]
    print("Updated article #%d [%s]" % (a["id"], "published" if a["is_published"] else "draft"))


def cmd_article_delete(args):
    if not args.yes:
        if input("Delete article #%d and its comments? [y/N] " % args.id).strip().lower() != "y":
            die("aborted")
    api("DELETE", "/api/v1/articles/%d" % args.id)
    print("Deleted article #%d" % args.id)


# --------------------------------------------------------------------------- #
# commands: token
# --------------------------------------------------------------------------- #
def cmd_token_list(args):
    res = api("GET", "/api/v1/tokens")
    rows = [[t["id"], t["name"], t["prefix"] + "…", t["created_at"][:10], (t["last_used_at"] or "never")[:16].replace("T", " ")]
            for t in res["tokens"]]
    table(rows, ["ID", "NAME", "PREFIX", "CREATED", "LAST_USED"])


def cmd_token_create(args):
    res = api("POST", "/api/v1/tokens", json_body={"name": args.name or "jm cli"})
    print(res["token"])
    sys.stderr.write("Created token #%d (%s). Copy it now — it won't be shown again.\n" % (res["id"], res["name"]))


def cmd_token_revoke(args):
    api("DELETE", "/api/v1/tokens/%d" % args.id)
    print("revoked token #%d" % args.id)


# --------------------------------------------------------------------------- #
# parser
# --------------------------------------------------------------------------- #
def build_parser():
    p = argparse.ArgumentParser(prog="jm", description="JM Tech Power command-line client")
    p.add_argument("--version", action="version", version="jm %s" % VERSION)
    sub = p.add_subparsers(dest="command")

    # auth
    auth = sub.add_parser("auth", help="authenticate").add_subparsers(dest="sub")
    a_login = auth.add_parser("login", help="log in (opens browser to Authorize, like gh)")
    a_login.add_argument("-u", "--username", help="use password login with this username")
    a_login.add_argument("--password", action="store_true", help="use username + password instead of the browser")
    a_login.add_argument("--host", help="API host (default %s)" % DEFAULT_HOST)
    a_login.add_argument("--name", help="token / device name")
    a_login.add_argument("--web", action="store_true", help="force the browser Authorize flow (this is the default)")
    a_login.add_argument("--with-token", action="store_true", help="paste an existing token instead")
    a_login.set_defaults(func=cmd_auth_login)
    auth.add_parser("logout", help="clear the stored token").set_defaults(func=cmd_auth_logout)
    auth.add_parser("status", help="show current login").set_defaults(func=cmd_whoami)

    sub.add_parser("whoami", help="show the logged-in user").set_defaults(func=cmd_whoami)
    sub.add_parser("readme", help="print the full documentation (README)").set_defaults(func=cmd_readme)
    sub.add_parser("help", help="print the full documentation (README)").set_defaults(func=cmd_readme)

    # apps
    apps = sub.add_parser("apps", help="list apps")
    apps.add_argument("action", nargs="?", default="list", choices=["list"])
    apps.add_argument("--mine", action="store_true", help="only your apps")
    apps.add_argument("--search", help="search query")
    apps.set_defaults(func=cmd_apps_list)

    app_p = sub.add_parser("app", help="work with a single app").add_subparsers(dest="sub")
    v = app_p.add_parser("view", help="show an app + releases")
    v.add_argument("id", type=int)
    v.set_defaults(func=cmd_app_view)
    c = app_p.add_parser("create", help="publish a new app (pending approval)")
    for flag in ["name", "description", "version", "category"]:
        c.add_argument("--" + flag, required=True)
    c.add_argument("--os", required=True, help="supported OS")
    c.add_argument("--license", required=True)
    c.add_argument("--developer-name")
    c.add_argument("--developer-email")
    c.add_argument("--file", help="installer file to upload")
    c.add_argument("--url", help="external download URL (instead of --file)")
    c.add_argument("--notes", help="release notes for the first release")
    c.set_defaults(func=cmd_app_create)
    d = app_p.add_parser("delete", help="delete an app (with releases + files)")
    d.add_argument("id", type=int)
    d.add_argument("-y", "--yes", action="store_true")
    d.set_defaults(func=cmd_app_delete)

    rel = sub.add_parser("release", help="manage releases").add_subparsers(dest="sub")
    rc = rel.add_parser("create", help="publish a new release for an app")
    rc.add_argument("app_id", type=int)
    rc.add_argument("--version", required=True)
    rc.add_argument("--file", help="installer file to upload")
    rc.add_argument("--url", help="external download URL")
    rc.add_argument("--notes")
    rc.add_argument("--highlight", action="append", help="a 'What's Changed' bullet (repeatable)")
    rc.set_defaults(func=cmd_release_create)
    rd = rel.add_parser("download", help="download a release file")
    rd.add_argument("id", type=int)
    rd.add_argument("-o", "--out", help="output path")
    rd.set_defaults(func=cmd_release_download)

    # drive
    drv = sub.add_parser("drive", help="JMDrive storage").add_subparsers(dest="sub")
    ls = drv.add_parser("ls", help="list files in a folder (root by default)")
    ls.add_argument("folder", nargs="?", type=int)
    ls.set_defaults(func=cmd_drive_ls)
    up = drv.add_parser("upload", help="upload one or more files")
    up.add_argument("paths", nargs="+")
    up.add_argument("--folder", type=int, help="destination folder id")
    up.add_argument("--public", action="store_true", help="make public + create share link")
    up.set_defaults(func=cmd_drive_upload)
    mk = drv.add_parser("mkdir", help="create a folder")
    mk.add_argument("name")
    mk.add_argument("--parent", type=int)
    mk.add_argument("--public", action="store_true")
    mk.set_defaults(func=cmd_drive_mkdir)
    dd = drv.add_parser("download", help="download a file (or a folder as zip)")
    dd.add_argument("id", type=int)
    dd.add_argument("-o", "--out")
    dd.set_defaults(func=cmd_drive_download)
    sh = drv.add_parser("share", help="make a file/folder public and print its link")
    sh.add_argument("id", type=int)
    sh.add_argument("--password")
    sh.add_argument("--limit", type=int, help="max downloads (0 = unlimited)")
    sh.set_defaults(func=cmd_drive_share)
    rm = drv.add_parser("rm", help="delete a file or folder")
    rm.add_argument("id", type=int)
    rm.add_argument("-y", "--yes", action="store_true")
    rm.set_defaults(func=cmd_drive_rm)

    # chat
    chat = sub.add_parser("chat", help="messages").add_subparsers(dest="sub")
    chat.add_parser("groups", help="list your chat groups").set_defaults(func=cmd_chat_groups)
    cr = chat.add_parser("read", help="read a conversation")
    cr.add_argument("--with", dest="with_user", type=int, help="user id for a direct message")
    cr.add_argument("--group", type=int, help="group id")
    cr.add_argument("--limit", type=int, default=30)
    cr.set_defaults(func=cmd_chat_read)
    cs = chat.add_parser("send", help="send a message")
    cs.add_argument("message")
    cs.add_argument("--to", help="recipient username or id")
    cs.add_argument("--group", type=int, help="group id")
    cs.set_defaults(func=cmd_chat_send)

    # articles
    arts = sub.add_parser("articles", help="list articles")
    arts.add_argument("action", nargs="?", default="list", choices=["list"])
    arts.add_argument("--mine", action="store_true", help="include your drafts + unpublished")
    arts.add_argument("--search")
    arts.add_argument("--category")
    arts.set_defaults(func=cmd_articles_list)

    art = sub.add_parser("article", help="work with a single article").add_subparsers(dest="sub")
    av = art.add_parser("view", help="show an article")
    av.add_argument("id", type=int)
    av.add_argument("--raw", action="store_true", help="print raw HTML instead of stripped text")
    av.set_defaults(func=cmd_article_view)
    ac = art.add_parser("create", help="publish a new article")
    ac.add_argument("--title", required=True)
    ac.add_argument("--category", required=True)
    ac.add_argument("--content", help="article body (HTML/text); or use --file or stdin")
    ac.add_argument("--file", help="read the body from a file (use - for stdin)")
    ac.add_argument("--summary")
    ac.add_argument("--tags", help="comma-separated")
    ac.add_argument("--draft", action="store_true", help="save unpublished")
    ac.set_defaults(func=cmd_article_create)
    ae = art.add_parser("edit", help="update an article")
    ae.add_argument("id", type=int)
    ae.add_argument("--title")
    ae.add_argument("--category")
    ae.add_argument("--content")
    ae.add_argument("--file", help="read the new body from a file (use - for stdin)")
    ae.add_argument("--summary")
    ae.add_argument("--tags")
    ae.add_argument("--publish", action="store_true", help="mark as published")
    ae.add_argument("--unpublish", action="store_true", help="mark as draft")
    ae.set_defaults(func=cmd_article_edit)
    adl = art.add_parser("delete", help="delete an article")
    adl.add_argument("id", type=int)
    adl.add_argument("-y", "--yes", action="store_true")
    adl.set_defaults(func=cmd_article_delete)

    # token
    tok = sub.add_parser("token", help="manage API tokens").add_subparsers(dest="sub")
    tok.add_parser("list", help="list active tokens").set_defaults(func=cmd_token_list)
    tc = tok.add_parser("create", help="create a token")
    tc.add_argument("--name")
    tc.set_defaults(func=cmd_token_create)
    tr = tok.add_parser("revoke", help="revoke a token")
    tr.add_argument("id", type=int)
    tr.set_defaults(func=cmd_token_revoke)

    return p


def main(argv=None):
    parser = build_parser()
    args = parser.parse_args(argv)
    if not getattr(args, "func", None):
        parser.print_help()
        sys.exit(1)
    try:
        args.func(args)
    except KeyboardInterrupt:
        die("interrupted", 130)


if __name__ == "__main__":
    main()
