#!/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.4.0"

# 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 update [--check]                      # update jm to the latest version

    jm apps list [--mine] [--search QUERY]
    jm apps install <name|id> [-y] [--dir DIR] [--download-only]   # download + install an app
    jm app view <id>
    jm app install <name|id> [-y] [--download-only]
    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

    jm apps install "Quickfire"                  # download the app and run its installer
    jm apps install 12 --download-only           # just download app #12

## Installing apps

`jm apps install <name|id>` downloads an app's latest release and installs it for
your OS: .deb/.rpm via the package manager, .AppImage into ~/.local/bin, .pkg/.dmg
on macOS, .exe/.msi on Windows; archives (.zip/.tar.gz) are extracted. It asks
before running an installer (skip with `-y`); `--download-only` just saves the file.

## Updating

    jm update            # download + replace the installed binary in place
    jm update --check    # just report whether a newer version exists

jm also checks once a day and prints a one-line notice when an update is
available (disable with the `JM_NO_UPDATE_CHECK` env var). If `jm` lives in a
system folder, run `sudo jm update` (Linux/macOS).
"""


# --------------------------------------------------------------------------- #
# 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 _version_tuple(v):
    out = []
    for part in str(v or "0").split("."):
        digits = "".join(ch for ch in part if ch.isdigit())
        out.append(int(digits) if digits else 0)
    return tuple(out)


def _fetch_latest(timeout=10):
    """Return the server's version info dict, or None on any failure."""
    try:
        url = get_host().rstrip("/") + "/jm/version"
        req = urllib.request.Request(url, headers={"User-Agent": "jm-cli/%s" % VERSION})
        return json.loads(urllib.request.urlopen(req, timeout=timeout).read())
    except Exception:
        return None


