#!/usr/bin/env python3
"""
Aurelia Premium — command-line client
=====================================

Clean manga/webtoon pages from your terminal. No browser, no Discord.

Quick start:
    aurelia login               # prompts for email + password
    aurelia clean chapter.zip   # cleans a whole chapter
    aurelia clean page.jpg      # cleans a single page
    aurelia balance             # check your credit balance
    aurelia history             # recent batches
    aurelia logout              # clear stored credentials

Your auth token is saved to ~/.aurelia/config.json (chmod 600). Nothing
ever leaves your machine except the images you choose to clean.

Requirements:
    pip install requests rich

Or run with zero dependencies via plain http.client — see `_http` below.
"""

from __future__ import annotations

import argparse
import getpass
import io
import json
import os
import re
import sys
import time
import zipfile
from pathlib import Path
from typing import Any

# ── Config ──────────────────────────────────────────────────────────────────
API_BASE = os.environ.get("AURELIA_API", "https://aureliapremium.com")
CONFIG_DIR = Path(os.environ.get("AURELIA_CONFIG_DIR") or (Path.home() / ".aurelia"))
CONFIG_FILE = CONFIG_DIR / "config.json"
VERSION = "1.0.2"

IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}

# ── Terminal colors (no external deps) ──────────────────────────────────────
# ANSI codes. On Windows we enable VT via a call below.
class C:
    RESET = "\x1b[0m"
    DIM = "\x1b[2m"
    BOLD = "\x1b[1m"
    GREEN = "\x1b[38;5;114m"
    LAVENDER = "\x1b[38;5;183m"
    AMBER = "\x1b[38;5;222m"
    RED = "\x1b[38;5;203m"
    CYAN = "\x1b[38;5;117m"
    GREY = "\x1b[38;5;245m"
    MUTED = "\x1b[38;5;240m"

def _enable_vt_on_windows() -> None:
    if os.name != "nt":
        return
    try:
        import ctypes
        kernel32 = ctypes.windll.kernel32
        # STD_OUTPUT_HANDLE = -11, ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
        h = kernel32.GetStdHandle(-11)
        mode = ctypes.c_ulong()
        kernel32.GetConsoleMode(h, ctypes.byref(mode))
        kernel32.SetConsoleMode(h, mode.value | 0x0004)
    except Exception:
        pass

_enable_vt_on_windows()

def _is_tty() -> bool:
    try:
        return sys.stdout.isatty()
    except Exception:
        return False

if not _is_tty() or os.environ.get("NO_COLOR"):
    # Strip colors when piped
    for name in dir(C):
        if not name.startswith("_") and name.isupper():
            setattr(C, name, "")


# ── HTTP (stdlib) ───────────────────────────────────────────────────────────
import urllib.parse
import urllib.request
import urllib.error
import ssl
import mimetypes

