#!/usr/bin/env python3
"""0wx.py -- a command line client for the 0wx API.

    ./0wx.py <action> [--parameter value ...]
    ./0wx.py help

Like the shell client, this is meant to be read before it is run. It uses
only the Python standard library -- no requests, no third-party anything --
so you can check every line of it against the API documentation on the
site's "API" page.

Where the shell client hands you the server's JSON and gets out of the way,
this one parses it and prints what the fields mean. If you are writing your
own client, the interesting parts are `send()` and `report()` at the bottom:
between them they show how to build a request, how to get the error message
out of a failed one, and what the replies actually contain.
"""

import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request

# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
# Either may come from the environment, which keeps the key out of your
# shell history:
#
#     export OWX_KEY=...
#     ./0wx.py listfiles
#
DEFAULT_URL = "https://0wx.es/api.cgi"

# Parameters that may be given more than once. Everything else is a single
# value, and repeating it is a mistake worth reporting rather than silently
# keeping the last one.
REPEATABLE = {"file", "share", "g", "otl"}


USAGE = """0wx.py -- command line client for the 0wx API

USAGE
    ./0wx.py <action> [--parameter value ...]
    ./0wx.py help

SETTINGS
    OWX_URL   API endpoint   (default %s)
    OWX_KEY   your API key   (generate one on your account page)

    Both can also be given as --url and --key. Using the environment keeps
    the key out of your shell history.

NO KEY NEEDED
    whoami                                  check a key, read the limits
    ip                                      how your address looks from here
    tinyurl   --url URL                     shorten a URL
    hugeurl   --url URL                     lengthen a URL, absurdly
    paste     --content TEXT                store text, get a link
    translate --content TEXT --to LANG      translate text
    upload    --file PATH                   upload a file

KEY NEEDED
    listfiles     [--page N] [--g ID]       your files; --g none = ungrouped
    listpastes    [--page N]                your pastes
    listurls      [--page N]                your short and huge URLs
    listgalleries                           your galleries
    listotl       [--page N]                your one-time links

    upload    --file PATH --gallery NAME    upload into a gallery
    otl       --share SHARE                 mint a one-time link
    move      --share SHARE --gallery NAME  move files into a gallery
    move      --share SHARE                 move them back out
    gallery   --g ID --publish true|false   publish or withdraw a gallery
    delete    --share SHARE                 delete files, pastes or URLs
    delete    --g ID [--withfiles true]     delete a gallery
    delete    --otl TOKEN                   kill a one-time link

REPEATING A PARAMETER
    --file, --share, --g and --otl may be given more than once:

        ./0wx.py delete --share Ab3xK9pQ --share Zz7yT2mN
        ./0wx.py upload --file one.png --file two.png

OUTPUT
    The reply is summarised in plain text. Add --json for the raw JSON,
    which is what you want when piping this into something else.

EXAMPLES
    ./0wx.py paste --content 'hello & world'
    ./0wx.py upload --file holiday.jpg --gallery 'Holiday 2026'
    ./0wx.py listfiles --page 2
    ./0wx.py gallery --g 7 --publish true --json
""" % DEFAULT_URL


def human_bytes(n):
    """Format a byte count the way the site does.

    Binary units, matching OWX::Util::human_bytes on the server: a listing
    that said "2.0 MB" where the file page says "1.9 MiB" would look like
    two different files.

    The server's long form appends the exact count, which is right on a page
    about one file and too wide for a column, so this keeps the short form.
    """
    try:
        n = int(n)
    except (TypeError, ValueError):
        return "?"

    if n <= 0:
        return "0 B"

    units = ["B", "KiB", "MiB", "GiB", "TiB"]
    value = float(n)
    index = 0
    while value >= 1024 and index < len(units) - 1:
        value /= 1024
        index += 1

    if index == 0:
        return "%d B" % n
    return "%.1f %s" % (value, units[index])


# Column headings for each kind of listing, matched to the widths used in
# describe(). Kept beside them so a change to one is visibly a change to the
# other -- they drifted apart the first time they were written separately.
HEADINGS = {
    "files":     "%-10s %10s  %-24s %s" % ("SHARE", "SIZE", "TYPE", "NAME"),
    "pastes":    "%-10s %10s  %s"       % ("SHARE", "SIZE", "PREVIEW"),
    "urls":      "%-10s %-8s %s"        % ("SHARE", "KIND", "TARGET"),
    "galleries": "%-6s %-24s %10s  %s"  % ("ID", "NAME", "FILES", "STATE"),
    "otl":       "%-10s    %-10s %s"    % ("LINK", "FILE", "NAME"),
}