def cmd_update(args):
    info = _fetch_latest()
    if not info or not info.get("version"):
        die("could not check for updates (is %s reachable?)" % get_host())
    latest = info["version"]
    if _version_tuple(latest) <= _version_tuple(VERSION):
        print("jm is already up to date (v%s)." % VERSION)
        return
    if args.check:
        print("Update available: v%s (you have v%s). Run:  jm update" % (latest, VERSION))
        return

    print("Updating jm v%s -> v%s ..." % (VERSION, latest))
    import io
    import stat
    import tempfile
    import zipfile

    try:
        req = urllib.request.Request(info["download_url"], headers={"User-Agent": "jm-cli/%s" % VERSION})
        blob = urllib.request.urlopen(req, timeout=60).read()
        new_src = zipfile.ZipFile(io.BytesIO(blob)).read("jm")
    except Exception as exc:
        die("download failed: %s" % exc)

    target = os.path.realpath(sys.argv[0])
    folder = os.path.dirname(target) or "."
    tmp = None
    try:
        fd, tmp = tempfile.mkstemp(dir=folder, prefix=".jm-new-")
        with os.fdopen(fd, "wb") as fh:
            fh.write(new_src)
        os.chmod(tmp, os.stat(target).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
        os.replace(tmp, target)
    except PermissionError:
        if tmp and os.path.exists(tmp):
            try:
                os.remove(tmp)
            except OSError:
                pass
        msg = "no permission to update %s." % target
        if os.name != "nt":
            msg += "\nRe-run with elevated rights:  sudo jm update"
        else:
            msg += "\nRe-run the installer:  irm %s | iex" % info.get("install_ps1", "")
        die(msg)
    except Exception as exc:
        if tmp and os.path.exists(tmp):
            try:
                os.remove(tmp)
            except OSError:
                pass
        die("update failed: %s" % exc)
    print("Updated to v%s. ✔" % latest)


def _maybe_notify_update(command):
    """Best-effort once-a-day 'update available' nudge. Never breaks a command."""
    if command in ("update", "upgrade", None):
        return
    if os.environ.get("JM_NO_UPDATE_CHECK") or not sys.stderr.isatty():
        return
    try:
        import time
        cfg = load_config()
        now = int(time.time())
        if now - int(cfg.get("last_update_check", 0)) < 86400:
            return
        cfg["last_update_check"] = now
        save_config(cfg)
        info = _fetch_latest(timeout=3)
        if info and info.get("version") and _version_tuple(info["version"]) > _version_tuple(VERSION):
            sys.stderr.write("\njm v%s is available (you have v%s). Update:  jm update\n"
                             % (info["version"], VERSION))
    except Exception:
        pass


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(args):
    """Dispatcher for `jm apps list` / `jm apps install <name|id>`."""
    if getattr(args, "action", "list") == "install":
        if not getattr(args, "target", None):
            die("usage: jm apps install <name|id>")
        args.app = args.target
        return cmd_app_install(args)
    return cmd_apps_list(args)


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 _confirm(prompt):
    try:
        return input("%s [y/N] " % prompt).strip().lower() in ("y", "yes")
    except EOFError:
        return False


def _safe_name(name):
    out = "".join(ch if (ch.isalnum() or ch in "-_.") else "-" for ch in (name or "app")).strip("-")
    return out or "app"


def _resolve_app(query):
    """Find an app by id or name; returns its full detail (with releases)."""
    if str(query).isdigit():
        return api("GET", "/api/v1/apps/%s" % query)["app"]
    matches = api("GET", "/api/v1/apps", params={"search": query})["apps"]
    exact = [a for a in matches if a["name"].lower() == str(query).lower()]
    cands = exact or matches
    if not cands:
        die("no app matching %r  (try: jm apps list --search %s)" % (query, query))
    if len(cands) > 1:
        sys.stderr.write("Multiple apps match — install by id:\n")
        for a in cands:
            sys.stderr.write("  %-4s %s (v%s)\n" % (a["id"], a["name"], a["version"]))
        sys.exit(1)
    return api("GET", "/api/v1/apps/%s" % cands[0]["id"])["app"]


def _download_to_dir(api_path, dest_dir):
    """Download a file endpoint into dest_dir. Returns (saved_path, external_url)."""
    resp = request("GET", api_path)
    if "application/json" in resp.headers.get("Content-Type", ""):
        data = json.loads(resp.read() or b"{}")
        if data.get("download_url"):
            return None, data["download_url"]
        die(data.get("message") or data.get("error") or "no file")
    disp = resp.headers.get("Content-Disposition", "")
    fname = "download.bin"
    if "filename=" in disp:
        fname = disp.split("filename=", 1)[1].strip().strip('"')
    os.makedirs(dest_dir, exist_ok=True)
    out = os.path.join(dest_dir, os.path.basename(fname))
    with open(out, "wb") as fh:
        while True:
            chunk = resp.read(65536)
            if not chunk:
                break
            fh.write(chunk)
    return out, None


def _extract_archive(path):
    import tarfile
    import zipfile
    target = os.path.splitext(path)[0]
    low = path.lower()
    if low.endswith(".zip"):
        with zipfile.ZipFile(path) as z:
            z.extractall(target)
    elif low.endswith((".tar", ".tar.gz", ".tgz", ".gz")):
        try:
            with tarfile.open(path) as t:
                t.extractall(target)
        except Exception:
            print("Saved %s — couldn't extract automatically." % path)
            return
    else:
        print("Saved %s — extract it with your archive tool (e.g. 7z, unrar)." % path)
        return
    print("Extracted to %s" % target)


def _install_file(path, app_obj, assume_yes):
    import platform
    import shutil
    import stat
    import subprocess
    osname = platform.system()
    ext = os.path.splitext(path)[1].lower().lstrip(".")

    def run(cmd, desc):
        print("Installer command:  %s" % desc)
        if not assume_yes and not _confirm("Run it?"):
            print("Skipped. The file is at: %s" % path)
            return
        try:
            subprocess.call(cmd)
        except Exception as exc:
            die("install command failed: %s" % exc)

    if osname == "Linux":
        if ext == "deb":
            run(["sudo", "apt-get", "install", "-y", path], "sudo apt-get install -y %s" % path)
        elif ext == "rpm":
            run(["sudo", "dnf", "install", "-y", path], "sudo dnf install -y %s" % path)
        elif ext == "appimage":
            dest = os.path.join(os.path.expanduser("~/.local/bin"), _safe_name(app_obj["name"]))
            os.makedirs(os.path.dirname(dest), exist_ok=True)
            shutil.move(path, dest)
            os.chmod(dest, 0o755)
            print("Installed -> %s   (run:  %s)" % (dest, os.path.basename(dest)))
        elif ext == "sh":
            os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR)
            run(["bash", path], "bash %s" % path)
        elif ext in ("zip", "tar", "gz", "tgz", "7z", "rar"):
            _extract_archive(path)
        else:
            print("Saved %s — not a Linux installer; install it manually." % path)
    elif osname == "Darwin":
        if ext == "pkg":
            run(["sudo", "installer", "-pkg", path, "-target", "/"], "sudo installer -pkg %s -target /" % path)
        elif ext == "dmg":
            run(["open", path], "open %s" % path)
        elif ext in ("zip", "tar", "gz", "tgz"):
            _extract_archive(path)
        else:
            print("Saved %s — open it to install." % path)
    elif osname == "Windows":
        if ext == "msi":
            run(["msiexec", "/i", path], "msiexec /i %s" % path)
        elif ext == "exe":
            print("Installer: %s" % path)
            if assume_yes or _confirm("Run it now?"):
                try:
                    os.startfile(path)  # Windows only
                except Exception as exc:
                    die("could not launch: %s" % exc)
        elif ext == "zip":
            _extract_archive(path)
        else:
            print("Saved %s" % path)
    else:
        print("Saved %s" % path)


