"""``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* devpi, 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.

``release-remote`` (below) then **promotes** the same wheel to the self-hosted devpi index
(M2 §3.5 retarget — the endpoint derives from ``$DEVPI_URL``; the runbook is
mgf-standard ``docs/recipes/remote-release.md``).
"""

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

#: The devpi upload password env var (the ``magogi`` account; REL-04/PKG-03 —
#: env/file substitution, never argv, never embedded). Matches the runbook
#: (mgf-standard docs/recipes/remote-release.md) and the CI publish step's
#: ``devpi_password`` org secret.
DEVPI_PASSWORD_ENV = "DEVPI_PASSWORD"  # noqa: S105 — the env-var NAME, not a secret value


@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 devpi
# (M2 §3.5 retarget; endpoint + auth shape derive from $DEVPI_URL).
# ---------------------------------------------------------------------------


@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 _upload_url(index_url: str | None = None) -> str:
    """The devpi upload endpoint derived from the index URL: everything before the
    ``+simple`` tail (``…/magogi/prod/+simple/`` → ``…/magogi/prod/``) — the same
    derivation the CI publish step does with ``${DEVPI_URL%%+simple*}`` (uploads
    POST to the index root; pip resolves from ``+simple/``)."""
    return (index_url or _index.devpi_url()).split("+simple", 1)[0]


def _upload_user(index_url: str | None = None) -> str:
    """The upload account derived from the index URL's owner segment
    (``…/magogi/prod/+simple/`` → ``magogi`` — the index's owning devpi user, which
    is the fleet's upload account per MGF_INFRA.md §2). Falls back to ``magogi``
    when the URL has no path."""
    from urllib.parse import urlsplit

    parts = urlsplit(index_url or _index.devpi_url()).path.strip("/").split("/")
    return parts[0] if parts and parts[0] else "magogi"


def _devpi_has(package: str, version: str) -> bool | None:
    """True/False whether ``package@version`` is served by devpi's ``+simple`` index
    (anonymous read — PKG-05: the index listing is the "is it live?" truth, never
    pypi.org); None if unknown (network error). Matches on the wheel-name shape
    ``-<version>-`` so ``0.1.1`` never false-positives on a served ``0.1.10``."""
    url = f"{_index.devpi_url().rstrip('/')}/{package}/"
    try:
        with urllib.request.urlopen(url, timeout=20) as resp:  # noqa: S310
            html = resp.read().decode("utf-8", "replace")
    except urllib.error.HTTPError as exc:
        return False if exc.code == 404 else None
    except (urllib.error.URLError, OSError):
        return None
    return f"-{version}-" in html


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, password: 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 = _devpi_has(m.repo, version)
    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 devpi now)",
        )
    if password is None:
        return RemoteResult(m.repo, version, "pre-req", False, "set DEVPI_PASSWORD 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 password goes via env (UV_PUBLISH_PASSWORD), never argv — so a
    # subprocess timeout/error that stringifies the command can't leak it. The
    # upload endpoint + account derive from $DEVPI_URL (magogi/prod shape) — see
    # _upload_url/_upload_user. NOTE: the tag push above also fires the repo's CI
    # tag pipeline, whose publish step targets the same index — the direct upload
    # here promotes the LOCALLY-validated wheel (build-once); an "already exists"
    # from either side is tolerated as idempotent.
    pub_ok, pub_out = _run(
        [uv, "publish", "--publish-url", _upload_url(), str(wheel)],
        env={
            **os.environ,
            "UV_PUBLISH_USERNAME": _upload_user(),
            "UV_PUBLISH_PASSWORD": password,
        },
    )
    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 _devpi_has(m.repo, version) is True:
        return RemoteResult(
            m.repo, version, "publish", True, f"{version} published + verified (+simple lists it)"
        )
    return RemoteResult(m.repo, version, "verify", False, "uploaded but not in the +simple listing")


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
    password = os.environ.get(DEVPI_PASSWORD_ENV)
    if password is None and not dry_run:
        print(
            f"set {DEVPI_PASSWORD_ENV} (the magogi account's devpi publish password; "
            "see mgf-standard docs/recipes/remote-release.md) 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, password, 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
