#!/usr/bin/env python3
"""Create a GitLab Release on gitlab.com from this machine.

Uploads every file in `binaries/` and `target/wheels/` to the project's
Generic Package Registry, then creates (or replaces) a Release with
`assets.links` pointing at those uploaded files.

Why not run the existing `release:gitlab` job locally? That job uses
`release-cli` and the asset links it writes point at gitlab.com job
artifacts (`/-/jobs/artifacts/...`). Those URLs only resolve after a real
CI run on gitlab.com. The Generic Package Registry path produces stable
URLs we control, so we can build everything locally and still get a
working Release page on gitlab.com.

Auth: a Personal Access Token or Project Access Token with the `api`
scope, read from `.gitlab-ci-local-variables.yml` (key: `GITLAB_TOKEN`)
or the `GITLAB_TOKEN` environment variable.
"""

from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any

import yaml


REPO_ROOT = Path(__file__).resolve().parent.parent
BINARIES_DIR = REPO_ROOT / "binaries"
WHEELS_DIR = REPO_ROOT / "target" / "wheels"
LOCAL_VARS = REPO_ROOT / ".gitlab-ci-local-variables.yml"
HOME_VARS = Path.home() / ".gitlab-ci-local" / "variables.yml"
GITLAB_CI_YAML = REPO_ROOT / ".gitlab-ci.yml"
PACKAGE_NAME = "pyaxml"


# !reference [.anchor, key] support: we don't actually use any of those
# inside `release:gitlab.release`, but PyYAML chokes on the custom tag.
# Register a noop constructor so safe_load doesn't fail.
class _CIYamlLoader(yaml.SafeLoader):
    pass


def _reference_ctor(loader: yaml.Loader, node: yaml.Node) -> Any:
    return None


_CIYamlLoader.add_constructor("!reference", _reference_ctor)


def _read_token() -> str:
    for path in (LOCAL_VARS, HOME_VARS):
        if path.exists():
            data = yaml.safe_load(path.read_text()) or {}
            if isinstance(data, dict) and data.get("GITLAB_TOKEN"):
                return str(data["GITLAB_TOKEN"])
    return os.environ.get("GITLAB_TOKEN", "")


def _git(*args: str) -> str:
    return subprocess.check_output(
        ["git", "-C", str(REPO_ROOT), *args], text=True, stderr=subprocess.DEVNULL
    ).strip()


def _origin() -> tuple[str, str]:
    """Return (server_url, project_path) e.g. ('https://gitlab.com', 'group/proj')."""
    url = _git("remote", "get-url", "origin")
    if url.startswith("git@"):
        host, path = url[4:].split(":", 1)
        url = f"https://{host}/{path}"
    if url.endswith(".git"):
        url = url[:-4]
    scheme, rest = url.split("://", 1)
    host, path = rest.split("/", 1)
    return f"{scheme}://{host}", path


def _detect_tag() -> str:
    out = _git("tag", "--points-at", "HEAD")
    if out:
        return sorted(out.splitlines())[-1]
    try:
        return _git("describe", "--tags", "--abbrev=0")
    except subprocess.CalledProcessError:
        return ""


def _api(
    method: str, url: str, token: str, *,
    data: bytes | None = None, content_type: str | None = None,
) -> bytes:
    req = urllib.request.Request(url, method=method, data=data)
    req.add_header("PRIVATE-TOKEN", token)
    if content_type:
        req.add_header("Content-Type", content_type)
    try:
        with urllib.request.urlopen(req) as resp:
            return resp.read()
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "replace")
        raise SystemExit(f"GitLab API {method} {url} → {e.code}\n{body}") from e


def upload_to_registry(
    server: str, project_id: str, version: str, file_path: Path, token: str,
) -> str:
    """PUT a file into the Generic Package Registry. Returns its download URL."""
    fn = file_path.name
    url = (
        f"{server}/api/v4/projects/{project_id}/packages/generic/"
        f"{urllib.parse.quote(PACKAGE_NAME)}/{urllib.parse.quote(version)}/"
        f"{urllib.parse.quote(fn)}"
    )
    size = file_path.stat().st_size
    print(f"  ↑ {fn:<55} {size:>10,} bytes")
    _api("PUT", url, token, data=file_path.read_bytes(),
         content_type="application/octet-stream")
    return url


def _release_exists(server: str, project_id: str, tag: str, token: str) -> bool:
    url = f"{server}/api/v4/projects/{project_id}/releases/{urllib.parse.quote(tag, safe='')}"
    req = urllib.request.Request(url, method="GET")
    req.add_header("PRIVATE-TOKEN", token)
    try:
        urllib.request.urlopen(req).read()
        return True
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return False
        raise


def delete_release(server: str, project_id: str, tag: str, token: str) -> None:
    url = f"{server}/api/v4/projects/{project_id}/releases/{urllib.parse.quote(tag, safe='')}"
    _api("DELETE", url, token)


def create_release(
    server: str, project_id: str, tag: str, name: str, description: str,
    asset_links: list[dict[str, str]], token: str,
) -> None:
    body = json.dumps({
        "name": name,
        "tag_name": tag,
        "description": description,
        "assets": {"links": asset_links},
    }).encode()
    url = f"{server}/api/v4/projects/{project_id}/releases"
    _api("POST", url, token, data=body, content_type="application/json")


def _substitute(text: str, variables: dict[str, str]) -> str:
    """Apply GitLab-style ${VAR} and $VAR substitution."""
    for k, v in variables.items():
        text = text.replace("${" + k + "}", v)
        text = text.replace("$" + k, v)
    return text