def _http(
    method: str,
    path: str,
    *,
    json_body: dict | None = None,
    files: list[tuple[str, str, bytes, str]] | None = None,
    token: str | None = None,
    form: dict | None = None,
    timeout: float = 60,
) -> tuple[int, dict | str]:
    """Simple HTTP helper with optional multipart upload.
    files: list of (field_name, filename, bytes, mimetype)
    Returns (status_code, body). Body is dict if JSON, else str.
    """
    url = API_BASE.rstrip("/") + path
    headers = {"User-Agent": f"aurelia-cli/{VERSION}"}
    if token:
        headers["Authorization"] = f"Bearer {token}"

    body: bytes
    if files is not None:
        boundary = "----aurelia" + os.urandom(8).hex()
        headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
        parts: list[bytes] = []
        if form:
            for k, v in form.items():
                parts.append(f"--{boundary}\r\n".encode())
                parts.append(
                    f'Content-Disposition: form-data; name="{k}"\r\n\r\n'.encode()
                )
                parts.append(str(v).encode() + b"\r\n")
        for field, filename, data, mtype in files:
            parts.append(f"--{boundary}\r\n".encode())
            parts.append(
                f'Content-Disposition: form-data; name="{field}"; '
                f'filename="{filename}"\r\n'.encode()
            )
            parts.append(f"Content-Type: {mtype}\r\n\r\n".encode())
            parts.append(data + b"\r\n")
        parts.append(f"--{boundary}--\r\n".encode())
        body = b"".join(parts)
    elif json_body is not None:
        headers["Content-Type"] = "application/json"
        body = json.dumps(json_body).encode()
    elif form is not None:
        headers["Content-Type"] = "application/x-www-form-urlencoded"
        body = urllib.parse.urlencode(form).encode()
    else:
        body = b""

    req = urllib.request.Request(url, data=body if method != "GET" else None,
                                 method=method, headers=headers)
    ctx = ssl.create_default_context()
    try:
        with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
            raw = resp.read()
            ct = resp.headers.get("Content-Type", "")
            if "application/json" in ct:
                return resp.status, json.loads(raw.decode("utf-8"))
            return resp.status, raw.decode("utf-8", errors="replace")
    except urllib.error.HTTPError as e:
        raw = e.read()
        try:
            return e.code, json.loads(raw.decode("utf-8"))
        except Exception:
            return e.code, raw.decode("utf-8", errors="replace")
    except urllib.error.URLError as e:
        raise RuntimeError(f"Cannot reach {url}: {e.reason}")


# ── Config I/O ──────────────────────────────────────────────────────────────
def load_config() -> dict:
    if not CONFIG_FILE.exists():
        return {}
    try:
        return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
    except Exception:
        return {}

def save_config(cfg: dict) -> None:
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
    try:
        CONFIG_FILE.chmod(0o600)  # user-only read/write
    except Exception:
        pass  # Windows ACLs are separate; chmod is no-op

def clear_config() -> None:
    if CONFIG_FILE.exists():
        CONFIG_FILE.unlink()

def require_token() -> str:
    cfg = load_config()
    tok = cfg.get("token")
    if not tok:
        err("Not signed in. Run 'aurelia login' first.")
        sys.exit(1)
    return tok


# ── Printing ────────────────────────────────────────────────────────────────
def ok(msg: str) -> None: print(f"{C.GREEN}✓{C.RESET} {msg}")
def info(msg: str) -> None: print(f"{C.LAVENDER}›{C.RESET} {msg}")
def warn(msg: str) -> None: print(f"{C.AMBER}!{C.RESET} {msg}", file=sys.stderr)
def err(msg: str) -> None: print(f"{C.RED}✗{C.RESET} {msg}", file=sys.stderr)
def dim(s: str) -> str: return f"{C.DIM}{s}{C.RESET}"
def accent(s: str) -> str: return f"{C.LAVENDER}{s}{C.RESET}"
def bold(s: str) -> str: return f"{C.BOLD}{s}{C.RESET}"

def banner() -> None:
    logo = [
        f"{C.LAVENDER}  /\\  {C.RESET}",
        f"{C.LAVENDER} /  \\ {C.RESET} {C.BOLD}Aurelia Premium{C.RESET} {C.MUTED}CLI v{VERSION}{C.RESET}",
        f"{C.LAVENDER}/____\\{C.RESET}",
    ]
    print("\n" + "\n".join(logo) + "\n")


