#!/usr/bin/env python3
"""WarmInboxes from a shell, for an agent that has one.

An agent with a terminal but no MCP configuration cannot reach the API without
hand-writing curl, an auth header and a JSON body each time. This is that,
already written: the same endpoints, the same key, the same scopes.

Nothing but the standard library, so it runs wherever python3 does — no
install, no virtualenv, no version of ours to drift from the API's. Download
it from the API itself and it comes with that host baked in:

    curl -fsSL https://<api>/v1/cli -o wib && chmod +x wib

Every command prints JSON on success and exits 0. A refusal prints the
server's own sentence on stderr and exits 1, so `wib ... || handle` works. The
sentences are written to be shown to whoever is reading the output; this adds
nothing to them.

Ordering spends real money, so `order` needs --yes as well as the key's
orders:place scope. An agent that meant to quote and typed the wrong command
stops at a missing flag rather than at a charge.
"""
from __future__ import annotations

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

# Replaced with the host it is served from when downloaded from /v1/cli, so a
# copy fetched from an API points at that API. Overridable either way.
DEFAULT_API = "https://api.warminboxes.com"
TIMEOUT = 60


def api_base(args) -> str:
    """Where to send the key, refused if that is somewhere it would travel in
    the clear.

    The key is a bearer credential: whoever reads it off the wire has the
    account. A mistyped --api, a copied WIB_API_URL with the scheme dropped,
    or an http:// stand-in someone pointed at while testing would each send it
    unencrypted, and nothing about the output would say so. localhost is
    allowed because there is no wire for it to travel on.
    """
    base = (args.api or os.environ.get("WIB_API_URL") or DEFAULT_API).rstrip("/")
    if not base.startswith("https://") and not base.startswith(
            ("http://localhost", "http://127.0.0.1")):
        die(f"Refusing to send your API key to {base or '(nothing)'} — that is "
            "not an https address, so the key would travel in the clear. "
            "Use https://, or localhost for a local server.")
    return base


def api_key(args) -> str:
    key = args.key or os.environ.get("WIB_API_KEY") or ""
    if not key:
        die("No API key. Set WIB_API_KEY, or pass --key. "
            "Create one in the portal under Settings - API keys.")
    return key


def die(message: str, detail=None) -> "NoReturn":  # noqa: F821
    print(message, file=sys.stderr)
    if detail:
        print(json.dumps(detail, indent=2), file=sys.stderr)
    raise SystemExit(1)


def call(args, method: str, path: str, body=None, headers=None) -> object:
    """One request, and the server's own words when it refuses."""
    url = api_base(args) + (path if path.startswith("/") else "/" + path)
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method, headers={
        "Authorization": f"Bearer {api_key(args)}",
        "Content-Type": "application/json",
        "Accept": "application/json",
        **(headers or {}),
    })
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
            raw = r.read().decode() or "null"
    except urllib.error.HTTPError as e:
        raw = e.read().decode() or ""
        if e.code == 429:
            # The one refusal worth saying more about than the server did: an
            # agent reading this decides whether to wait or to give up, and
            # the number it needs is in a header rather than in the sentence.
            wait = e.headers.get("Retry-After") if e.headers else None
            detail = ""
            try:
                detail = (json.loads(raw) or {}).get("detail") or ""
            except ValueError:
                pass
            die((detail or "Too many requests.") + (
                f" Retry after {wait} seconds." if wait else ""))
        try:
            payload = json.loads(raw)
        except ValueError:
            die(f"{method} {path} failed: HTTP {e.code}. {raw[:300]}")
        # The API answers with a sentence in `detail`, and lists the per-domain
        # reasons in `problems` when an order cannot be built.
        detail = payload.get("detail") if isinstance(payload, dict) else None
        if isinstance(detail, str):
            die(detail, payload.get("problems") if isinstance(payload, dict) else None)
        die(f"{method} {path} failed: HTTP {e.code}.", payload)
    except urllib.error.URLError as e:
        die(f"Could not reach {api_base(args)}: {e.reason}")
    try:
        return json.loads(raw)
    except ValueError:
        return raw


def show(value) -> None:
    print(json.dumps(value, indent=2) if not isinstance(value, str) else value)


