#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
gameon-packaging self-installer.

Hosted on the public file server alongside the encrypted wheels.
Pure stdlib — no external dependencies.

Usage
-----
    uv run install.py [options]

Prints the resolved local wheel path to stdout on success so callers can
capture it:

    WHEEL=$(python3 install.py --cache-dir .vendor --branch develop)
    uv run --find-links .vendor --script bootstrap.py

File server layout expected
---------------------------
    <base-url>/
        <branch>/                           # branch name may contain /
            gameon_packaging-1.4.2.dev7-py3-none-any.whl.gpg
            gameon_packaging-1.4.2.dev6-py3-none-any.whl.gpg
        main/
            gameon_packaging-1.4.2-py3-none-any.whl.gpg
        feature/some-feature/
            gameon_packaging-1.4.3.dev1-py3-none-any.whl.gpg
        install.py                          # this file

The file server must support directory listing (HTML href listing is parsed
to discover available wheels).  No version manifest files are needed — the
latest version is determined by parsing and sorting the filenames in the
branch directory.
"""

import argparse
import html.parser
import os
import pathlib
import re
import shutil
import subprocess
import sys
import urllib.parse
import urllib.request

# ---------------------------------------------------------------------------
# Defaults - override via env vars for infra-wide configuration
# ---------------------------------------------------------------------------
DEFAULT_BASE_URL = os.environ.get(
    "GAMEON_PKG_BASE_URL",
    "https://pkg.gameon.example/gameon-packaging",  # replace at deploy time
)
DEFAULT_CACHE_DIR = os.environ.get(
    "GAMEON_PKG_CACHE_DIR",
    ".vendor",
)
DEFAULT_BRANCH = os.environ.get("GAMEON_PKG_BRANCH", "main")
STAMP_FILENAME = ".gameon_pkg_version"
_WHL_GPG_RE = re.compile(
    r"^gameon_packaging-(?P<ver>[^-]+)-py3-none-any\.whl(\.gpg)?$"
)


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "--branch",
        dest="branch",
        default=DEFAULT_BRANCH,
        help=(
            "branch directory on the file server; may contain '/' "
            f"(default: {DEFAULT_BRANCH})"
        ),
    )
    parser.add_argument(
        "--version",
        dest="version",
        default=None,
        help=(
            "pin to a specific version, e.g. '1.4.2'; "
            "skips directory listing and downloads directly"
        ),
    )
    parser.add_argument(
        "--cache-dir",
        dest="cache_dir",
        default=DEFAULT_CACHE_DIR,
        help=f"local directory to store the decrypted wheel (default: {DEFAULT_CACHE_DIR})",
    )
    parser.add_argument(
        "--base-url",
        dest="base_url",
        default=DEFAULT_BASE_URL,
        help=f"file server base URL (default: {DEFAULT_BASE_URL})",
    )
    parser.add_argument(
        "--force",
        dest="force",
        action="store_true",
        help="re-download even if the local stamp already matches the target version",
    )
    return parser.parse_args()


# ---------------------------------------------------------------------------
# Version helpers  (pure stdlib, no external dependencies)
# ---------------------------------------------------------------------------
def _parse_version(ver: str) -> tuple:
    """
    Return a sortable tuple for setuptools-scm guess-next-dev versions.

    The only two formats produced by gameon-packaging's version scheme are:
        1.2.3          → stable exact tag
        1.2.4.devN     → N commits after 1.2.3 tag (heading toward 1.2.4)

    Sort behaviour mirrors PEP 440: 1.2.3 < 1.2.4.devN < 1.2.4.
    """
    m = re.match(
        r"^(?P<release>\d+(?:\.\d+)*)"
        r"(?:\.dev(?P<dev_n>\d+))?",
        ver,
    )
    if not m:
        return ((0,), 0)
    release = tuple(int(x) for x in m.group("release").split("."))
    # dev releases sort before the release they target (PEP 440)
    dev_n = int(m.group("dev_n")) if m.group("dev_n") is not None else None
    has_dev = 0 if dev_n is None else -1
    return (release, has_dev, dev_n or 0)


def wheel_name(version: str) -> str:
    return f"gameon_packaging-{version}-py3-none-any.whl"


# ---------------------------------------------------------------------------
# Directory listing
# ---------------------------------------------------------------------------
class _HrefCollector(html.parser.HTMLParser):
    def __init__(self) -> None:
        super().__init__()
        self.hrefs: list[str] = []

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        if tag == "a":
            for name, val in attrs:
                if name == "href" and val:
                    self.hrefs.append(val)


def list_remote_versions(base_url: str, branch: str) -> list[str]:
    """
    Fetch the directory listing for *branch* and return all available version
    strings, sorted from oldest to newest.
    """
    url = f"{base_url.rstrip('/')}/{branch}/"
    print(f"[install.py] listing {url}", file=sys.stderr)
    try:
        with urllib.request.urlopen(url, timeout=30) as resp:
            html_body = resp.read().decode(errors="replace")
    except Exception as exc:
        print(f"[install.py] ERROR: could not list {url}: {exc}", file=sys.stderr)
        sys.exit(1)

    parser = _HrefCollector()
    parser.feed(html_body)

    versions: list[str] = []
    for href in parser.hrefs:
        filename = urllib.parse.urlparse(href).path.rstrip("/").split("/")[-1]
        m = _WHL_GPG_RE.match(filename)
        if m:
            versions.append(m.group("ver"))

    if not versions:
        print(
            f"[install.py] ERROR: no .whl.gpg files found in {url}",
            file=sys.stderr,
        )
        sys.exit(1)

    versions.sort(key=_parse_version)
    return versions


def resolve_version(base_url: str, branch: str, pinned: str | None) -> str:
    """Return the target version: pinned if given, otherwise latest from listing."""
    if pinned:
        return pinned.strip()
    versions = list_remote_versions(base_url, branch)
    latest = versions[-1]
    print(f"[install.py] available: {versions}", file=sys.stderr)
    return latest


# ---------------------------------------------------------------------------
# Stamp helpers
# ---------------------------------------------------------------------------
def read_stamp(cache_dir: pathlib.Path) -> str | None:
    stamp = cache_dir / STAMP_FILENAME
    if stamp.exists():
        return stamp.read_text().strip() or None
    return None


def write_stamp(cache_dir: pathlib.Path, version: str) -> None:
    (cache_dir / STAMP_FILENAME).write_text(version + "\n")


# ---------------------------------------------------------------------------
# Download + decrypt
# ---------------------------------------------------------------------------
def fetch_and_decrypt(
    base_url: str,
    branch: str,
    version: str,
    cache_dir: pathlib.Path,
) -> pathlib.Path:
    remote_filename = wheel_name(version) + ".gpg"
    url = f"{base_url.rstrip('/')}/{branch}/{remote_filename}"
    encrypted_path = cache_dir / remote_filename
    wheel_path = cache_dir / wheel_name(version)

    print(f"[install.py] downloading {url}", file=sys.stderr)
    try:
        urllib.request.urlretrieve(url, encrypted_path)
    except Exception as exc:
        print(f"[install.py] ERROR: download failed: {exc}", file=sys.stderr)
        sys.exit(1)

    print(f"[install.py] decrypting {encrypted_path.name}", file=sys.stderr)
    if not shutil.which("gpg"):
        print("[install.py] ERROR: 'gpg' not found on PATH", file=sys.stderr)
        sys.exit(1)

    result = subprocess.run(
        [
            "gpg", "--batch", "--yes",
            "--output", str(wheel_path),
            "--decrypt", str(encrypted_path),
        ],
        stderr=subprocess.PIPE,
    )

    encrypted_path.unlink(missing_ok=True)  # always remove the encrypted blob

    if result.returncode != 0:
        print(
            "[install.py] ERROR: gpg decryption failed:\n"
            + result.stderr.decode(errors="replace"),
            file=sys.stderr,
        )
        sys.exit(1)

    return wheel_path


# ---------------------------------------------------------------------------
# Stale wheel cleanup
# ---------------------------------------------------------------------------
def remove_old_wheels(cache_dir: pathlib.Path, keep_version: str) -> None:
    keep = wheel_name(keep_version)
    for whl in cache_dir.glob("gameon_packaging-*.whl"):
        if whl.name != keep:
            print(f"[install.py] removing old wheel {whl.name}", file=sys.stderr)
            whl.unlink()


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
    args = parse_args()
    cache_dir = pathlib.Path(args.cache_dir).resolve()
    cache_dir.mkdir(parents=True, exist_ok=True)

    target_version = resolve_version(args.base_url, args.branch, args.version)
    print(f"[install.py] target version: {target_version}", file=sys.stderr)

    wheel_path = cache_dir / wheel_name(target_version)

    if not args.force and read_stamp(cache_dir) == target_version and wheel_path.exists():
        print(
            f"[install.py] already up to date ({target_version}), skipping download",
            file=sys.stderr,
        )
        print(str(wheel_path))
        return

    fetch_and_decrypt(args.base_url, args.branch, target_version, cache_dir)
    remove_old_wheels(cache_dir, target_version)
    write_stamp(cache_dir, target_version)

    print(f"[install.py] installed {wheel_path.name}", file=sys.stderr)
    print(str(wheel_path))


# ---------------------------------------------------------------------------
# Self-test  (python3 install.py --test)
# ---------------------------------------------------------------------------
def _test_ordering() -> list[str]:
    failures: list[str] = []
    pairs = [
        ("1.0.0",       "1.2.3"),
        ("1.2.3",       "1.2.4.dev1"),
        ("1.2.4.dev5",  "1.2.4.dev17"),
        ("1.2.4.dev17", "1.2.4"),
        ("1.2.4",       "2.0.0.dev1"),
    ]
    for a, b in pairs:
        if not (_parse_version(a) < _parse_version(b)):
            failures.append(f"FAIL: expected {a!r} < {b!r}")
    return failures


def _test_regex() -> list[str]:
    failures: list[str] = []
    should_match = {
        "gameon_packaging-1.2.3-py3-none-any.whl":          "1.2.3",
        "gameon_packaging-1.2.4.dev5-py3-none-any.whl":     "1.2.4.dev5",
        "gameon_packaging-1.2.4.dev5-py3-none-any.whl.gpg": "1.2.4.dev5",
        "gameon_packaging-2.0.0.dev1-py3-none-any.whl.gpg": "2.0.0.dev1",
    }
    for fname, expected_ver in should_match.items():
        m = _WHL_GPG_RE.match(fname)
        if not m:
            failures.append(f"FAIL: regex did not match {fname!r}")
        elif m.group("ver") != expected_ver:
            failures.append(
                f"FAIL: {fname!r} -> ver={m.group('ver')!r}, expected {expected_ver!r}"
            )
    should_not_match = [
        "install.py",
        "gameon_packaging-1.2.3-cp311-cp311-linux_x86_64.whl",
        "other_package-1.2.3-py3-none-any.whl",
    ]
    for fname in should_not_match:
        if _WHL_GPG_RE.match(fname):
            failures.append(f"FAIL: regex unexpectedly matched {fname!r}")
    return failures


def _test_sort() -> list[str]:
    unsorted = ["1.2.4.dev17", "1.0.0", "1.2.4", "1.2.4.dev5", "1.2.3", "2.0.0.dev1"]
    expected = ["1.0.0", "1.2.3", "1.2.4.dev5", "1.2.4.dev17", "1.2.4", "2.0.0.dev1"]
    got = sorted(unsorted, key=_parse_version)
    if got != expected:
        return [f"FAIL: sort order wrong\n  got:      {got}\n  expected: {expected}"]
    return []


def _test() -> None:
    failures = _test_ordering() + _test_regex() + _test_sort()
    if failures:
        for f in failures:
            print(f, file=sys.stderr)
        sys.exit(1)
    print("All tests passed.")


if __name__ == "__main__":
    if len(sys.argv) == 2 and sys.argv[1] == "--test":
        _test()
    else:
        main()