# ── Commands ────────────────────────────────────────────────────────────────
def cmd_login(args: argparse.Namespace) -> int:
    banner()
    print(f"  Aurelia signs you in with a {accent('Personal Access Token')} (PAT).")
    print(f"  Create one at {accent(API_BASE + '/account/profile')}")
    print()
    print(f"  Steps:")
    print(f"    {dim('1.')} Sign in at the URL above (Google login works)")
    print(f"    {dim('2.')} Scroll to {bold('API tokens')}, click {bold('Generate new token')}")
    print(f"    {dim('3.')} Copy the token (starts with {accent('apk_')})")
    print(f"    {dim('4.')} Paste it below")
    print()

    token = (args.token or "").strip()
    if not token:
        # Use visible input() rather than getpass — tokens are not passwords,
        # they're long random strings that need to be verified on paste.
        # (GitHub CLI, npm, and Docker all do it this way.)
        try:
            token = input("Token: ").strip()
        except (KeyboardInterrupt, EOFError):
            print()
            err("Cancelled.")
            return 130

    if not token:
        err("Token required.")
        return 1
    if not token.startswith("apk_"):
        err("That doesn't look like an Aurelia token. "
            "Expected something starting with 'apk_'.")
        return 1

    print(f"\n{dim('Verifying token...')}")
    try:
        code, body = _http("GET", "/api/auth/me.php", token=token)
    except RuntimeError as e:
        err(str(e))
        return 1

    if code == 401 or (isinstance(body, dict) and not body.get("ok")):
        err("Token invalid or revoked. Generate a new one from the web dashboard.")
        return 1
    if code != 200 or not isinstance(body, dict):
        err(f"Unexpected response: {body}")
        return 1

    user = body.get("user", {})
    save_config({
        "token": token,
        "email": user.get("email"),
        "user_id": user.get("id"),
        "display_name": user.get("display_name") or user.get("username"),
    })
    ok(f"Signed in as {accent(user.get('email', ''))}")
    print(f"  {dim('Credits:')} {bold(str(user.get('credit_balance', 0)))}")
    print(f"  {dim('Token saved to')} {CONFIG_FILE}")
    print(f"  {dim('Manage / revoke tokens at')} {API_BASE}/account/profile")
    return 0


def cmd_logout(_args: argparse.Namespace) -> int:
    clear_config()
    ok("Signed out. Local credentials cleared.")
    return 0


def cmd_whoami(_args: argparse.Namespace) -> int:
    tok = require_token()
    code, body = _http("GET", "/api/auth/me.php", token=tok)
    if code != 200 or not isinstance(body, dict) or not body.get("ok"):
        err("Could not fetch account.")
        return 1
    u = body.get("user", {})
    print(f"\n  {dim('Email:')}    {accent(u.get('email', ''))}")
    print(f"  {dim('Display:')}  {u.get('display_name') or u.get('username') or '—'}")
    print(f"  {dim('Credits:')}  {bold(str(u.get('credit_balance', 0)))}")
    print(f"  {dim('Role:')}     {u.get('role') or 'user'}")
    sub = body.get("subscription")
    if sub:
        print(f"  {dim('Plan:')}     {sub.get('tier')} ({sub.get('status')})")
    print()
    return 0


def cmd_balance(_args: argparse.Namespace) -> int:
    tok = require_token()
    code, body = _http("GET", "/api/auth/me.php", token=tok)
    if code != 200 or not isinstance(body, dict) or not body.get("ok"):
        err("Could not fetch balance.")
        return 1
    n = body.get("user", {}).get("credit_balance", 0)
    print(f"  {accent(str(n))} credits")
    return 0


def _collect_files(paths: list[str]) -> list[tuple[str, bytes]]:
    """Expand paths → list of (filename, bytes). Zips get unpacked in memory."""
    out: list[tuple[str, bytes]] = []
    for p in paths:
        pp = Path(p)
        if not pp.exists():
            err(f"Not found: {p}")
            continue
        if pp.is_file() and pp.suffix.lower() == ".zip":
            with zipfile.ZipFile(pp) as zf:
                members = [m for m in zf.namelist() if not m.endswith("/")]
                # Only images, sorted numerically when possible
                members = [m for m in members
                           if Path(m).suffix.lower() in IMAGE_EXTS
                           and not Path(m).name.startswith(".")]
                members.sort(key=_natural_key)
                for m in members:
                    out.append((Path(m).name, zf.read(m)))
        elif pp.is_file() and pp.suffix.lower() in IMAGE_EXTS:
            out.append((pp.name, pp.read_bytes()))
        elif pp.is_dir():
            for f in sorted(pp.rglob("*"), key=lambda x: _natural_key(str(x))):
                if f.is_file() and f.suffix.lower() in IMAGE_EXTS:
                    out.append((f.name, f.read_bytes()))
        else:
            warn(f"Skipping unsupported: {p}")
    return out