def load_release_template(tag: str, project_url: str) -> dict[str, Any]:
    """Read `release:gitlab.release` from .gitlab-ci.yml, with vars substituted.

    Returns a dict with `name`, `description`, and `links` (list of
    {name, url, link_type}). The YAML file is the single source of truth,
    so a local-published Release matches what gitlab.com's `release:gitlab`
    job would produce — only the asset URLs differ (we point them at our
    Generic Package Registry uploads, the YAML points at job artifacts).
    """
    if not GITLAB_CI_YAML.exists():
        return {}
    with GITLAB_CI_YAML.open() as f:
        data = yaml.load(f, Loader=_CIYamlLoader) or {}
    job = data.get("release:gitlab") or {}
    rel = job.get("release") or {}
    if not rel:
        return {}

    variables = {
        "CI_COMMIT_TAG": tag,
        "CI_PROJECT_URL": project_url,
    }
    name = _substitute(str(rel.get("name", f"Release {tag}")), variables)
    description = _substitute(str(rel.get("description", "")), variables)

    yaml_links = ((rel.get("assets") or {}).get("links") or [])
    links: list[dict[str, str]] = []
    for yl in yaml_links:
        if not isinstance(yl, dict):
            continue
        links.append({
            "name": _substitute(str(yl.get("name", "")), variables),
            "url": _substitute(str(yl.get("url", "")), variables),
            "link_type": str(yl.get("link_type", "other")),
        })
    return {"name": name, "description": description, "links": links}


def merge_links(
    yaml_links: list[dict[str, str]], uploaded: list[dict[str, str]],
) -> list[dict[str, str]]:
    """Use YAML's listing/order, swap URLs to our uploaded ones.

    YAML lists named CLI binaries (e.g. `pyaxml-linux-x86_64`); we replace
    each entry's URL with our Generic Package Registry URL for the file
    of the same name. Files on disk that aren't in YAML (typically the
    wheels) are appended at the end so they show up on the Release page.
    YAML entries with no matching uploaded file (e.g. a macOS binary that
    didn't build) are dropped silently.
    """
    by_name = {u["name"]: u for u in uploaded}
    out: list[dict[str, str]] = []
    listed: set[str] = set()
    for yl in yaml_links:
        match = by_name.get(yl["name"])
        if match is None:
            continue
        out.append({
            "name": yl["name"],
            "url": match["url"],
            "link_type": yl.get("link_type", "other"),
        })
        listed.add(yl["name"])
    for u in uploaded:
        if u["name"] not in listed:
            out.append(u)
    return out


def collect_files() -> list[Path]:
    files: list[Path] = []
    for d in (BINARIES_DIR, WHEELS_DIR):
        if d.is_dir():
            files.extend(p for p in sorted(d.iterdir()) if p.is_file())
    return files


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(
        description="Publish a GitLab Release with locally-built artifacts as assets.",
    )
    ap.add_argument("--tag", default=None,
                    help="Override the release tag. Default: git-detected.")
    ap.add_argument("--no-overwrite", action="store_true",
                    help="Fail if the Release already exists instead of replacing it.")
    ap.add_argument("--dry-run", action="store_true",
                    help="Print what would be uploaded; don't touch gitlab.com.")
    args = ap.parse_args(argv)

    token = _read_token()
    if not token:
        print("ERROR: GITLAB_TOKEN not found in .gitlab-ci-local-variables.yml or env.",
              file=sys.stderr)
        print("       Generate a PAT/PrAT with `api` scope and add it to the variables file.",
              file=sys.stderr)
        return 2

    tag = args.tag or _detect_tag()
    if not tag:
        print("ERROR: no git tag found at HEAD or earlier", file=sys.stderr)
        return 2
    if not tag.startswith("v"):
        print(f"WARN: tag {tag!r} doesn't start with 'v'.", file=sys.stderr)
    version = tag.removeprefix("v")

    server, project_path = _origin()
    project_id = urllib.parse.quote(project_path, safe="")

    files = collect_files()
    if not files:
        print("ERROR: no files in binaries/ or target/wheels/ — run `make ci-local-tag` first.",
              file=sys.stderr)
        return 2

    project_url = f"{server}/{project_path}"
    template = load_release_template(tag, project_url)
    name = template.get("name") or f"Release {tag}"
    description = template.get("description") or f"Release {tag}"
    yaml_links = template.get("links") or []

    print(f"Publishing release {tag} → {project_url}")
    print(f"  package: {PACKAGE_NAME}/{version}")
    print(f"  files:   {len(files)} ({sum(f.stat().st_size for f in files):,} bytes)")
    print(f"  notes:   {'from .gitlab-ci.yml release:gitlab' if template else 'fallback (no template found)'}\n")

    if args.dry_run:
        for f in files:
            print(f"  (dry-run) would upload {f.name}")
        print("\n--- description ---")
        print(description)
        print("--- end description ---")
        return 0

    if _release_exists(server, project_id, tag, token):
        if args.no_overwrite:
            print(f"ERROR: release {tag} already exists; pass without --no-overwrite to replace.",
                  file=sys.stderr)
            return 1
        print(f"  release {tag} exists — deleting first")
        delete_release(server, project_id, tag, token)

    uploaded: list[dict[str, str]] = []
    for f in files:
        url = upload_to_registry(server, project_id, version, f, token)
        link_type = "package" if f.suffix == ".whl" else "other"
        uploaded.append({"name": f.name, "url": url, "link_type": link_type})

    asset_links = merge_links(yaml_links, uploaded) if yaml_links else uploaded
    create_release(server, project_id, tag, name, description, asset_links, token)
    print(f"\n✓ Release page: {project_url}/-/releases/{tag}")
    return 0


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