#!python
"""
shotbot | Python CLI for the Shotbot screenshot API.

Single-file standalone script. Requires Python 3.8+ and stdlib only.
Run `./shotbot help` for usage.

Copyright (c) 2026 Valentin Beck
SPDX-License-Identifier: MIT
"""
from __future__ import annotations

import json
import os
import re
import socket
import sys
import time
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlparse
from urllib.request import Request, urlopen

VERSION = "2.0.0"
API_BASE_DEFAULT = "https://api.shotbot.net"
POLL_INITIAL_S = 2
POLL_INTERVAL_S = 2
POLL_TIMEOUT_S = 180

# ── Output helpers ───────────────────────────────────────────────────────────

_color_cache = None

def use_color() -> bool:
    global _color_cache
    if _color_cache is not None:
        return _color_cache
    if os.environ.get("NO_COLOR") is not None:
        _color_cache = False
        return False
    try:
        _color_cache = bool(sys.stdout.isatty())
    except Exception:
        _color_cache = False
    return _color_cache

def clr(s: str, code: str) -> str:
    return f"\033[{code}m{s}\033[0m" if use_color() else s

def note(s: str = "") -> None:
    sys.stdout.write(s + "\n")

def ok(s: str) -> None:
    sys.stdout.write(clr("✓", "32") + " " + s + "\n")

def warn(s: str) -> None:
    sys.stderr.write(clr("!", "33") + " " + s + "\n")

def err(s: str) -> None:
    sys.stderr.write(clr("✗", "31") + " " + s + "\n")

def dim(s: str) -> str:
    return clr(s, "2")

def b(s: str) -> str:
    return clr(s, "1")

# ── Config ───────────────────────────────────────────────────────────────────

def home() -> str:
    try:
        h = str(Path.home())
    except (RuntimeError, OSError):
        h = ""
    if not h:
        h = os.environ.get("HOME") or os.environ.get("USERPROFILE") or ""
    if not h:
        err("cannot determine home directory")
        sys.exit(2)
    return h.rstrip("/\\")

def config_dir() -> str:
    return os.path.join(home(), ".shotbot-python-cli")

def config_path() -> str:
    return os.path.join(config_dir(), "config.json")

def config_load() -> dict:
    p = config_path()
    if not os.path.isfile(p):
        return {}
    try:
        with open(p, "rb") as fp:
            data = fp.read()
        j = json.loads(data.decode("utf-8"))
        return j if isinstance(j, dict) else {}
    except (OSError, ValueError):
        return {}

def config_save(c: dict) -> None:
    d = config_dir()
    try:
        os.makedirs(d, mode=0o700, exist_ok=True)
    except OSError:
        err(f"cannot create config directory: {d}")
        sys.exit(2)
    try:
        os.chmod(d, 0o700)
    except OSError:
        pass
    p = config_path()
    try:
        with open(p, "w", encoding="utf-8") as fp:
            json.dump(c, fp, indent=2, ensure_ascii=False)
            fp.write("\n")
    except OSError:
        err(f"cannot write config file: {p}")
        sys.exit(2)
    try:
        os.chmod(p, 0o600)
    except OSError:
        pass

def api_key_get() -> str:
    env = os.environ.get("SHOTBOT_API_KEY", "")
    if isinstance(env, str) and env.strip():
        return env.strip()
    cfg = config_load()
    if cfg.get("api_key"):
        return str(cfg["api_key"])
    return prompt_for_key()

# ── Persistent capture defaults ──────────────────────────────────────────────
# Keys allowed in config.json `defaults`. Type drives coercion in `set` and
# validation when merging into the parsed CLI options at capture time.