def _natural_key(s: str) -> list:
    return [int(t) if t.isdigit() else t.lower()
            for t in re.split(r"(\d+)", str(s))]


def _mimetype_for(name: str) -> str:
    return mimetypes.guess_type(name)[0] or "image/jpeg"


def _fmt_secs(s: float) -> str:
    s = int(s)
    if s < 60: return f"{s}s"
    if s < 3600: return f"{s//60}m{s%60:02d}s"
    return f"{s//3600}h{(s%3600)//60:02d}m"


def cmd_clean(args: argparse.Namespace) -> int:
    tok = require_token()
    paths = args.files
    if not paths:
        err("Usage: aurelia clean <file|dir|zip> [...]")
        return 1

    files = _collect_files(paths)
    total = len(files)
    if total == 0:
        err("No images found.")
        return 1

    # Header matching the hero screenshot
    source_label = Path(paths[0]).name if len(paths) == 1 else f"{len(paths)} inputs"
    print()
    print(f"  {dim('$')} aurelia clean {source_label} {dim('────')} {total} pages")

    # Upload in chunks of 10 (matches web UI's Cloudflare-friendly size)
    CHUNK = 10
    t0 = time.time()
    batch_id: str | None = None
    uploaded = 0

    # ── Stage 1: analyzing (upload) ──
    line = _Line(f"{dim('>')} analyzing...      ")
    for ci in range(0, total, CHUNK):
        chunk = files[ci:ci + CHUNK]
        file_parts = [
            ("files[]", name, data, _mimetype_for(name))
            for name, data in chunk
        ]
        form = {"batch_id": batch_id} if batch_id else None
        code, body = _http("POST", "/api/clean-submit.php",
                           files=file_parts, form=form, token=tok, timeout=180)
        if code == 402:
            line.finish_fail()
            err(f"Insufficient credits: balance={body.get('balance')}, needed={body.get('needed')}")
            print(f"  {dim('Top up at')} {API_BASE}/pricing")
            return 402
        if code == 401:
            line.finish_fail()
            err("Not authorized. Run 'aurelia login' again.")
            return 1
        if code != 200 or not isinstance(body, dict) or not body.get("ok"):
            line.finish_fail()
            err(f"Upload failed: {(body or {}).get('error', 'unknown')}")
            return 1
        batch_id = body.get("batch_id") or batch_id
        uploaded += len(chunk)
        elapsed = time.time() - t0
        line.update(f"{dim('>')} analyzing...      "
                    f"{dim(f'({uploaded}/{total} pg · {elapsed:.1f}s)')}")

    elapsed = time.time() - t0
    line.finish_ok(f"{C.GREEN}✓{C.RESET} analyzing...      "
                   f"{dim(f'({total}/{total} pg · {elapsed:.1f}s)')}")

    if not batch_id:
        err("No batch_id returned.")
        return 1

    # ── Stage 2: cleaning (poll progress) ──
    t1 = time.time()
    line = _Line(f"{dim('>')} cleaning...       ")
    last_done = 0
    while True:
        code, body = _http("GET", f"/api/clean-progress.php?batch_id={batch_id}",
                           token=tok, timeout=15)
        if code != 200 or not isinstance(body, dict):
            line.update(f"{dim('>')} cleaning...       {dim('(reconnecting...)')}")
            time.sleep(2)
            continue
        status = body.get("status", "")
        done = int(body.get("pages_completed", 0))
        failed = int(body.get("pages_failed", 0))
        elapsed = time.time() - t1
        line.update(
            f"{dim('>')} cleaning...       "
            f"{dim(f'({done}/{total} pg · {elapsed:.1f}s)')}"
            + (f" {C.AMBER}{failed} failed{C.RESET}" if failed else "")
        )
        if status in ("completed", "failed", "partial"):
            break
        if done == last_done:
            # Nothing moved — small backoff
            time.sleep(1.5)
        else:
            last_done = done
            time.sleep(0.8)

    elapsed = time.time() - t1
    line.finish_ok(
        f"{C.GREEN}✓{C.RESET} cleaning...       "
        f"{dim(f'({done}/{total} pg · {elapsed:.1f}s)')}"
    )

    # ── Stage 3: finishing (download results) ──
    t2 = time.time()
    line = _Line(f"{dim('>')} finishing...      ")
    code, results = _http("GET", f"/api/clean-results.php?batch_id={batch_id}",
                          token=tok, timeout=60)
    if code != 200 or not isinstance(results, dict):
        line.finish_fail()
        err(f"Could not fetch results: {results}")
        return 1

    out_dir = Path(args.out) if args.out else Path.cwd() / f"aurelia_{batch_id}"
    out_dir.mkdir(parents=True, exist_ok=True)

    downloaded = 0
    res_list = results.get("results", [])
    # Same-origin downloads (e.g. /api/merge-patches.php) need the Bearer token.
    # Presigned S3 URLs already authenticate via the URL signature.
    api_host = urllib.parse.urlparse(API_BASE).netloc
    for r in res_list:
        url = r.get("image_url")
        name = r.get("filename") or f"page_{downloaded+1}.png"
        if not url:
            continue
        headers: dict[str, str] = {"User-Agent": f"aurelia-cli/{VERSION}"}
        try:
            target = urllib.parse.urlparse(url)
            if target.netloc == api_host:
                headers["Authorization"] = f"Bearer {tok}"
        except Exception:
            pass
        try:
            req = urllib.request.Request(url, headers=headers)
            with urllib.request.urlopen(req, timeout=60) as resp:
                (out_dir / name).write_bytes(resp.read())
            downloaded += 1
            elapsed = time.time() - t2
            line.update(
                f"{dim('>')} finishing...      "
                f"{dim(f'({downloaded}/{len(res_list)} pg · {elapsed:.1f}s)')}"
            )
        except urllib.error.HTTPError as e:
            warn(f"Failed to download {name}: HTTP {e.code}")
        except Exception as e:
            warn(f"Failed to download {name}: {e}")

    elapsed = time.time() - t2
    line.finish_ok(
        f"{C.GREEN}✓{C.RESET} finishing...      "
        f"{dim(f'({downloaded}/{len(res_list)} pg · {elapsed:.1f}s)')}"
    )

    total_elapsed = time.time() - t0
    print(f"  {dim(f'eta 0s  ·  done  ({_fmt_secs(total_elapsed)} total)')}")
    print()
    print(f"  {C.LAVENDER}Saved to:{C.RESET} {out_dir}")
    print(f"  {dim('Batch ID:')} {batch_id}")
    if results.get("new_balance") is not None:
        print(f"  {dim('Balance:')}  {accent(str(results['new_balance']))} credits")
    print()
    return 0


