#!/usr/bin/env python3
"""Download the pinned browser libraries into the static vendor directory.

LLeMbas has no Node toolchain and loads nothing from a CDN at runtime -- a
self-hosted tool should keep working without internet access, and should not
report every user's page view to a third party. The few libraries it does use
are fetched once, here, and committed.

Integrity is enforced with vendor.lock.json. A mismatched hash aborts rather
than overwriting, and so does a name that is not in the lock at all: that is
the whole point of pinning.

    python scripts/fetch_vendor.py            # fetch and verify against the lock
    python scripts/fetch_vendor.py --update   # re-pin after a version bump
"""

from __future__ import annotations

import argparse
import hashlib
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
VENDOR_DIR = ROOT / "src" / "lembas" / "web" / "static" / "vendor"
LOCKFILE = Path(__file__).resolve().parent / "vendor.lock.json"

# Pinned deliberately. Bump the version, run with --update, review the diff.
PACKAGES = {
    "htmx.min.js": {
        "version": "2.0.10",
        "url": "https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js",
        "why": "Server-rendered interactivity: every swap in the app.",
    },
    "htmx-ext-sse.js": {
        "version": "2.2.4",
        "url": "https://unpkg.com/htmx-ext-sse@2.2.4/sse.js",
        "why": "Server-sent events, which is how streamed replies reach the page.",
    },
    "alpine.min.js": {
        "version": "3.15.12",
        "url": "https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js",
        "why": "Small client-only state: menus, theme toggle, composer autosize.",
    },
    "xterm.js": {
        "version": "5.5.0",
        "url": "https://unpkg.com/@xterm/xterm@5.5.0/lib/xterm.js",
        "why": "The terminal panel. Loaded only on a chat that has an SSH connection.",
    },
    "xterm.css": {
        "version": "5.5.0",
        "url": "https://unpkg.com/@xterm/xterm@5.5.0/css/xterm.css",
        "why": "Terminal layout. Its colours are overridden from tokens.css at runtime.",
    },
    "xterm-addon-fit.js": {
        "version": "0.10.0",
        "url": "https://unpkg.com/@xterm/addon-fit@0.10.0/lib/addon-fit.js",
        "why": "Sizes the terminal to the panel; without it a resize is 80x24 forever.",
    },
}


def sha256(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def fetch(url: str) -> bytes:
    request = urllib.request.Request(url, headers={"User-Agent": "lembas-vendor-fetch"})
    with urllib.request.urlopen(request, timeout=60) as response:  # noqa: S310
        return response.read()


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--update",
        action="store_true",
        help="rewrite vendor.lock.json with the hashes just downloaded",
    )
    args = parser.parse_args()

    lock = json.loads(LOCKFILE.read_text()) if LOCKFILE.exists() else {}
    VENDOR_DIR.mkdir(parents=True, exist_ok=True)

    new_lock: dict[str, dict[str, str]] = {}
    failed = False

    for filename, spec in PACKAGES.items():
        try:
            payload = fetch(spec["url"])
        except (urllib.error.URLError, TimeoutError) as exc:
            print(f"  FAIL {filename}: {exc}", file=sys.stderr)
            failed = True
            continue

        digest = sha256(payload)
        expected = lock.get(filename, {}).get("sha256")

        if lock and not expected and not args.update:
            # A name added to PACKAGES but absent from the lock has nothing to
            # compare against, so the mismatch branch below never fires and the
            # file lands unpinned -- which is the one thing this script exists
            # to prevent. Adding a library is a --update, like bumping one.
            print(
                f"  FAIL {filename}: not in {LOCKFILE.name}\n"
                f"       Nothing to verify this download against. If the "
                f"library was added deliberately, re-run with --update.",
                file=sys.stderr,
            )
            failed = True
            continue

        if expected and digest != expected and not args.update:
            print(
                f"  FAIL {filename}: hash mismatch\n"
                f"       expected {expected}\n"
                f"       received {digest}\n"
                f"       Refusing to overwrite. If the version was bumped "
                f"deliberately, re-run with --update.",
                file=sys.stderr,
            )
            failed = True
            continue

        (VENDOR_DIR / filename).write_bytes(payload)
        new_lock[filename] = {
            "version": spec["version"],
            "url": spec["url"],
            "sha256": digest,
        }
        status = "ok" if expected == digest else ("pinned" if args.update else "new")
        print(f"  {status:>6}  {filename}  {len(payload):>8,} bytes  v{spec['version']}")

    if failed:
        print("\nOne or more downloads failed. Vendored files were not fully written.")
        return 1

    if args.update or not LOCKFILE.exists():
        LOCKFILE.write_text(json.dumps(new_lock, indent=2, sort_keys=True) + "\n")
        print(f"\nwrote {LOCKFILE.relative_to(ROOT)}")

    return 0


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