def read_spec(source: str) -> dict:
    """An order spec: a file, `-` for stdin, or the JSON itself.

    Inline JSON is accepted because it is what everybody tries first — the
    argument is a body, and `wib post /path '{"name":"x"}'` is the obvious
    thing to type. It used to reach open() and come back as a traceback
    ending in FileNotFoundError: '{"name":"x"}', which is both unhelpful and
    a broken promise, since this tool's whole contract is that a refusal is
    one sentence on stderr and exit 1. A file whose name begins with `{`
    does not exist in practice, so there is nothing to be ambiguous about.

    The unreadable-file case is caught here too, for the same reason: it is
    the one remaining way into this function that used to raise.
    """
    if source == "-":
        text = sys.stdin.read()
    elif source.lstrip().startswith(("{", "[")):
        text = source
    else:
        try:
            text = open(source).read()
        except OSError as e:
            die(f"Could not read {source}: {e.strerror}. Give a path to a JSON "
                f"file, the JSON itself, or - to read it from stdin.")
    try:
        spec = json.loads(text)
    except ValueError as e:
        die(f"That is not JSON: {e}")
    if not isinstance(spec, dict):
        die("An order spec is a JSON object.")
    return spec


# --- the commands ------------------------------------------------------------

def cmd_catalogue(args):
    show(call(args, "GET", "/v1/catalogue"))


def cmd_preview(args):
    show(call(args, "POST", "/v1/orders/preview", read_spec(args.spec)))


def cmd_order(args):
    # The key's scope is the boundary; this is the typo guard in front of it.
    if not args.yes:
        die("`order` charges the card on file. Add --yes when you mean it, "
            "or run `preview` to see what it would cost.")
    spec = read_spec(args.spec)
    show(call(args, "POST", "/v1/orders", spec,
              {"Idempotency-Key": args.idempotency_key}))


def cmd_payment_method(args):
    show(call(args, "GET", "/v1/payment-method"))


def cmd_setup_link(args):
    show(call(args, "POST", "/v1/payment-method/setup-link",
              {"success_url": args.success_url, "cancel_url": args.cancel_url}))


def cmd_portal_get(path):
    def run(args):
        query = f"?period={urllib.parse.quote(args.period)}" if getattr(args, "period", None) else ""
        show(call(args, "GET", f"/v1/portal/{path}{query}"))
    return run


def cmd_get(args):
    show(call(args, "GET", args.path))


def cmd_post(args):
    body = read_spec(args.body) if args.body else None
    headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
    show(call(args, "POST", args.path, body, headers))


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="wib", description=__doc__.split("\n")[0],
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="Key: WIB_API_KEY, or --key. Host: WIB_API_URL, or --api.")
    p.add_argument("--key", help="API key. Defaults to $WIB_API_KEY.")
    p.add_argument("--api", help="API base URL. Defaults to $WIB_API_URL.")
    sub = p.add_subparsers(dest="command", required=True)

    def add(name, fn, help_text):
        s = sub.add_parser(name, help=help_text, description=help_text)
        s.set_defaults(func=fn)
        return s

    add("catalogue", cmd_catalogue, "What this account can order, and at what price.")

    s = add("preview", cmd_preview,
            "Check and price an order without placing it. Creates nothing.")
    s.add_argument("spec", help="Order spec as JSON: a file, or - for stdin.")

    s = add("order", cmd_order, "Place an order and charge the card on file.")
    s.add_argument("spec", help="Order spec as JSON: a file, or - for stdin.")
    s.add_argument("--idempotency-key", required=True,
                   help="8-64 chars. The same one retries safely; a new one is "
                        "a new order.")
    s.add_argument("--yes", action="store_true",
                   help="Required. Confirms this spends money.")

    add("payment-method", cmd_payment_method,
        "Whether there is a card to charge, as brand and last four.")

    s = add("setup-link", cmd_setup_link,
            "A Stripe page to save a card. Open it in a browser.")
    s.add_argument("--success-url", required=True)
    s.add_argument("--cancel-url", required=True)

    add("dashboard", cmd_portal_get("dashboard"),
        "Every domain this account owns, grouped by the order that paid.")
    add("orders", cmd_portal_get("orders"), "This account's orders.")
    add("billing", cmd_portal_get("billing"), "Invoices, spend, and what is due.")
    add("requests", cmd_portal_get("requests"), "Changes asked for, and the replies.")
    add("workspaces", cmd_portal_get("workspaces"), "Folders, and what is in each.")

    s = add("deliverability", cmd_portal_get("analytics"),
            "How this account's domains are sending.")
    s.add_argument("--period", default="7d", help="7d, 30d or 90d. Default 7d.")

    # Everything else. The API has more routes than this has commands, and a
    # wrapper per route would be a second list to keep in step with the first.
    s = add("get", cmd_get, "GET any API path, for anything without a command.")
    s.add_argument("path", help="e.g. /v1/portal/orders")

    s = add("post", cmd_post, "POST any API path.")
    s.add_argument("path")
    s.add_argument("body", nargs="?", help="JSON file, or - for stdin.")
    s.add_argument("--idempotency-key")
    return p


def main(argv=None) -> int:
    args = build_parser().parse_args(argv)
    args.func(args)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