def default_keys() -> dict:
    return {
        # All tiers
        "preset":          "string",   # og|mobile|youtube_thumbnail|square|reel|pinterest|tablet|desktop|desktop_hd|twitter_header|linkedin_banner|hero_banner
        "frame":           "string",   # rounded|shadow|browser_chrome|browser_chrome_dark|mobile|mobile_light|tablet|tablet_light|laptop|polaroid|gradient|shotbot_brand
        "format":          "string",   # jpg|png|webp|webp_lossless|avif|pdf
        "viewport":        "int",      # 280..3840 (Pro custom; common 360..1920)
        "output-size":     "int",      # 120..1920
        "wait":            "int",      # 0..30 (free capped at 5)
        "ratio":           "string",   # 16:9, 4:3, 1:1, ...
        "color-scheme":    "string",   # dark|light (sets prefers-color-scheme)
        "full-page":       "bool",
        "nojs":            "bool",
        "hidpi":           "bool",
        "reduce-motion":   "bool",     # emulate prefers-reduced-motion: reduce
        # Pro
        "dismiss-cookies": "string",   # accept|reject
        "scroll":          "bool",
        "scroll-offset":   "int",      # 0..30000, scroll N px before capture
        "scroll-to":       "string",   # CSS selector, scrollIntoView before capture
        "render-region":   "string",   # fr-paris|ca-montreal|sg-singapore|au-sydney|vn-hanoi (Pro except fr-paris)
        "block-ads":       "bool",
        "crop-height":     "int",
        "selector":        "string",
        "autoplay-videos":     "bool",
        "omit-background":     "bool",
        "emulate-print-media": "bool",
        "http-auth-user":  "string",
        "http-auth-pass":  "string",
        # PDF (only meaningful when format=pdf)
        "pdf-page-size":   "string",   # A4|A3|A5|Letter|Legal|Tabloid
        "pdf-margin":      "int",      # 0..50 mm
        "pdf-scale":       "float",    # 0.10..2.00
        "pdf-landscape":   "bool",
        # CLI ergonomics
        "output":          "string",   # path or "true" for cwd-auto-name
        "cdn":             "bool",      # upload to the public CDN (default: private)
        "timeout":         "int",      # poll timeout, seconds
    }

ENUMS = {
    "color-scheme":    ["dark", "light"],
    "dismiss-cookies": ["accept", "reject"],
    "format":          ["jpg", "png", "webp", "webp_lossless", "avif", "pdf"],
    "pdf-page-size":   ["A4", "A3", "A5", "Letter", "Legal", "Tabloid"],
    "render-region":   ["fr-paris", "ca-montreal", "sg-singapore", "au-sydney", "vn-hanoi"],
}

def coerce(key: str, raw: str, kind: str):
    if kind == "bool":
        v = raw.strip().lower()
        if v in ("1", "true", "yes", "on"):
            return True
        if v in ("0", "false", "no", "off"):
            return False
        err(f"invalid boolean for {key}: '{raw}' (use true|false)")
        sys.exit(2)
    if kind == "int":
        if not re.match(r"^-?\d+$", raw.strip()):
            err(f"invalid integer for {key}: '{raw}'")
            sys.exit(2)
        return int(raw)
    if kind == "float":
        try:
            return float(raw.strip())
        except ValueError:
            err(f"invalid number for {key}: '{raw}'")
            sys.exit(2)
    # Enum keys: validate against the canonical set defined in capture_options.php.
    if key in ENUMS and raw not in ENUMS[key]:
        err(f"invalid value for {key}: '{raw}' (allowed: " + "|".join(ENUMS[key]) + ")")
        sys.exit(2)
    return raw

def apply_defaults(opts: dict) -> dict:
    cfg = config_load()
    defaults = cfg.get("defaults") or {}
    if not isinstance(defaults, dict) or not defaults:
        return opts
    allowed = default_keys()
    for k, v in defaults.items():
        if k not in allowed:
            continue              # unknown key: ignore silently
        if k in opts:
            continue              # CLI flag wins
        opts[k] = v
    return opts

def api_base() -> str:
    base = os.environ.get("SHOTBOT_API_BASE", "")
    if isinstance(base, str) and base.strip():
        return base.strip().rstrip("/")
    return API_BASE_DEFAULT

def prompt_for_key() -> str:
    note()
    note(b("No Shotbot API key configured."))
    note("Get one at " + clr("https://www.shotbot.net/screenshot-api-key/", "36"))
    note()
    sys.stdout.write("API key: ")
    sys.stdout.flush()
    key = sys.stdin.readline().strip()
    if not key:
        err("empty key, aborting")
        sys.exit(2)
    if not re.match(r"^[0-9A-Za-z]{12}$", key):
        warn("key does not match expected format (12 alphanumeric chars), saving anyway")
    cfg = config_load()
    cfg["api_key"] = key
    config_save(cfg)
    ok(f"Saved to {config_path()} (chmod 600)")
    note()
    return key

# ── HTTP ─────────────────────────────────────────────────────────────────────

def truthy(v) -> bool:
    """Mimic PHP `!empty()` for the values we ever encounter."""
    if v is None or v is False:
        return False
    if v == "" or v == 0 or v == "0":
        return False
    return True