class _Line:
    """Update-in-place terminal line. Falls back to normal prints in non-TTY."""

    def __init__(self, initial: str):
        self._last = ""
        self._tty = _is_tty()
        self.update(initial)

    def update(self, s: str):
        if self._tty:
            # Clear line, then write
            sys.stdout.write("\r\x1b[2K  " + s)
            sys.stdout.flush()
        else:
            if s != self._last:
                print("  " + s)
        self._last = s

    def finish_ok(self, s: str):
        if self._tty:
            sys.stdout.write("\r\x1b[2K  " + s + "\n")
            sys.stdout.flush()
        else:
            print("  " + s)

    def finish_fail(self):
        if self._tty:
            sys.stdout.write("\r\x1b[2K")
            sys.stdout.flush()


def cmd_status(args: argparse.Namespace) -> int:
    tok = require_token()
    code, body = _http("GET", f"/api/clean-progress.php?batch_id={args.batch_id}",
                       token=tok)
    if code != 200:
        err(f"Could not fetch status: {body}")
        return 1
    print(json.dumps(body, indent=2))
    return 0


def cmd_history(_args: argparse.Namespace) -> int:
    # We don't have a dedicated history endpoint yet; just print a hint.
    tok = require_token()
    info("Open your dashboard to see full history:")
    print(f"  {API_BASE}/account/")
    _ = tok  # silence unused
    return 0


