"""``mgf-fed release-local`` — Phase 2: the local-release gate, then publish to the local index.

**Wave-ordered** (roots → leaves). Per library, five stages (the local-release gate, plan §5
Table C, condensed): **gate** (static ruff lint+format) → **build** the wheel (`uv build`) →
**validate** (py.typed + version coherence) → **smoke** (install in a throwaway env resolving
**LOCAL-FIRST** — the local index + PyPI, *not* Codeberg, so mgf-* deps must already be locally
released, which enforces wave order) → **publish** the validated wheel to ``~/.mgf/local-index/``.

Same version as the eventual remote (build-once): this does **not** bump — bump before releasing.
For the *full* gate (mypy/pytest matrix/vuln/sbom) run ``mgf-fed local-ci`` first.
"""

from __future__ import annotations

import os
import shutil
import subprocess
import tempfile
import tomllib
import urllib.error
import urllib.request
import zipfile
from dataclasses import dataclass
from pathlib import Path

from mgf.fed import _index
from mgf.fed._gates import _find_ruff, _lint_dirs
from mgf.fed._roster import Member, members

CODEBERG_PYPI_UPLOAD = "https://codeberg.org/api/packages/magogi-admin/pypi"
CODEBERG_PKG_API = "https://codeberg.org/api/v1/packages/magogi-admin/pypi"


@dataclass(frozen=True, slots=True)
class ReleaseResult:
    repo: str
    version: str
    reached: str  # the last stage attempted
    ok: bool
    detail: str


def _run(args: list[str], cwd: str | None = None) -> tuple[bool, str]:
    try:
        p = subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=600)  # noqa: S603
    except (OSError, subprocess.SubprocessError) as exc:
        return False, str(exc)
    return p.returncode == 0, (p.stdout + p.stderr).strip()


def _version(repo_dir: Path) -> str:
    try:
        data = tomllib.loads((repo_dir / "pyproject.toml").read_text(encoding="utf-8"))
    except (OSError, tomllib.TOMLDecodeError):
        return "?"
    return str((data.get("project", {}) or {}).get("version", "?"))


def _newest_wheel(repo_dir: Path, version: str) -> Path | None:
    wheels = sorted((repo_dir / "dist").glob(f"*-{version}-*.whl"))
    return wheels[-1] if wheels else None


def _validate_wheel(wheel: Path, version: str) -> tuple[bool, str]:
    try:
        names = zipfile.ZipFile(wheel).namelist()
    except (OSError, zipfile.BadZipFile) as exc:
        return False, f"unreadable wheel: {exc}"
    if version not in wheel.name:
        return False, f"version {version} not in wheel name {wheel.name}"
    if not any(n.endswith("py.typed") for n in names):
        return False, "no py.typed in wheel (AP-09)"
    return True, ""


def _release_one(m: Member, uv: str, ruff: str | None) -> ReleaseResult:
    d = m.repo_dir
    version = _version(d)

    dirs = _lint_dirs(d)
    if ruff and dirs:
        chk_ok, _ = _run([ruff, "check", "--no-cache", *dirs])
        if not chk_ok:
            return ReleaseResult(m.repo, version, "gate", False, "ruff check failed")
        fmt_ok, _ = _run([ruff, "format", "--check", "--no-cache", *dirs])
        if not fmt_ok:
            return ReleaseResult(m.repo, version, "gate", False, "format diff")

    shutil.rmtree(d / "dist", ignore_errors=True)
    build_ok, build_out = _run([uv, "build", "--wheel"], cwd=str(d))
    if not build_ok:
        tail = build_out.splitlines()[-1] if build_out else "uv build failed"
        return ReleaseResult(m.repo, version, "build", False, tail)
    wheel = _newest_wheel(d, version)
    if wheel is None:
        return ReleaseResult(m.repo, version, "build", False, f"no wheel matching {version}")

    val_ok, val_detail = _validate_wheel(wheel, version)
    if not val_ok:
        return ReleaseResult(m.repo, version, "validate", False, val_detail)

    with tempfile.TemporaryDirectory() as tmp:
        args = [uv, "pip", "install", "--target", tmp, str(wheel)]
        args += _index.resolve_args(local=True, remote=False)
        smoke_ok, smoke_out = _run(args)
    if not smoke_ok:
        tail = smoke_out.splitlines()[-1] if smoke_out else "install failed"
        return ReleaseResult(m.repo, version, "smoke", False, f"local-first install failed: {tail}")

    dest = _index.publish(wheel)
    return ReleaseResult(m.repo, version, "publish", True, f"{version} → {dest.name}")


def release_local(repos: list[str], *, root: Path | None = None) -> int:
    uv = shutil.which("uv")
    if uv is None:
        print("uv not found on PATH — needed for build + smoke-install.")
        return 2
    ruff = _find_ruff()

    want = set(repos)
    all_members = members(root)
    known = {m.repo for m in all_members}
    for r in repos:
        if r not in known:
            print(f"  ! unknown repo: {r}")
    selection = [m for m in all_members if m.repo in want]  # wave-ordered (members is sorted)

    results: list[ReleaseResult] = []
    for m in selection:
        print(f"  release-local {m.repo} (wave {m.wave}) …")
        res = _release_one(m, uv, ruff)
        results.append(res)
        print(f"    {'✓ published' if res.ok else '✗ ' + res.reached} — {res.detail}")

    published = [r for r in results if r.ok]
    print(f"\n{len(published)}/{len(results)} published to {_index.local_index_dir()}")
    return 0 if results and all(r.ok for r in results) else 1