def http(method: str, path: str, body: dict | None = None) -> dict:
    url = api_base() + path
    headers = {
        "Accept": "application/json",
        "User-Agent": f"shotbot-python-cli/{VERSION}",
    }
    data = None
    if method == "POST":
        data = json.dumps(body if body is not None else {}, ensure_ascii=False).encode("utf-8")
        headers["Content-Type"] = "application/json"
    req = Request(url, data=data, headers=headers, method=method)
    raw = ""
    code = 0
    try:
        with urlopen(req, timeout=60) as resp:
            code = resp.getcode()
            raw = resp.read().decode("utf-8", errors="replace")
    except HTTPError as e:
        code = e.code
        try:
            raw = e.read().decode("utf-8", errors="replace")
        except Exception:
            raw = ""
    except (URLError, socket.timeout, ConnectionError, OSError) as e:
        err(f"network error: {e}")
        sys.exit(3)
    j = None
    if raw:
        try:
            parsed = json.loads(raw)
            if isinstance(parsed, dict):
                j = parsed
        except ValueError:
            j = None
    return {"code": code, "json": j, "raw": raw}

def download(url: str, dest: str) -> int:
    headers = {"User-Agent": f"shotbot-python-cli/{VERSION}"}
    req = Request(url, headers=headers)
    try:
        with urlopen(req, timeout=120) as resp:
            code = resp.getcode()
            if code != 200:
                err(f"download failed (HTTP {code})")
                sys.exit(3)
            try:
                fp = open(dest, "wb")
            except OSError:
                err(f"cannot open {dest} for writing")
                sys.exit(2)
            try:
                while True:
                    chunk = resp.read(65536)
                    if not chunk:
                        break
                    fp.write(chunk)
            finally:
                fp.close()
    except (HTTPError, URLError, socket.timeout, ConnectionError, OSError) as e:
        try:
            os.unlink(dest)
        except OSError:
            pass
        err(f"download failed: {e}")
        sys.exit(3)
    try:
        return os.path.getsize(dest)
    except OSError:
        return 0

# ── Args parser ──────────────────────────────────────────────────────────────

def parse_args(argv: list) -> dict:
    """Parse argv into {cmd, opts, pos}.

    Recognises --flag, --key=value, --key value, and bare positional args.
    """
    rest = list(argv[1:])
    cmd = ""
    if rest and not rest[0].startswith("-"):
        cmd = rest.pop(0)
    opts: dict = {}
    pos: list = []
    i = 0
    n = len(rest)
    while i < n:
        a = rest[i]
        if a.startswith("--"):
            tail = a[2:]
            if "=" in tail:
                k, _, v = tail.partition("=")
                opts[k] = v
            else:
                nxt = rest[i + 1] if i + 1 < n else None
                if nxt is not None and not nxt.startswith("-"):
                    opts[tail] = nxt
                    i += 1
                else:
                    opts[tail] = True
        elif a.startswith("-") and len(a) > 1:
            opts[a[1:]] = True
        else:
            pos.append(a)
        i += 1
    return {"cmd": cmd, "opts": opts, "pos": pos}

# ── Commands ─────────────────────────────────────────────────────────────────