def at(row, name, fallback="?"):
    """One field of a reply, with a fallback for missing OR null.

    dict.get(name, default) is not enough: the default only applies when the
    KEY IS ABSENT. A JSON null decodes to a present key holding None, so
    every .get() default in this script was dead code for exactly the case
    it was written for -- and "None" leaked into the output.
    """
    value = row.get(name)
    return fallback if value is None or value == "" else value


def die(message):
    print("0wx.py: %s" % message, file=sys.stderr)
    raise SystemExit(1)


# ---------------------------------------------------------------------------
# Reading the command line
# ---------------------------------------------------------------------------
def parse_args(argv):
    """Return (action, fields, files, url, key).

    argparse is not used on purpose: the parameters are whatever the API
    takes, and listing them twice -- here and in the docs -- is how the two
    drift apart. Anything --name value is passed straight through, so a new
    API parameter works without touching this script.
    """
    if not argv:
        print(USAGE, end="")
        raise SystemExit(0)

    action = argv[0]
    if action in ("help", "--help", "-h"):
        print(USAGE, end="")
        raise SystemExit(0)

    if action.startswith("-"):
        die("the first argument must be an action, not %r. Try: ./0wx.py help"
            % action)

    fields = {}          # name -> list of values
    files = []           # paths
    url = os.environ.get("OWX_URL", DEFAULT_URL)
    key = os.environ.get("OWX_KEY", "")

    rest = argv[1:]
    while rest:
        name = rest.pop(0)
        if not name.startswith("--"):
            die("unexpected argument %r. Parameters look like --name value."
                % name)
        if not rest:
            die("%s needs a value" % name)
        value = rest.pop(0)
        name = name[2:]

        if name == "url":
            url = value
        elif name == "key":
            key = value
        elif name == "json":
            die("--json takes no value; put it last on its own")
        elif name == "file":
            files.append(value)
        else:
            if name in fields and name not in REPEATABLE:
                die("--%s was given twice, and only one value is used" % name)
            fields.setdefault(name, []).append(value)

    return action, fields, files, url, key


# ---------------------------------------------------------------------------
# Building the request
# ---------------------------------------------------------------------------
def multipart(fields, files):
    """Build a multipart/form-data body.

    Uploads have to be multipart, and the standard library has no helper for
    building one -- so here it is, in full. A body is a sequence of parts,
    each introduced by a boundary line, and the boundary is any string that
    does not appear in the data.
    """
    boundary = "----0wx" + os.urandom(16).hex()
    out = []

    def part(header):
        out.append(("--%s\r\n%s\r\n\r\n" % (boundary, header)).encode("utf-8"))

    for name, values in fields.items():
        for value in values:
            part('Content-Disposition: form-data; name="%s"' % name)
            out.append(str(value).encode("utf-8"))
            out.append(b"\r\n")

    for path in files:
        try:
            with open(path, "rb") as handle:
                blob = handle.read()
        except OSError as err:
            die("cannot read %s: %s" % (path, err.strerror))

        part('Content-Disposition: form-data; name="file"; filename="%s"\r\n'
             'Content-Type: application/octet-stream'
             % os.path.basename(path).replace('"', ""))
        out.append(blob)
        out.append(b"\r\n")

    out.append(("--%s--\r\n" % boundary).encode("utf-8"))
    return b"".join(out), "multipart/form-data; boundary=%s" % boundary


def send(action, fields, files, url, key):
    """POST the request and return the decoded JSON.

    The important part is the `except HTTPError`. The API reports its own
    errors with a status -- 400 for a bad parameter, 401 for a missing key,
    404, 413 -- and urllib RAISES on all of those. A client that only
    handles the happy path throws away the error message it most needs,
    because that message is in the body of the exception.
    """
    fields = dict(fields)
    fields["action"] = [action]

    if files:
        body, content_type = multipart(fields, files)
    else:
        # urlencode with doseq handles the repeated parameters, and encodes
        # every value -- an unencoded "&" in a paste would otherwise arrive
        # as two separate fields and truncate it.
        body = urllib.parse.urlencode(fields, doseq=True).encode("utf-8")
        content_type = "application/x-www-form-urlencoded"

    request = urllib.request.Request(url, data=body, method="POST")
    request.add_header("Content-Type", content_type)
    if key:
        request.add_header("Authorization", "Bearer " + key)

    try:
        with urllib.request.urlopen(request) as response:
            raw = response.read()
    except urllib.error.HTTPError as err:
        raw = err.read()
    except urllib.error.URLError as err:
        die("could not reach %s: %s" % (url, err.reason))

    try:
        return json.loads(raw.decode("utf-8"))
    except (ValueError, UnicodeDecodeError):
        # Not JSON: a proxy timeout or a block page in front of the API.
        # Show it rather than hiding it behind a parse error.
        die("the server did not return JSON:\n%s"
            % raw.decode("utf-8", "replace")[:500])


