"""``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 re
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._logging import log_operation
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 _redact_cmd(text: str) -> str:
    """Redact secret-flag values from a stringified command (SC-11 defence-in-depth).

    `str(subprocess.TimeoutExpired/CalledProcessError)` embeds the full argv list;
    if a secret ever reaches argv it must not surface to a log. Primary defence is
    env-passing the token (so it is never in argv at all); this is the backstop.
    """
    return re.sub(r"('--(?:password|token)',\s*)'[^']*'", r"\1'<redacted>'", text)


def _run(
    args: list[str], cwd: str | None = None, env: dict[str, str] | None = None
) -> tuple[bool, str]:
    try:
        p = subprocess.run(  # noqa: S603
            args, cwd=cwd, capture_output=True, text=True, timeout=600, env=env
        )
    except (OSError, subprocess.SubprocessError) as exc:
        return False, _redact_cmd(str(exc))
    return p.returncode == 0, _redact_cmd((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:
        with zipfile.ZipFile(wheel) as zf:  # DP-13: close the handle deterministically
            names = zf.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",
        )

    # SC-11: the token goes via env (UV_PUBLISH_PASSWORD), never argv — so a
    # subprocess timeout/error that stringifies the command can't leak it.
    pub_ok, pub_out = _run(
        [uv, "publish", "--publish-url", CODEBERG_PYPI_UPLOAD, str(wheel)],
        env={
            **os.environ,
            "UV_PUBLISH_USERNAME": "magogi-admin",
            "UV_PUBLISH_PASSWORD": token,
        },
    )
    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:
    with log_operation("release.remote", repos=len(repos), dry_run=dry_run):
        return _release_remote(repos, dry_run=dry_run, root=root)


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