def cmd_help() -> None:
    cfg = config_path()
    sys.stdout.write(f"""
  shotbot v{VERSION}  Python CLI for the Shotbot screenshot API

  USAGE
    shotbot <command> [options]

  COMMANDS
    capture                Capture a single URL (see CAPTURE OPTIONS).
    status                 Show account plan, credits, quota and in-flight captures.
    set <key> [value]      Set a persistent default for `capture` (see DEFAULTS).
                           Booleans accept true/false/yes/no/1/0; bare `set <key>`
                           on a boolean is shorthand for `set <key> true`.
    unset <key>            Remove a persistent default.
    defaults               List currently-set persistent defaults.
    defaults --keys        List every key you can `set` and its type.
    config                 Print the path to the config file.
    reset                  Re-prompt for the API key (overwrites the stored one).
    version                Print the version and exit.
    help                   Print this help.

  CAPTURE OPTIONS  (free tier)
    --url=URL              Required. The page to capture (http/https).
    --preset=NAME          Named output bundle: og, mobile, youtube_thumbnail,
                           square, reel, pinterest, tablet (free); desktop,
                           desktop_hd, twitter_header, linkedin_banner,
                           hero_banner (Pro).
                           Fills viewport/output-size/crop-height in one go.
    --frame=NAME           Decorative frame around the image: rounded, shadow,
                           browser_chrome, browser_chrome_dark, mobile,
                           mobile_light, tablet, tablet_light, laptop, polaroid,
                           gradient, shotbot_brand.
                           Free accounts get a "shotbot.fr" mark | clean with Pro.
    --format=FMT           jpg | png | webp | webp_lossless | avif | pdf  (default: jpg)
    --viewport=N           Viewport width, common 360..1920; Pro 280..3840 (default: 1280)
    --output-size=N        Resize the result. 120..1920 px (default: same as viewport)
    --wait=N               Seconds to wait after page load. Free 0..5, Pro 0..30 (default: 5)
    --ratio=R              16:9, 4:3, 1:1, 9:16, etc.              (default: 16:9)
    --full-page            Capture the full scrollable page (max 30000 px).
    --nojs                 Disable JavaScript before navigation.
    --color-scheme=dark|light  Force prefers-color-scheme (matches API field).
    --hidpi                Render at 2x device pixel ratio.
    --reduce-motion        Emulate prefers-reduced-motion: reduce (well-built
                           sites skip animations | cleaner captures).

  CAPTURE OPTIONS  (Pro)
    --dismiss-cookies=accept|reject   Auto-handle cookie consent banners.
    --scroll                          Scroll the page before capture
                                      (triggers lazy-loaded images).
    --scroll-offset=N                 Scroll down N px before capture (0..30000).
    --scroll-to=CSS                   Scroll the matched element into view before
                                      capture (mutually exclusive with --scroll-offset).
    --block-ads                       Block ad-network requests.
    --crop-height=N                   Crop the result to N px tall (10..30000),
                                      overrides --ratio, implies full-page.
    --selector=CSS                    Capture only the matched DOM element.
    --render-region=REGION            Render from a real local IP in the chosen
                                      region: fr-paris (free, default),
                                      ca-montreal, sg-singapore, au-sydney,
                                      vn-hanoi (Pro).
    --autoplay-videos                 Allow unmuted <video autoplay> to start
                                      without a user gesture.
    --omit-background                 Transparent backdrop (PNG/WebP/AVIF only;
                                      JPG flattens to black).
    --emulate-print-media             Render with the page's print stylesheet.
    --http-auth-user=U
    --http-auth-pass=P                HTTP Basic Auth credentials.

  CAPTURE OPTIONS  (PDF only - when --format=pdf)
    --pdf-page-size=SIZE   A4 | A3 | A5 | Letter | Legal | Tabloid (default: A4)
    --pdf-margin=N         Page margin in mm 0..50                  (default: 10)
    --pdf-scale=F          Rendering scale 0.10..2.00               (default: 1.00)
    --pdf-landscape        Landscape orientation.

  CLI OPTIONS
    By default a capture is private and saved to the current directory as
    ./HOST-TOKEN.FMT. Use --output to choose where, --cdn for a public URL,
    or --json for machine output (the last two do not save a file).
    --output[=PATH]        Choose where to save (default: cwd, auto-named):
                             --output                 -> ./HOST-TOKEN.FMT (cwd, auto-named)
                             --output=DIR/            -> save into DIR/ (auto-named)
                             --output=path/file.jpg   -> save to that exact path
    --cdn                  Upload to the public CDN and return a permanent URL
                           (no local file is saved). Without it, captures stay
                           private (not on the CDN) and auto-expire server-side.
    --json                 Print the full API response as JSON (no file saved).
    --timeout=N            Max seconds to wait for the capture      (default: 180)

  DEFAULTS
    `shotbot set <key> [value]` stores a default in the config file. Any default
    is applied to every `capture` call; per-call flags always override.

    Settable keys (run `shotbot defaults --keys` for the full list):
      Free tier:
        format          (string)   viewport        (int)   output-size  (int)
        wait            (int)      ratio           (string)
        color-scheme    (dark|light)               full-page (bool)
        nojs            (bool)     hidpi           (bool)   reduce-motion (bool)
      Pro:
        dismiss-cookies (accept|reject)            scroll    (bool)
        block-ads       (bool)     crop-height     (int)
        selector        (string)   autoplay-videos (bool)
        omit-background (bool)     emulate-print-media (bool)
        render-region   (fr-paris|ca-montreal|sg-singapore|au-sydney|vn-hanoi)
        http-auth-user / http-auth-pass (string)
      PDF:
        pdf-page-size   (A4|A3|A5|Letter|Legal|Tabloid)
        pdf-margin      (int)      pdf-scale       (float) pdf-landscape (bool)
      CLI:
        output          (string)   cdn             (bool)  timeout      (int)

    Examples:
      shotbot set full-page                     # always full-page
      shotbot set output ./shots/               # always save into ./shots/
      shotbot set cdn true                      # always publish to the public CDN
      shotbot set viewport 1440                 # always render at 1440 px
      shotbot set format webp                   # always webp
      shotbot set color-scheme dark             # always emulate prefers-color-scheme: dark
      shotbot defaults                          # show what's stored
      shotbot unset full-page                   # forget that default

    Note: captures are private by default and saved to the current directory.
    They are never published on the CDN unless you pass `--cdn` (or `set cdn
    true`), which returns a permanent public URL instead of saving a file.

  ENVIRONMENT
    SHOTBOT_API_KEY        Override the stored API key.
    SHOTBOT_API_BASE       Override the API base URL (default: https://api.shotbot.net).
    NO_COLOR               Disable ANSI color output.

  CONFIG FILE
    {cfg}
    Stores: api_key (string), defaults (object). Chmod 600.

  EXIT CODES
    0  success                       4  API returned an error response
    2  bad invocation / config       5  waitlisted (quota exhausted)
    3  network error                 6  capture failed server-side
                                     7  polling timeout

  EXAMPLES
    shotbot capture --url=https://example.com
    shotbot capture --url=https://example.com --format=webp --viewport=1440
    shotbot capture --url=https://example.com --full-page --output=shot.jpg
    shotbot capture --url=https://example.com --json | jq .
""")