# ---------------------------------------------------------------------------
# Reading the reply
# ---------------------------------------------------------------------------
def report(data):
    """Print a reply in plain text. Returns the exit status.

    Every reply has an "ok" field. When it is false there is a stable
    "error" code to branch on and a "message" for a human -- so a script
    reads the code and a person reads the message.

    Fields are read with .get() rather than [] throughout. A reply from a
    newer server may carry fields this script does not know, and one from a
    proxy or a future version may be missing fields it expects; neither is a
    reason to end in a traceback. Anything missing prints as "?".
    """
    if not data.get("ok"):
        print("error: %s" % at(data, "error", "unknown"), file=sys.stderr)
        print(at(data, "message", ""), file=sys.stderr)
        return 1

    kind = data.get("type", "")

    if kind == "upload":
        for item in data.get("files", []):
            print("%s  %s" % (at(item, "url"), at(item, "name")))
        for item in data.get("rejected", []):
            print("rejected: %s (%s)"
                  % (at(item, "name"), at(item, "error")),
                  file=sys.stderr)
        if data.get("gallery_id"):
            print("in gallery %s" % data.get("gallery_id"))

    elif kind in ("tinyurl", "hugeurl", "paste"):
        print(at(data, "url"))

    elif kind == "otl" and isinstance(data.get("otl"), str):
        print(at(data, "url"))

    elif kind in ("files", "pastes", "urls", "otl", "galleries"):
        rows = data.get(kind, []) or data.get("otl", [])

        # Only when there is something to head. A heading over an empty
        # listing reads as a failure to fetch rather than an empty account.
        if rows:
            print(HEADINGS.get(kind, ""))

        for row in rows:
            print(describe(kind, row))

        if not rows:
            print("(nothing here)")

        if data.get("pages", 1) > 1:
            print("page %s of %s"
                  % (at(data, "page"), at(data, "pages")))

    elif kind == "gallery":
        where = at(data, "url") if data.get("published") else "(private)"
        print("%s  %s" % (at(data, "name"), where))

    elif kind == "move":
        print("moved %s of %s"
              % (at(data, "moved", 0), at(data, "requested", 0)))

    elif kind == "delete":
        print("deleted %s file(s), %s link(s), %s gallery(s), "
              "%s one-time link(s)"
              % (at(data, "files", 0), at(data, "links", 0),
                 at(data, "galleries", 0), at(data, "otl", 0)))

    elif kind == "translate":
        print(at(data, "text"))

    else:
        # whoami, ip, and anything added to the API after this script was
        # written. Printing the fields is more useful than saying nothing.
        for name, value in sorted(data.items()):
            if name in ("ok", "type") or isinstance(value, (dict, list)):
                continue

            # A JSON null means the server has nothing for that field -- the
            # geolocation database not knowing an address, say. Printing
            # "None" puts a Python word in front of the user, and printing a
            # blank leaves them wondering whether it failed. "-" says
            # "nothing here" in a way that lines up with the other rows.
            print("%-14s %s" % (name, "-" if value is None else value))

    return 0


def describe(kind, row):
    """One line for one item of a listing."""
    get = lambda name: at(row, name)

    if kind == "files":
        return "%-10s %10s  %-24s %s" % (
            get("share"), human_bytes(row.get("bytes")),
            get("mime"), get("name"))
    if kind == "pastes":
        preview = (row.get("preview") or "").replace("\n", " ")[:40]
        return "%-10s %10s  %s" % (
            get("share"), human_bytes(row.get("bytes")), preview)
    if kind == "urls":
        return "%-10s %-8s %s" % (get("share"), get("kind"), get("target"))
    if kind == "galleries":
        state = at(row, "url") if row.get("published") else "private"
        return "%-6s %-24s %10s  %s" % (
            get("id"), get("name"), at(row, "files", 0), state)
    if kind == "otl":
        gone = "  (file expired)" if row.get("expired") else ""
        return "%-10s -> %-10s %s%s" % (
            get("otl"), get("share"), get("name"), gone)
    return str(row)


def main(argv):
    raw_json = "--json" in argv
    argv = [a for a in argv if a != "--json"]

    action, fields, files, url, key = parse_args(argv)
    data = send(action, fields, files, url, key)

    if raw_json:
        print(json.dumps(data, indent=2, sort_keys=True))
        return 0 if data.get("ok") else 1

    return report(data)


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