# ---------------------------------------------------------------------------
# Phase 3 — release-remote: promote the local-validated wheel to Codeberg.
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class RemoteResult:
    repo: str
    version: str
    reached: str
    ok: bool
    detail: str


def _git(args: list[str], cwd: str) -> tuple[bool, str]:
    return _run(["git", "-c", "core.hooksPath=/dev/null", *args], cwd=cwd)


def _codeberg_has(package: str, version: str, token: str | None) -> bool | None:
    """True/False whether package@version is on Codeberg; None if unknown (network/auth)."""
    req = urllib.request.Request(f"{CODEBERG_PKG_API}/{package}/{version}")  # noqa: S310
    if token:
        req.add_header("Authorization", f"token {token}")
    try:
        with urllib.request.urlopen(req, timeout=20) as resp:  # noqa: S310
            return bool(resp.status == 200)
    except urllib.error.HTTPError as exc:
        return False if exc.code == 404 else None
    except (urllib.error.URLError, OSError):
        return None


def _local_wheel(repo: str, version: str) -> Path | None:
    hits = sorted(_index.local_index_dir().glob(f"{repo.replace('-', '_')}-{version}-*.whl"))
    return hits[-1] if hits else None


def _release_remote_one(m: Member, token: str | None, uv: str, *, dry_run: bool) -> RemoteResult:
    d = m.repo_dir
    version = _version(d)

    wheel = _local_wheel(m.repo, version)
    if wheel is None:
        return RemoteResult(
            m.repo,
            version,
            "pre-req",
            False,
            f"no local release of {version} — run `release-local {m.repo}` first",
        )
    _, dirty = _git(["status", "--porcelain"], str(d))
    if dirty.strip():
        return RemoteResult(
            m.repo, version, "pre-req", False, "working tree not clean (commit/stash first)"
        )

    present = _codeberg_has(m.repo, version, token)
    if present is True:
        return RemoteResult(
            m.repo, version, "verify", True, f"{version} already published — nothing to do"
        )
    if dry_run:
        state = "absent" if present is False else "unknown"
        return RemoteResult(
            m.repo,
            version,
            "dry-run",
            True,
            f"would tag v{version} + publish {wheel.name} + verify ({state} on Codeberg now)",
        )
    if token is None:
        return RemoteResult(m.repo, version, "pre-req", False, "set MGF_CODEBERG_TOKEN to publish")

    tag = f"v{version}"
    _git(["tag", "-a", tag, "-m", f"{m.repo} {version}"], str(d))  # no-op if it already exists
    push_ok, push_out = _git(["push", "origin", tag], str(d))
    if not push_ok and "already exists" not in push_out and "up-to-date" not in push_out:
        return RemoteResult(
            m.repo,
            version,
            "tag",
            False,
            push_out.splitlines()[-1] if push_out else "tag push failed",
        )

    pub_ok, pub_out = _run(
        [
            uv,
            "publish",
            "--publish-url",
            CODEBERG_PYPI_UPLOAD,
            "--username",
            "magogi-admin",
            "--password",
            token,
            str(wheel),
        ]
    )
    if not pub_ok and "already exists" not in pub_out.lower():
        return RemoteResult(
            m.repo,
            version,
            "publish",
            False,
            pub_out.splitlines()[-1] if pub_out else "upload failed",
        )

    if _codeberg_has(m.repo, version, token) is True:
        return RemoteResult(
            m.repo, version, "publish", True, f"{version} published + verified (REST 200)"
        )
    return RemoteResult(m.repo, version, "verify", False, "uploaded but REST verify != 200")


def release_remote(repos: list[str], *, dry_run: bool = False, root: Path | None = None) -> int:
    uv = shutil.which("uv")
    if uv is None:
        print("uv not found on PATH — needed to publish.")
        return 2
    token = os.environ.get("MGF_CODEBERG_TOKEN") or os.environ.get("CODEBERG_TOKEN")
    if token is None and not dry_run:
        print("set MGF_CODEBERG_TOKEN (Codeberg pypi token) to publish, or pass --dry-run.")
        return 2

    want = set(repos)
    all_members = members(root)
    for r in repos:
        if r not in {mm.repo for mm in all_members}:
            print(f"  ! unknown repo: {r}")
    selection = [m for m in all_members if m.repo in want]  # wave-ordered

    results: list[RemoteResult] = []
    for m in selection:
        print(f"  release-remote {m.repo} (wave {m.wave}){' [dry-run]' if dry_run else ''} …")
        res = _release_remote_one(m, token, uv, dry_run=dry_run)
        results.append(res)
        print(f"    {'✓' if res.ok else '✗'} {res.reached} — {res.detail}")

    ok = [r for r in results if r.ok]
    print(f"\n{len(ok)}/{len(results)} ok{' (dry-run — nothing published)' if dry_run else ''}")
    return 0 if results and all(r.ok for r in results) else 1