def cmd_status(opts: dict) -> None:
    key = api_key_get()
    r   = http("GET", "/account?key=" + quote(key, safe=""))
    if r["code"] == 401:
        err("invalid API key  (run " + b("shotbot reset") + " to update it)")
        sys.exit(2)
    if r["code"] != 200 or not r["json"]:
        err("request failed: " + (r["json"].get("error") if r["json"] else f"HTTP {r['code']}"))
        sys.exit(3)
    if truthy(opts.get("json")):
        sys.stdout.write(json.dumps(r["json"], indent=2) + "\n")
        return
    d   = r["json"]
    COL = 14

    def lbl(s: str) -> str:
        return dim(s.ljust(COL))

    plan = d["plan"]
    if d["plan"] == "pro" and d.get("pro_until"):
        from datetime import datetime, timezone
        dt = datetime.fromtimestamp(d["pro_until"], tz=timezone.utc).strftime("%Y-%m-%d")
        plan = b("pro") + dim(f"  (until {dt})")
    quota = (
        f"{d['quota_used']} / {d['quota_total']}  " + dim(f"({d['quota_remaining']} remaining)")
        if d["quota_total"] > 0 else dim("—")
    )
    credit   = str(d["credit"]) if d["credit"] > 0 else dim("0")
    inflight = f"{d['inflight']} / {d['inflight_cap']}"

    note("")
    note(lbl("plan")      + plan)
    note(lbl("credits")   + credit)
    note(lbl("quota")     + quota)
    note(lbl("in flight") + inflight)
    note("")

def cmd_version() -> None:
    sys.stdout.write(VERSION + "\n")

def cmd_config() -> None:
    sys.stdout.write(config_path() + "\n")

def cmd_reset() -> None:
    cfg = config_load()
    cfg.pop("api_key", None)
    config_save(cfg)
    ok("API key cleared.")
    prompt_for_key()

def cmd_set(pos: list) -> None:
    if len(pos) < 1:
        err("usage: shotbot set <key> [value]")
        note(dim("list keys: ") + b("shotbot defaults --keys"))
        sys.exit(2)
    key = pos[0]
    allowed = default_keys()
    if key not in allowed:
        err(f"unknown key '{key}'")
        note(dim("valid keys: ") + ", ".join(allowed.keys()))
        sys.exit(2)
    kind = allowed[key]
    if len(pos) < 2:
        if kind != "bool":
            err(f"'{key}' is a {kind}, value required: shotbot set {key} <value>")
            sys.exit(2)
        value = True
    else:
        value = coerce(key, str(pos[1]), kind)
    cfg = config_load()
    if not isinstance(cfg.get("defaults"), dict):
        cfg["defaults"] = {}
    cfg["defaults"][key] = value
    config_save(cfg)
    shown = ("true" if value else "false") if isinstance(value, bool) else str(value)
    ok(f"default {key} = {shown}")

