#!/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 collections.abc
import html.parser
import os
import pathlib
import re
import shutil
import subprocess
import sys
import urllib.parse
import urllib.request

# ---------------------------------------------------------------------------
# Source config  (/etc/game_on/pkg-source-config.yml)
# ---------------------------------------------------------------------------
_SOURCE_CONFIG_PATH = pathlib.Path("/etc/game_on/pkg-source-config.yml")


def _read_source_config() -> dict[str, str]:
    """
    Parse /etc/game_on/pkg-source-config.yml for the two fields we need.
    Supports simple flat YAML only (no nested structures needed).
    Returns a dict with any of: download_url, key_fingerprint.
    """
    if not _SOURCE_CONFIG_PATH.exists():
        return {}
    result: dict[str, str] = {}
    for line in _SOURCE_CONFIG_PATH.read_text().splitlines():
        m = re.match(r'^(\w+):\s*["\']?([^"\' #\n]+)["\']?', line.strip())
        if m:
            result[m.group(1)] = m.group(2).strip()
    return result


_SOURCE_CONF = _read_source_config()

# ---------------------------------------------------------------------------
# Defaults - override via env vars for infra-wide configuration
# ---------------------------------------------------------------------------
DEFAULT_BASE_URL = os.environ.get(
    "GAMEON_PKG_BASE_URL",
    _SOURCE_CONF.get("download_url", "https://file.game-on.eu/gameondist"),
)
DEFAULT_CACHE_DIR = os.environ.get(
    "GAMEON_PKG_CACHE_DIR",
    ".vendor",
)
DEFAULT_BRANCH = os.environ.get("GAMEON_PKG_BRANCH", "main")
EXPECTED_KEY_FINGERPRINT: str | None = _SOURCE_CONF.get("key_fingerprint")
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(repo_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"{repo_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(repo_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(repo_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")


# ---------------------------------------------------------------------------
# GPG status verification helper
# ---------------------------------------------------------------------------
def _verify_gpg_status(
    status: str,
    context: str,
    on_fail: "collections.abc.Callable[[], None] | None" = None,
) -> None:
    """
    Check gpg --status-fd output for a valid signature from the expected key.
    If EXPECTED_KEY_FINGERPRINT is set, requires VALIDSIG with that fingerprint.
    Otherwise falls back to requiring GOODSIG from any trusted key.
    """
    if EXPECTED_KEY_FINGERPRINT:
        fingerprint = EXPECTED_KEY_FINGERPRINT.upper().replace(" ", "")
        if f"[GNUPG:] VALIDSIG {fingerprint}" not in status.upper():
            if on_fail:
                on_fail()
            print(
                f"[install.py] ERROR: {context} signature not from expected key "
                f"(fingerprint: {fingerprint}):\n{status}",
                file=sys.stderr,
            )
            sys.exit(1)
    else:
        if "[GNUPG:] GOODSIG" not in status and "[GNUPG:] VALIDSIG" not in status:
            if on_fail:
                on_fail()
            print(
                f"[install.py] ERROR: {context} signature verification failed — "
                "no GOODSIG in gpg status (hint: set key_fingerprint in "
                "/etc/game_on/pkg-source-config.yml):\n" + status,
                file=sys.stderr,
            )
            sys.exit(1)
    print(f"[install.py] {context} signature OK", file=sys.stderr)


# ---------------------------------------------------------------------------
# Download + decrypt
# ---------------------------------------------------------------------------
def fetch_and_decrypt(
    branch_url: str,
    version: str,
    cache_dir: pathlib.Path,
) -> pathlib.Path:
    remote_filename = wheel_name(version) + ".gpg"
    url = f"{branch_url.rstrip('/')}/{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",
            "--status-fd", "1",
            "--output", str(wheel_path),
            "--decrypt", str(encrypted_path),
        ],
        capture_output=True,
    )
    print(result.stdout.decode(errors="replace"), file=sys.stderr)  # gpg status/debug info
    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)

    status = result.stdout.decode(errors="replace")
    _verify_gpg_status(status, context="wheel", on_fail=lambda: wheel_path.unlink(missing_ok=True))

    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)
    repo_url = f"{args.base_url.rstrip('/')}/gameon-packaging"
    url = f"{repo_url}/{args.branch}"

    target_version = resolve_version(repo_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(url, 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()