def cmd_register(_args: argparse.Namespace) -> int:
    """Open the browser to the sign-up flow and show next steps."""
    banner()
    url = f"{API_BASE}/account/login?next=/account/profile"
    print(f"  Opening {accent(url)} in your browser...")
    print()
    print(f"  Once signed in (Google works), scroll to {bold('API tokens')},")
    print(f"  click {bold('Generate new token')}, then run:")
    print()
    print(f"    {accent('aurelia login')}")
    print()
    import webbrowser
    try:
        webbrowser.open(url, new=2)
        ok("Browser opened.")
    except Exception:
        warn("Could not open browser automatically.")
        print(f"  {dim('Copy this URL:')} {url}")
    return 0


# ── Self-update ─────────────────────────────────────────────────────────────
VERSION_CACHE_FILE = CONFIG_DIR / "version-check.json"
VERSION_CACHE_TTL  = 86400  # 24h

def _parse_version(s: str) -> tuple[int, ...]:
    """'1.2.3' -> (1,2,3). Non-numeric parts become 0 for safe comparison."""
    parts = []
    for p in (s or "").split("."):
        try: parts.append(int(re.sub(r"[^\d].*$", "", p)))
        except Exception: parts.append(0)
    return tuple(parts) if parts else (0,)


def _fetch_latest_version_sync(timeout: float = 4) -> str | None:
    try:
        code, body = _http("GET", "/download.php?f=version", timeout=timeout)
        if code == 200 and isinstance(body, dict):
            return body.get("version")
    except Exception:
        pass
    return None


def _read_cached_latest() -> tuple[str | None, bool]:
    """Return (cached_version, stale_flag). stale=True means we should refresh."""
    try:
        c = json.loads(VERSION_CACHE_FILE.read_text(encoding="utf-8"))
        age = int(time.time()) - int(c.get("checked_at", 0))
        return c.get("latest"), age >= VERSION_CACHE_TTL
    except Exception:
        return None, True


def _write_cached_latest(latest: str) -> None:
    try:
        CONFIG_DIR.mkdir(parents=True, exist_ok=True)
        VERSION_CACHE_FILE.write_text(
            json.dumps({"latest": latest, "checked_at": int(time.time())}),
            encoding="utf-8",
        )
    except Exception:
        pass


def _schedule_update_check() -> None:
    """Fire-and-forget background refresh of the cached latest version.
    Never blocks the main command — results show up on the NEXT run.
    """
    import threading
    def run():
        latest = _fetch_latest_version_sync()
        if latest: _write_cached_latest(latest)
    threading.Thread(target=run, daemon=True).start()


def _print_update_banner_if_any() -> None:
    """If a newer version is cached, print a one-line notification."""
    cached, stale = _read_cached_latest()
    # Kick off a refresh if the cache is stale; doesn't affect this run.
    if stale: _schedule_update_check()
    if not cached: return
    try:
        if _parse_version(cached) > _parse_version(VERSION):
            print()
            print(f"  {accent('↑')} Update available: "
                  f"{dim(VERSION)} {C.LAVENDER}→{C.RESET} {bold(cached)}  "
                  f"{dim('run')} {accent('aurelia update')}")
    except Exception:
        pass


