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

**Wave-ordered** (roots → leaves). Per library, six stages (the local-release gate, plan §5
Table C, condensed): **gate** (static ruff lint+format) → **preflight** (``dist/`` is ours to
clear) → **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 sys
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, federation_root, 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 _owner(path: Path) -> str:
    """``uid:name`` for a path, degrading to the bare uid when it has no passwd entry."""
    try:
        uid = path.stat().st_uid
    except OSError:
        return "?"
    try:
        import pwd

        return f"{uid}:{pwd.getpwuid(uid).pw_name}"
    except (ImportError, KeyError):
        return str(uid)


def _unremovable(dist: Path) -> list[Path]:
    """Directories under ``dist`` this uid cannot empty, and so cannot remove.

    Unlink permission comes from the **containing directory**, not the file: a
    root-owned *file* inside a directory we own deletes fine, a root-owned
    *directory* with contents does not. So the honest question is which directories
    we lack write+search on — not which files have a foreign owner.
    """
    stuck: list[Path] = []
    for parent, dirnames, _files in os.walk(dist):
        p = Path(parent)
        if not os.access(p, os.W_OK | os.X_OK):
            stuck.append(p)
            dirnames[:] = []  # can't act on anything below it either; don't double-report
    return stuck


def _clear_dist(repo_dir: Path) -> str | None:
    """Remove ``dist/`` so the build writes into a clean tree. Returns why, or None.

    This used to be ``shutil.rmtree(dist, ignore_errors=True)``, which silently
    no-opped whenever the tree held a path this uid cannot unlink — exactly the state
    a containerised run leaves behind when it writes into the mounted working tree as
    **root** (``mgf-fed local-ci`` / ``woodpecker-cli exec``). ``uv build`` then failed
    several lines later with its own message about the destination, so the release
    looked like a build defect rather than a permissions one. Reported as `build` it
    cost real diagnosis time on hrb-app; reported here it is one line and a fix.

    Refusing is right rather than building anyway: a stale ``dist/`` we could not clear
    is also how ``_newest_wheel`` picks up a *previous* run's artifact for this same
    version and publishes it (PKG-07 — devpi is non-volatile, so that is unrecoverable).
    """
    if not os.access(repo_dir, os.W_OK | os.X_OK):
        return f"{repo_dir} not writable by uid {os.getuid()} — `uv build` cannot create dist/"

    dist = repo_dir / "dist"
    if dist.is_symlink() or (dist.exists() and not dist.is_dir()):
        try:
            dist.unlink()
        except OSError as exc:
            return f"cannot remove non-directory {dist}: {exc}"
        return None
    if not dist.exists():
        return None

    try:
        shutil.rmtree(dist)
    except OSError as exc:
        stuck = _unremovable(dist)
        if not stuck:  # removal failed for some reason other than permissions
            return f"cannot clear {dist}: {exc}"
        shown = ", ".join(f"{p.relative_to(repo_dir)} (owner {_owner(p)})" for p in stuck[:3])
        more = f", +{len(stuck) - 3} more" if len(stuck) > 3 else ""
        return (
            f"cannot clear {dist}: {len(stuck)} director(ies) not writable by "
            f"uid {os.getuid()} — {shown}{more}. A containerised run (local-ci / "
            f"woodpecker-cli exec) writes into the mounted tree as root. "
            f"Fix: sudo rm -rf {dist}"
        )
    return 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")

    if (blocked := _clear_dist(d)) is not None:
        return ReleaseResult(m.repo, version, "preflight", False, blocked)
    # Build BOTH: `--wheel` alone is why release-remote promoted no sdist, so every
    # prior release had one only because the tag pipeline happened to publish it.
    build_ok, build_out = _run([uv, "build"], 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 federation_gate(root: Path | None = None) -> tuple[bool | None, str]:
    """REL-13's federation consistency gate: ``(ok, detail)``; ``ok=None`` = could not run.

    REL-13 makes a green gate the releasing supervisor's standing duty, but it lived
    only in a checklist — so nothing stopped an irreversible publish while it was red.
    On 2026-08-09 `mgf-common` 0.52.0 was tagged and uploaded to a **non-volatile**
    index with the gate RED and the REL-10 cohort wave not started: for 67 minutes
    four siblings' declared windows excluded a version any ``pip install -U`` would
    resolve. That cannot be undone — which is exactly why the check belongs in an exit
    code rather than a document.

    Returns ``None`` when the generator is unreachable (no ``mgf-standard`` checkout —
    e.g. single-repo CI). "Could not verify" is NOT "verified": the caller refuses on
    ``None`` too, and the operator must pass ``--skip-federation-gate`` to proceed.
    """
    base = root or federation_root()
    script = base / "mgf-standard" / "scripts" / "gen_mgf_libraries.py"
    if not script.is_file():
        return (
            None,
            f"REL-13 generator not found at {script} — cannot verify federation consistency",
        )
    ok, out = _run([sys.executable, str(script), "--check"], cwd=str(base / "mgf-standard"))
    if ok:
        return True, "federation consistency gate GREEN"
    reds = [ln.strip() for ln in out.splitlines() if "pin EXCLUDES" in ln or "STALE" in ln]
    return False, "REL-13 gate RED: " + ("; ".join(reds[:3]) if reds else out.strip()[:200])


def _artefacts(wheel: Path) -> list[str]:
    """The wheel plus its sibling sdist, when one exists.

    `release-remote` published `str(wheel)` alone, so it promoted **no sdist** — every
    prior release had one only because the repo's tag pipeline happened to publish it
    too. Neither path alone produced the full artefact set.
    """
    version = wheel.name.split("-")[1]
    out = [str(wheel)]
    # `<name>-<version>.tar.gz` → strip the `.tar` left by Path.stem, then take the tail.
    out += [
        str(c)
        for c in sorted(wheel.parent.glob("*.tar.gz"))
        if c.name.removesuffix(".tar.gz").rsplit("-", 1)[-1] == version
    ]
    return out


#: What a consumer actually installs. `fed-resources/` generated state,
#: `pending-feedback/`, docs and CI are NOT this — and conflating them is the
#: recorded failure of the tool this replaces: `verify_release_currency.py`
#: reported 28 drifted distributions of which **26 were false positives**, all of
#: them generated or non-shipped files. A gate that cries wolf is a gate ignored.
_SHIPPED_PREFIXES = ("src/",)
_SHIPPED_FILES = ("pyproject.toml",)


def _ships(path: str) -> bool:
    return path.startswith(_SHIPPED_PREFIXES) or path in _SHIPPED_FILES


def release_currency(repo_dir: Path, version: str) -> tuple[bool, str]:
    """REL-130 — does `main` still carry the code that `version` names?

    mgf-standard PAPER-130. `mgf-alembic`'s main sat 28 commits past `v0.4.10`
    while still declaring `0.4.10`, so **the published 0.4.10 and main's 0.4.10
    were different code under the same number** — and the registry check passed,
    because it compares declared-vs-registry and neither had moved.

    The consequences were all invisible: six of seven published wheels still said
    `License: MIT` after the relicence, four papers marked SHIPPED pointed at a
    release that never happened, and eighteen leaves' wheels still capped
    `mgf-common<0.50`, which backtracked the whole federation's co-install onto a
    stale cornerstone.

    This runs **before the tag** on purpose. Publishing is irreversible (devpi
    `magogi/prod` is non-volatile), and the moment the tag is created over drifted
    source is the moment the lie exists.

    Returns ``(ok, detail)``. ``ok`` is False only when the tag EXISTS and
    installable content moved past it — a version with no tag yet is the normal
    pre-release state, not drift.
    """
    tag = None
    for cand in (f"v{version}", version):
        found, _ = _git(["rev-parse", "--verify", "-q", f"refs/tags/{cand}"], str(repo_dir))
        if found:
            tag = cand
            break
    if tag is None:
        return True, f"no tag for {version} yet — first release of this version"

    ok, out = _git(["diff", "--name-only", f"{tag}..HEAD"], str(repo_dir))
    if not ok:
        # Could not compare. Do NOT pass: this gate exists because a silent pass
        # over drifted source is exactly the defect.
        return False, f"cannot compare {tag}..HEAD — refusing to publish over an unverifiable tag"
    moved = [f for f in out.splitlines() if f and _ships(f)]
    if not moved:
        return True, f"{tag} matches HEAD's installable content"
    shown = ", ".join(moved[:4]) + (f" (+{len(moved) - 4} more)" if len(moved) > 4 else "")
    return False, (
        f"{tag} exists but {len(moved)} installable file(s) moved past it — {shown}. "
        f"Publishing {version} again would put DIFFERENT code under a number that is already "
        f"taken. Bump the version, or re-tag deliberately."
    )


def _release_remote_one(
    m: Member,
    password: str | None,
    uv: str,
    *,
    dry_run: bool,
    skip_federation_gate: bool = False,
) -> 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)"
        )

    # REL-13, now in an exit code rather than a checklist. Publishing is irreversible
    # (devpi `magogi/prod` is non-volatile), so this must refuse BEFORE the tag.
    if not skip_federation_gate:
        gate_ok, gate_detail = federation_gate()
        if gate_ok is not True:
            verb = "cannot verify" if gate_ok is None else "is RED"
            return RemoteResult(
                m.repo,
                version,
                "pre-req",
                False,
                f"REL-13 federation gate {verb} — {gate_detail}. Publishing is irreversible; "
                f"resolve it, or pass --skip-federation-gate deliberately",
            )

    # PAPER-130 — before the tag, because the tag over drifted source IS the lie.
    cur_ok, cur_detail = release_currency(d, version)
    if not cur_ok:
        return RemoteResult(m.repo, version, "pre-req", False, f"release currency: {cur_detail}")

    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(), *_artefacts(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,
    skip_federation_gate: bool = False,
) -> int:
    with log_operation(
        "release.remote",
        repos=len(repos),
        dry_run=dry_run,
        skip_federation_gate=skip_federation_gate,
    ):
        return _release_remote(
            repos, dry_run=dry_run, root=root, skip_federation_gate=skip_federation_gate
        )


def _release_remote(
    repos: list[str],
    *,
    dry_run: bool = False,
    root: Path | None = None,
    skip_federation_gate: bool = False,
) -> 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, skip_federation_gate=skip_federation_gate
        )
        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