def cmd_unset(pos: list) -> None:
    if len(pos) < 1:
        err("usage: shotbot unset <key>")
        sys.exit(2)
    key = pos[0]
    cfg = config_load()
    defaults = cfg.get("defaults") or {}
    if not isinstance(defaults, dict) or key not in defaults:
        warn(f"no default set for '{key}'")
        return
    del defaults[key]
    if defaults:
        cfg["defaults"] = defaults
    else:
        cfg.pop("defaults", None)
    config_save(cfg)
    ok(f"cleared default {key}")

def cmd_defaults(opts: dict) -> None:
    if truthy(opts.get("keys")):
        for k, t in default_keys().items():
            note(f"  {k} " + dim(f"({t})"))
        return
    cfg = config_load()
    d = cfg.get("defaults") or {}
    if not isinstance(d, dict) or not d:
        note("no persistent defaults set.")
        note(dim("try: ") + b("shotbot set full-page true"))
        return
    note(b("persistent defaults") + dim(" (in " + config_path() + ")"))
    for k, v in d.items():
        shown = ("true" if v else "false") if isinstance(v, bool) else str(v)
        note("  " + k + dim(" = ") + shown)

def cmd_capture(opts: dict) -> None:
    opts = apply_defaults(opts)

    url = opts.get("url")
    if not isinstance(url, str) or not url:
        err("missing required --url")
        note("try: " + dim("shotbot capture --url=https://example.com"))
        sys.exit(2)
    if not re.match(r"^https?://", url, re.IGNORECASE):
        err("--url must start with http:// or https://")
        sys.exit(2)

    body: dict = {"key": api_key_get(), "url": url}

    # Captures are private by default: not published on static.shotbot.net,
    # retrieved on demand from the API and auto-expired. Pass --cdn to opt into
    # a permanent public CDN URL instead.
    body["private"] = not opts.get("cdn")

    if truthy(opts.get("preset")):       body["preset"]         = str(opts["preset"])
    if truthy(opts.get("frame")):        body["frame"]          = str(opts["frame"])
    if truthy(opts.get("format")):       body["format"]         = str(opts["format"])
    if truthy(opts.get("viewport")):     body["viewport_width"] = int(opts["viewport"])
    if truthy(opts.get("output-size")):  body["output_size"]    = int(opts["output-size"])
    if truthy(opts.get("ratio")):        body["ratio"]          = str(opts["ratio"])
    if "wait" in opts:                   body["wait_time"]      = int(opts["wait"])
    if truthy(opts.get("full-page")):    body["fullpage"]       = True
    if truthy(opts.get("nojs")):         body["nojs"]           = True
    if truthy(opts.get("hidpi")):        body["hidpi"]          = True
    if truthy(opts.get("color-scheme")):
        cs = str(opts["color-scheme"])
        if cs not in ("dark", "light"):
            err(f"--color-scheme must be 'dark' or 'light' (got '{cs}')")
            sys.exit(2)
        body["color_scheme"] = cs
    if truthy(opts.get("dismiss-cookies")):
        d = str(opts["dismiss-cookies"])
        if d not in ("accept", "reject"):
            err(f"--dismiss-cookies must be 'accept' or 'reject' (got '{d}')")
            sys.exit(2)
        body["dismiss_cookies"] = d
    if truthy(opts.get("scroll")):       body["scroll_before_capture"] = True
    if opts.get("scroll-offset") is not None: body["scroll_offset_px"] = int(opts["scroll-offset"])
    if truthy(opts.get("scroll-to")):    body["scroll_to"]    = str(opts["scroll-to"])
    if truthy(opts.get("render-region")):
        rr = str(opts["render-region"])
        if rr not in ("fr-paris", "ca-montreal", "sg-singapore", "au-sydney", "vn-hanoi"):
            err(f"--render-region must be one of fr-paris|ca-montreal|sg-singapore|au-sydney|vn-hanoi (got '{rr}')")
            sys.exit(2)
        body["render_region"] = rr
    if truthy(opts.get("block-ads")):    body["block_ads"]    = True
    if truthy(opts.get("crop-height")):  body["crop_height"]  = int(opts["crop-height"])
    if truthy(opts.get("selector")):     body["selector"]     = str(opts["selector"])
    if truthy(opts.get("autoplay-videos")):     body["autoplay_videos"] = True
    if truthy(opts.get("reduce-motion")):       body["prefers_reduced_motion"] = True
    if truthy(opts.get("omit-background")):     body["omit_background"] = True
    if truthy(opts.get("emulate-print-media")): body["emulate_print_media"] = True
    if truthy(opts.get("http-auth-user")):
        body["http_auth"] = {
            "user": str(opts["http-auth-user"]),
            "pass": str(opts.get("http-auth-pass") or ""),
        }
    # PDF options (silently ignored by API when format != pdf)
    if truthy(opts.get("pdf-page-size")): body["pdf_page_size"] = str(opts["pdf-page-size"])
    if "pdf-margin" in opts:              body["pdf_margin_mm"] = int(opts["pdf-margin"])
    if "pdf-scale" in opts:               body["pdf_scale"]     = float(opts["pdf-scale"])
    if truthy(opts.get("pdf-landscape")): body["pdf_landscape"] = True

    timeout    = int(opts.get("timeout") or POLL_TIMEOUT_S)
    json_only  = truthy(opts.get("json"))
    output_arg = opts.get("output") if "output" in opts else None

    # CLI default: save to the current directory (auto-named) unless the user
    # asked for a public CDN URL (--cdn) or machine-readable JSON (--json).
    # Captures are private and expire, so a local file is the durable artifact.
    if output_arg is None and not json_only and not opts.get("cdn"):
        output_arg = True

    # Pre-validate explicit directory targets (so we don't waste a capture).
    if isinstance(output_arg, str) and output_arg and (output_arg.endswith("/") or output_arg.endswith("\\")):
        d = output_arg.rstrip("/\\") or "."
        if not os.path.isdir(d):
            err(f"output directory does not exist: {d}")
            sys.exit(2)

    if not json_only:
        note(dim("→") + " POST " + api_base() + "/capture")
        note("  " + dim("url:    ") + url)
        note("  " + dim("format: ") + str(body.get("format", "jpg"))
             + dim(" · viewport: ") + str(body.get("viewport_width", 1280))
             + dim(" · wait: ") + str(body.get("wait_time", 5)) + "s")

    r = http("POST", "/capture", body)
    j = r["json"]

    if r["code"] >= 400 or not j:
        msg = (j.get("error") if j else None) or (j.get("message") if j else None) or (j.get("detail") if j else None) or f"HTTP {r['code']}"
        err("API error: " + str(msg))
        if r["code"] == 401:
            note(dim("hint: ") + "run " + b("shotbot reset") + " to update your API key")
        elif r["code"] == 403:
            fields = j.get("fields", []) if j else []
            if "url" in fields:
                note(dim("hint: ") + "the URL contains a custom TCP port — upgrade to Pro to use it, see " + clr("https://www.shotbot.net/pro/", "36"))
            else:
                note(dim("hint: ") + "this option may require a Pro account, see " + clr("https://www.shotbot.net/pro/", "36"))
        if json_only:
            note(r["raw"])
        sys.exit(4)

    status = j.get("status", "")
    if status == "waitlisted":
        if json_only:
            sys.stdout.write(json.dumps(j) + "\n")
            sys.exit(5)
        warn("waitlisted: " + str(j.get("detail", "quota exhausted")))
        sys.exit(5)
    if "token" not in j:
        err("unexpected response (no token)")
        if json_only:
            note(r["raw"])
        sys.exit(4)

    token = str(j["token"])
    eta   = int(j.get("eta_seconds", 5))
    dedup = bool(j.get("deduplicated", False))
    jid   = j.get("job_id")

    if not json_only:
        note(dim("  job:    ") + token + (dim(f" #{jid}") if jid is not None else "") + (dim(" (deduplicated)") if dedup else ""))
        note(dim("  eta:    ") + "~" + str(eta) + "s")
        note()

    # Poll
    start  = time.time()
    first  = max(POLL_INITIAL_S, min(eta, 8))
    frames = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"]
    tick   = 0
    time.sleep(first)
    while True:
        r2 = http("GET", "/capture/" + quote(token, safe=""))
        s  = r2["json"]
        if r2["code"] >= 400 or not s:
            if not json_only and use_color():
                sys.stdout.write("\r\033[K")
                sys.stdout.flush()
            err(f"status check failed (HTTP {r2['code']})")
            if json_only:
                note(r2["raw"])
            sys.exit(4)
        st = s.get("status", "")
        if st == "done":
            if not json_only and use_color():
                sys.stdout.write("\r\033[K")
                sys.stdout.flush()
            if json_only:
                sys.stdout.write(json.dumps(s) + "\n")
                return
            img       = s.get("image", "") or ""
            download_ = s.get("download", "") or ""
            will_save = output_arg is not None and (download_ or img)
            elapsed   = int(time.time() - start)
            ok(f"captured in {elapsed}s")
            if s.get("meta_page_title"):
                note(dim("  title:  ") + str(s["meta_page_title"]))
            if s.get("meta_http_status"):
                note(dim("  status: ") + str(s["meta_http_status"]))
            # Skip the (expiring) private URL when we're saving a local file | the
            # saved path below is what the user wants.
            if download_ and not will_save:
                note(dim("  private:") + " " + clr(download_, "36") + dim(" (expires)"))
            elif img:
                note(dim("  image:  ") + clr(img, "36"))
            if s.get("preview"):
                note(dim("  preview:") + " " + clr(str(s["preview"]), "36"))
            if will_save:
                final_path = resolve_output_path(output_arg, url, token, str(body.get("format", "jpg")))
                bytes_ = download(download_ or img, final_path)
                ok(f"saved {final_path}" + dim(f" ({human_bytes(bytes_)})"))
            return
        if st == "failed":
            if not json_only and use_color():
                sys.stdout.write("\r\033[K")
                sys.stdout.flush()
            code_ = s.get("error_code", "unknown")
            if json_only:
                sys.stdout.write(json.dumps(s) + "\n")
                sys.exit(6)
            err("capture failed: " + str(code_))
            if s.get("image"):
                note(dim("  partial:") + " " + clr(str(s["image"]), "36"))
            sys.exit(6)
        if time.time() - start > timeout:
            if not json_only and use_color():
                sys.stdout.write("\r\033[K")
                sys.stdout.flush()
            err(f"timed out after {timeout}s (job {token} still {st})")
            note(dim("hint: ") + "check later with " + b(f"curl {api_base()}/capture/{token}"))
            sys.exit(7)
        if not json_only and use_color():
            f = frames[tick % len(frames)]
            tick += 1
            elapsed_s = int(time.time() - start)
            sys.stdout.write("\r" + clr(f, "36") + " " + str(st) + dim(f" · {elapsed_s}s elapsed"))
            sys.stdout.flush()
        time.sleep(POLL_INTERVAL_S)