def cmd_update(_args: argparse.Namespace) -> int:
    """In-place self-update. Replaces this very script file."""
    banner()
    print(f"  {dim('Checking for the latest version...')}")

    try:
        code, body = _http("GET", "/download.php?f=aurelia", timeout=30)
    except RuntimeError as e:
        err(str(e)); return 1

    if code != 200 or not isinstance(body, str) or not body.startswith("#!"):
        err(f"Could not fetch latest script (HTTP {code}).")
        return 1

    # Parse VERSION string from the downloaded text
    m = re.search(r'^VERSION\s*=\s*"([^"]+)"', body, re.MULTILINE)
    latest = m.group(1) if m else None
    if not latest:
        warn("Could not detect new version number. Proceeding anyway.")
        latest = "?"

    if latest != "?" and _parse_version(latest) <= _parse_version(VERSION):
        ok(f"Already up to date ({bold(VERSION)}).")
        _write_cached_latest(VERSION)
        return 0

    # Find the currently-running script. Prefer __file__ (real on-disk path).
    try:
        target = Path(__file__).resolve()
    except Exception:
        err("Can't determine script location; cannot self-update.")
        print(f"  {dim('Re-run the installer:')}")
        print(f"    curl -sL {API_BASE}/download.php?f=install | bash   {dim('# mac/linux')}")
        print(f"    irm {API_BASE}/download.php?f=install-ps1 | iex     {dim('# windows ps')}")
        return 1

    # Atomic-ish replace: write next to the original, then move.
    try:
        tmp = target.with_suffix(target.suffix + ".new")
        tmp.write_text(body, encoding="utf-8")
        # On Windows, os.replace() handles the atomic rename even over an
        # existing file that's currently open by this very process (Python
        # loaded the script at startup, so the file handle is closed).
        os.replace(tmp, target)
        try: target.chmod(0o755)  # keep exec bit on unix; no-op on Windows
        except Exception: pass
    except PermissionError as e:
        err(f"Permission denied writing to {target}: {e}")
        print(f"  {dim('Try re-running the installer — it handles sudo / UAC:')}")
        print(f"    curl -sL {API_BASE}/download.php?f=install | bash   {dim('# mac/linux')}")
        print(f"    irm {API_BASE}/download.php?f=install-ps1 | iex     {dim('# windows ps')}")
        return 1
    except Exception as e:
        err(f"Update failed: {e}")
        return 1

    _write_cached_latest(latest)
    ok(f"Updated {dim(VERSION)} {C.LAVENDER}→{C.RESET} {bold(latest)}")
    print(f"  {dim('Installed at')} {target}")
    return 0


# ── Entry point ─────────────────────────────────────────────────────────────
def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(
        prog="aurelia",
        description="Clean manga pages from the command line. "
                    f"Default API: {API_BASE}",
    )
    p.add_argument("--version", action="version", version=f"aurelia-cli {VERSION}")
    sub = p.add_subparsers(dest="cmd", required=True)

    sub.add_parser("register",
        help="Open the sign-up page in your browser and show next steps")

    p_login = sub.add_parser("login",
        help="Sign in with a Personal Access Token (create at /account/profile)")
    p_login.add_argument("--token", help="API token (prompt if omitted)")

    sub.add_parser("logout", help="Clear stored credentials")
    sub.add_parser("whoami", help="Show current account")
    sub.add_parser("balance", help="Show credit balance")

    p_clean = sub.add_parser("clean", help="Clean pages (files, folders, or zips)")
    p_clean.add_argument("files", nargs="+", help="Files, folders, or .zip archives")
    p_clean.add_argument("-o", "--out", help="Output folder (default: ./aurelia_<batch_id>)")

    p_stat = sub.add_parser("status", help="Check a batch's status")
    p_stat.add_argument("batch_id")

    sub.add_parser("history", help="Open your dashboard for full history")
    sub.add_parser("update", help="Self-update to the latest version")

    args = p.parse_args(argv)

    commands = {
        "register": cmd_register,
        "login":    cmd_login,
        "logout":   cmd_logout,
        "whoami":   cmd_whoami,
        "balance":  cmd_balance,
        "clean":    cmd_clean,
        "status":   cmd_status,
        "history":  cmd_history,
        "update":   cmd_update,
    }
    try:
        rc = commands[args.cmd](args)
    except KeyboardInterrupt:
        print()
        err("Interrupted.")
        return 130

    # Subtle update banner at end of run — doesn't block anything,
    # skipped if the user is already running `update`.
    if args.cmd != "update":
        _print_update_banner_if_any()

    return rc


if __name__ == "__main__":
    sys.exit(main())