def cmd_app_install(args):
    import platform
    target = getattr(args, "app", None) or getattr(args, "target", None)
    if not target:
        die("usage: jm app install <name|id>")
    app_obj = _resolve_app(target)
    name = app_obj["name"]
    osname = platform.system()

    supported = (app_obj.get("supported_os") or "").lower()
    keys = {"Linux": ["linux"], "Darwin": ["mac", "osx", "darwin"], "Windows": ["win"]}
    if supported and not any(k in supported for k in keys.get(osname, [])):
        sys.stderr.write("Note: %s lists OS support '%s' — you're on %s.\n"
                         % (name, app_obj.get("supported_os"), osname))

    releases = app_obj.get("releases", [])
    rel = next((r for r in releases if r.get("has_file") or r.get("download_url")), None)
    dest_dir = args.dir or os.path.join(os.path.expanduser("~"), "Downloads")
    if not os.path.isdir(dest_dir):
        dest_dir = os.getcwd()

    if rel and rel.get("has_file"):
        print("Downloading %s v%s ..." % (name, rel["version"]))
        path, url = _download_to_dir("/api/v1/releases/%s/download" % rel["id"], dest_dir)
        if url:
            print("External download: %s" % url)
            return
    elif app_obj.get("download_url") or (rel and rel.get("download_url")):
        url = app_obj.get("download_url") or rel.get("download_url")
        print("%s is hosted externally — download it from:\n  %s" % (name, url))
        return
    else:
        die("no installable file for %s." % name)

    print("Saved to %s" % path)
    if args.download_only:
        return
    _install_file(path, app_obj, assume_yes=args.yes)


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)
    for _name in ("update", "upgrade"):
        _u = sub.add_parser(_name, help="update jm to the latest version")
        _u.add_argument("--check", action="store_true", help="only check, don't install")
        _u.set_defaults(func=cmd_update)

    # apps
    apps = sub.add_parser("apps", help="list or install apps")
    apps.add_argument("action", nargs="?", default="list", choices=["list", "install"])
    apps.add_argument("target", nargs="?", help="app name or id (for: apps install)")
    apps.add_argument("--mine", action="store_true", help="only your apps")
    apps.add_argument("--search", help="search query")
    apps.add_argument("-y", "--yes", action="store_true", help="don't prompt before running an installer")
    apps.add_argument("--dir", help="download directory (default ~/Downloads)")
    apps.add_argument("--download-only", action="store_true", help="download without installing")
    apps.set_defaults(func=cmd_apps)

    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)
    ins = app_p.add_parser("install", help="download + install an app by name or id")
    ins.add_argument("app", help="app name or id")
    ins.add_argument("-y", "--yes", action="store_true", help="don't prompt before running the installer")
    ins.add_argument("--dir", help="download directory (default ~/Downloads)")
    ins.add_argument("--download-only", action="store_true", help="download without installing")
    ins.set_defaults(func=cmd_app_install)
    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)
    _maybe_notify_update(getattr(args, "command", None))


if __name__ == "__main__":
    main()