def resolve_output_path(opt, url: str, token: str, fmt: str) -> str:
    host = urlparse(url).hostname or "shotbot"
    host = re.sub(r"^www\.", "", host, flags=re.IGNORECASE)
    host = re.sub(r"[^a-z0-9.\-]", "_", host, flags=re.IGNORECASE)
    # webp_lossless is stored as that format but the file is a plain .webp.
    ext  = "webp" if fmt == "webp_lossless" else fmt
    auto = f"{host}-{token[:8]}.{ext}"

    if opt is True or opt is None or opt == "":
        return "./" + auto
    s = str(opt)
    if s.endswith("/") or s.endswith("\\"):
        d = s.rstrip("/\\") or "."
        return d + "/" + auto
    if os.path.isdir(s):
        return s.rstrip("/\\") + "/" + auto
    return s

def human_bytes(n: int) -> str:
    if n < 1024:
        return f"{n}B"
    if n < 1024 * 1024:
        return f"{round(n / 1024, 1)}KB"
    return f"{round(n / 1024 / 1024, 2)}MB"

# ── Dispatch ─────────────────────────────────────────────────────────────────

def main() -> None:
    args = parse_args(sys.argv)
    cmd  = args["cmd"]
    opts = args["opts"]

    if cmd == "" and (truthy(opts.get("version")) or truthy(opts.get("v"))):
        cmd = "version"
    if cmd == "" and (truthy(opts.get("help")) or truthy(opts.get("h"))):
        cmd = "help"
    if cmd == "":
        cmd = "help"

    if cmd == "capture":
        cmd_capture(opts)
    elif cmd == "status":
        cmd_status(opts)
    elif cmd == "config":
        cmd_config()
    elif cmd == "set":
        cmd_set(args["pos"])
    elif cmd == "unset":
        cmd_unset(args["pos"])
    elif cmd == "defaults":
        cmd_defaults(opts)
    elif cmd in ("reset", "reset-key"):
        cmd_reset()
    elif cmd in ("version", "-v", "--version"):
        cmd_version()
    elif cmd in ("help", "-h", "--help"):
        cmd_help()
    else:
        err("unknown command: " + cmd)
        note(dim("try: ") + b("shotbot help"))
        sys.exit(2)

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.stderr.write("\n")
        sys.exit(130)
