"""Deterministic, zero-dependency renderer for the canonical ``.woodpecker.yml``.

Each member's CI pipeline is a GENERATED artifact, not a hand-pasted copy. The
single source of truth is ``mgf-standard/templates/woodpecker/`` — a canonical
``base.yml`` (the standard Python-library pipeline) plus opt-in fragments
(``frag-*.yml``) spliced in at ``# <<INSERT:NAME>>`` markers, and ``base-zig.yml``
for the Zig shape. ``render_woodpecker(member)`` derives the per-repo parameters
from the member's metadata + on-disk layout and returns the pipeline text.

Pure text, no jinja/cookiecutter (mgf-fed has no templating engine at runtime):
the only operations are ``str.replace`` for ``{{TOKEN}}`` substitution and
marker-line replacement for the ``# <<INSERT:NAME>>`` fragments.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

from mgf.fed._roster import Member, federation_root
from mgf.fed._template import canonical_text, template_dir

# Marker name -> fragment file, in the order they appear in base.yml. A marker
# whose gate (in ``_gates``) is False has its line removed; otherwise the line
# is replaced by the fragment's text verbatim (fragments are pre-indented to
# their insertion column, so no runtime re-indent is needed).
_FRAGMENTS: dict[str, str] = {
    "cron-event": "frag-cron-event.yml",
    "no-pypi-surface": "frag-no-pypi-surface.yml",
    "commit-trailers": "frag-commit-trailers.yml",
    "supervisor": "frag-supervisor.yml",
    "cornerstone": "frag-cornerstone.yml",
    "cornerstone-publish-dep": "frag-cornerstone-publish-dep.yml",
    "console-script-smoke": "frag-console-script-smoke.yml",
}

# Default coverage floor for the 3.12 gate when the repo declares no floor at
# all (neither [tool.mgf.ci].coverage_floor nor [tool.coverage.report].fail_under).
# AGGRESSIVE picks 85 (the fleet's hot target) deliberately: a repo with no
# declared floor that lands below 85 SHOULD red-line the sweep and get a
# measured floor set first — see derive_params + the COVERAGE-FLOOR risk list.
_DEFAULT_COVERAGE_FLOOR = 85

# The self-hosted devpi index (Phase 2 cutover, 2026-06-19). magogi/prod serves
# mgf-* releases LOCAL-ONLY (mirror_whitelist empty → a public-PyPI mgf-* can
# never be served) and proxies all public deps through its root/pypi pull-through
# cache. So a SINGLE --index-url resolves everything with NO public-PyPI in the
# path — dependency-confusion is structurally impossible (Findings B/C closed at
# the index, not merely mitigated). Hence: no --extra-index-url. On a transient
# failure the install retries the SAME steered command (see `_steered`), never a
# bare index-less install. Interim host = <ip>.sslip.io (real domain later).
_DEVPI_INDEX = "https://195.15.203.250.sslip.io/magogi/prod/+simple/"
_INDEX_FLAGS = f"--index-url {_DEVPI_INDEX}"


@dataclass(frozen=True, slots=True)
class WoodpeckerParams:
    """Everything the template needs, derived from one member."""

    import_name: str  # e.g. "mgf.common" / "mgf.vm.core"
    dist_name: str  # underscored wheel/sdist stem, e.g. "mgf_vm_core"
    dist_name_dash: str  # dashed project name, e.g. "mgf-vm-core"
    extras_install: str  # editable install target, e.g. ".[dev,observability,vault]"
    sbom_install: str  # consumer-surface install, e.g. ".[observability,vault]" or "."
    coverage_floor: int
    lang: str
    is_cli: bool
    console_script: str  # the [project.scripts] entry-point name ("" when not a CLI)
    has_supervisor: bool
    is_cornerstone: bool
    # --- declared [tool.mgf.ci] carve-outs (faithful generation, no regressions) ---
    vuln_ignore: tuple[str, ...] = ()  # pip-audit --ignore-vuln <CVE> per entry
    skip_editable: bool = False  # pip-audit --skip-editable
    # System packages apt-installed before every package-BUILDING install
    # (lint/typecheck/test/vuln-scan/sbom). For repos whose deps include a
    # C-extension that compiles from source — e.g. mgf-vm-libvirt / mgf-vm-sdk
    # pull libvirt-python, which needs gcc + libc6-dev + pkg-config + libvirt-dev,
    # none of which python:*-slim ships. Empty (the default) renders nothing.
    apt_packages: tuple[str, ...] = ()
    # Index fallback (Codeberg simple index + pypi.org) on EVERY package-
    # consuming install — lint/typecheck/test/vuln-scan editable installs AND
    # sbom/tag-coherence/smoke. DEFAULT-ON: an mgf-* member resolves its mgf-*
    # deps (which are in its ``dependencies`` / ``[dev]``, not yet all on
    # pypi.org) from the Codeberg registry; without the index those installs
    # fail in a fresh CI container. ``[tool.mgf.ci] extra_index = false`` opts
    # out (the cornerstone mgf-common has no mgf-* deps, and mgf-fastapi pins
    # them editable, so neither needs it).
    extra_index: bool = True

    @property
    def gates(self) -> dict[str, bool]:
        """Which ``# <<INSERT:NAME>>`` markers are active for this member."""
        return {
            "cron-event": self.is_cornerstone,
            "no-pypi-surface": self.is_cornerstone,
            # AG-03 trailer gate — every repo's commits need both trailers.
            "commit-trailers": True,
            "supervisor": self.has_supervisor,
            "cornerstone": self.is_cornerstone,
            # The cornerstone's compat-matrix is a release gate: publish MUST
            # wait on it (a public-API regression vs example_app blocks the
            # upload). Preserved as a load-bearing carve-out, not normalised away.
            "cornerstone-publish-dep": self.is_cornerstone,
            "console-script-smoke": self.is_cli,
        }

    @property
    def vuln_audit_cmd(self) -> str:
        """The ``pip-audit`` invocation line.

        ``--skip-editable --desc`` with one retry on transient OSV-database fetch
        errors. ``--skip-editable`` is intrinsic, not a carve-out: every mgf-*
        repo installs ITSELF ``-e`` for the audit (so the mgf-* deps resolve), and
        the repo's own package is unpublishable on PyPI. NOTE: no ``--strict`` —
        ``--strict`` treats the deliberately-skipped editable self as an
        un-auditable dep and hard-fails ("distribution marked as editable"); the
        default still exits non-zero on any real advisory, which is the gate.
        ``vuln_ignore`` appends ``--ignore-vuln <CVE>`` per embargoed-but-unfixed
        advisory.
        """
        flags = "--skip-editable --desc"
        for cve in self.vuln_ignore:
            flags += f" --ignore-vuln {cve}"
        cmd = f"pip-audit {flags}"
        return f"{cmd} || (sleep 30 && {cmd})"

    @property
    def index_flags(self) -> str:
        """Index-fallback flags for package-consuming installs (empty unless opted in)."""
        return f" {_INDEX_FLAGS}" if self.extra_index else ""

    def _steered(self, base: str) -> str:
        """Wrap a package-consuming install with the Codeberg-first index flags + a
        transient-failure retry of the SAME steered command.

        Returns ``base`` unchanged when ``extra_index`` is opted out. Otherwise:
        ``<base><flags> || (sleep 15 && <base><flags>)``. The retry re-runs the
        index-steered command — it NEVER falls back to a bare (index-less) install.
        A bare retry would resolve the mgf-* deps from public PyPI (dependency-
        confusion + stale-version testing), and since those deps live on the
        Codeberg index a public resolution is *always* wrong, never a useful
        fallback — so the retry buys transient-network resilience without the hole.
        """
        if not self.extra_index:
            return base
        steered = f"{base}{self.index_flags}"
        return f"{steered} || (sleep 15 && {steered})"

    @property
    def apt_prefix(self) -> str:
        """An ``apt-get install`` list-item rendered before a build install, or ``""``.

        The value expands the single ``      - {{..._INSTALL...}}`` template line
        into TWO YAML list items — the apt-get line, then the pip install — by
        carrying the next item's ``\\n      - `` prefix itself. Empty (no declared
        ``apt_packages``) leaves the install line untouched.
        """
        if not self.apt_packages:
            return ""
        pkgs = " ".join(self.apt_packages)
        return (
            f"apt-get update -qq && apt-get install -y -qq --no-install-recommends {pkgs}\n      - "
        )

    @property
    def extras_install_cmd(self) -> str:
        """The lint/typecheck/test editable-install line (``.[dev,...]``).

        The mgf-* deps in ``dependencies`` / ``[dev]`` resolve from the Codeberg
        index, so these install steps carry the index flags + a steered retry (see
        ``_steered``). ``extra_index = false`` collapses it to the plain editable
        install.
        """
        base = f'pip install --quiet -e "{self.extras_install}"'
        return f"{self.apt_prefix}{self._steered(base)}"

    @property
    def vuln_pkg_install(self) -> str:
        """The vuln-scan step's install line (``pip-audit`` + the editable surface).

        Same index-steered shape as the other install steps; auditing the editable
        closure needs the mgf-* deps resolvable from Codeberg too.
        """
        base = f'pip install --quiet pip-audit -e "{self.extras_install}"'
        return f"{self.apt_prefix}{self._steered(base)}"

    @property
    def sbom_pkg_install(self) -> str:
        """The sbom step's consumer-surface install command line."""
        base = f'pip install --quiet "{self.sbom_install}"'
        return f"{self.apt_prefix}{self._steered(base)}"

    @property
    def tagcoh_install(self) -> str:
        """The tag-coherence step's editable install command line."""
        return self._steered("pip install --quiet -e .")

    @property
    def smoke_install(self) -> str:
        """The smoke step's wheel install command line."""
        wheel = f"dist/{self.dist_name}-${{CI_COMMIT_TAG#v}}-py3-none-any.whl"
        # /tmp/smoke is the canonical smoke-venv path INSIDE the CI container
        # (created by `python -m venv /tmp/smoke` in base.yml) — it is a literal
        # in generated YAML, not a host-side temp file this process touches.
        base = "/tmp/smoke/bin/pip install --no-cache-dir"  # noqa: S108
        if not self.extra_index:
            return f"{base} {wheel}"
        return f"{base}{self.index_flags} {wheel}"


def _shallowest_import_name(repo_dir: Path) -> str:
    """The package import name == the shallowest ``__init__.py`` under ``src/``.

    ``src/mgf/common/__init__.py`` -> ``mgf.common``;
    ``src/mgf/vm/core/__init__.py`` -> ``mgf.vm.core`` (the single-level glob
    ``src/mgf/*/__init__.py`` misses this — mgf-vm-core has no ``src/mgf/vm``
    ``__init__.py`` — so resolve by shallowest leaf instead).
    """
    src = repo_dir / "src"
    inits = sorted(
        src.rglob("__init__.py"),
        key=lambda p: (len(p.relative_to(src).parts), str(p)),
    )
    if not inits:
        raise FileNotFoundError(f"no src/**/__init__.py under {src} — cannot derive import name")
    pkg_dir = inits[0].parent.relative_to(src)
    return ".".join(pkg_dir.parts)


def _read_pyproject(repo_dir: Path) -> dict[str, object]:
    import tomllib

    path = repo_dir / "pyproject.toml"
    if not path.is_file():
        return {}
    data: dict[str, object] = tomllib.loads(path.read_text(encoding="utf-8"))
    return data


def _project_table(pyproject: dict[str, object]) -> dict[str, object]:
    project = pyproject.get("project")
    return project if isinstance(project, dict) else {}


def _optional_dep_names(project: dict[str, object]) -> list[str]:
    opt = project.get("optional-dependencies")
    return list(opt.keys()) if isinstance(opt, dict) else []


def _tool_table(pyproject: dict[str, object], name: str) -> dict[str, object]:
    """Return ``[tool.<name>]`` as a dict (empty when absent / malformed)."""
    tool = pyproject.get("tool")
    if isinstance(tool, dict):
        table = tool.get(name)
        if isinstance(table, dict):
            return table
    return {}


def _mgf_ci(pyproject: dict[str, object]) -> dict[str, object]:
    """Return the ``[tool.mgf-fed]`` woodpecker CI carve-out table (empty when absent).

    The carve-outs live in the dedicated ``[tool.mgf-fed]`` namespace, NOT
    ``[tool.mgf]`` — the latter is mgf-common's ``MgfSettings`` config source
    (``extra="forbid"``), so a subtable there breaks ``MgfSettings()``/``bootstrap()``.
    The transitional ``[tool.mgf.ci]`` fallback was dropped once the fleet finished
    migrating (2026-06-15) — every repo now declares ``[tool.mgf-fed]``.
    """
    return _tool_table(pyproject, "mgf-fed")


def _coverage_floor(pyproject: dict[str, object]) -> int:
    """Derive the 3.12 coverage gate floor.

    Precedence: ``[tool.mgf.ci].coverage_floor`` → ``[tool.coverage.report]
    .fail_under`` → ``_DEFAULT_COVERAGE_FLOOR`` (85). The mgf.ci override lets a
    repo pin a CI gate that differs from its pyproject fail_under (e.g. mgf-common
    runs the gate at 85 while its pyproject fail_under is 80).
    """
    ci = _mgf_ci(pyproject)
    floor = ci.get("coverage_floor")
    if isinstance(floor, (int, float)) and not isinstance(floor, bool):
        return int(floor)
    report = _tool_table(pyproject, "coverage").get("report")
    if isinstance(report, dict):
        report_floor = report.get("fail_under")
        if isinstance(report_floor, (int, float)) and not isinstance(report_floor, bool):
            return int(report_floor)
    return _DEFAULT_COVERAGE_FLOOR


def _str_list(value: object) -> tuple[str, ...]:
    """Coerce a TOML value to a tuple of strings (empty when absent / wrong type)."""
    if isinstance(value, list):
        return tuple(str(x) for x in value)
    return ()


def derive_params(member: Member, root: Path | None = None) -> WoodpeckerParams:
    """Derive the template parameters for ``member`` from metadata + on-disk layout."""
    root = root or federation_root()
    repo_dir = root / member.repo
    pyproject = _read_pyproject(repo_dir)
    project = _project_table(pyproject)

    dist_dash = member.repo  # repo slug == published project name (== dashed dist)
    dist_under = dist_dash.replace("-", "_")

    ci = _mgf_ci(pyproject)

    # Optional-dependency surface, minus the always-installed `dev` and the
    # mgf-standard `standards` extra (a conformance helper, not a runtime dep).
    opt_names = _optional_dep_names(project)
    runtime_extras = [e for e in opt_names if e not in ("dev", "standards")]

    extras_install = ".[" + ",".join(["dev", *runtime_extras]) + "]"

    # SBOM consumer surface: the derived runtime extras by default, OR the
    # explicit [tool.mgf.ci].sbom_extras override (e.g. fastapi excludes the
    # `testing` extra — uvicorn/httpx are test-only, not consumer runtime deps).
    sbom_override = _str_list(ci.get("sbom_extras"))
    sbom_extras = list(sbom_override) if "sbom_extras" in ci else runtime_extras
    sbom_install = (".[" + ",".join(sbom_extras) + "]") if sbom_extras else "."

    scripts = project.get("scripts")
    console_script = next(iter(scripts), "") if isinstance(scripts, dict) and scripts else ""

    # Zig (and any other non-Python) members ship no ``src/**/__init__.py``;
    # their template (base-zig.yml) carries no import-name token, so a missing
    # package is expected, not an error.
    import_name = "" if member.lang != "python" else _shallowest_import_name(repo_dir)

    return WoodpeckerParams(
        import_name=import_name,
        dist_name=dist_under,
        dist_name_dash=dist_dash,
        extras_install=extras_install,
        sbom_install=sbom_install,
        coverage_floor=_coverage_floor(pyproject),
        lang=member.lang,
        is_cli=bool(console_script),
        console_script=console_script,
        has_supervisor="mgf-test-supervisor" in member.deps,
        is_cornerstone=member.repo == "mgf-common",
        vuln_ignore=_str_list(ci.get("vuln_ignore")),
        skip_editable=bool(ci.get("skip_editable", False)),
        extra_index=bool(ci.get("extra_index", True)),
        apt_packages=_str_list(ci.get("apt_packages")),
    )


def _substitute(text: str, params: WoodpeckerParams) -> str:
    """Replace every ``{{TOKEN}}`` in ``text`` with the derived value."""
    mapping = {
        "{{IMPORT_NAME}}": params.import_name,
        "{{DIST_NAME}}": params.dist_name,
        "{{DIST_NAME_DASH}}": params.dist_name_dash,
        "{{EXTRAS_INSTALL}}": params.extras_install,
        "{{SBOM_INSTALL}}": params.sbom_install,
        "{{COVERAGE_FLOOR}}": str(params.coverage_floor),
        "{{CONSOLE_SCRIPT}}": params.console_script,
        # Carve-out-aware whole-command tokens ([tool.mgf.ci]).
        "{{EXTRAS_INSTALL_CMD}}": params.extras_install_cmd,
        "{{VULN_PKG_INSTALL}}": params.vuln_pkg_install,
        "{{VULN_AUDIT_CMD}}": params.vuln_audit_cmd,
        "{{SBOM_PKG_INSTALL}}": params.sbom_pkg_install,
        "{{TAGCOH_INSTALL}}": params.tagcoh_install,
        "{{SMOKE_INSTALL}}": params.smoke_install,
    }
    # Replace longest token first so a token that is a prefix of another
    # ({{EXTRAS_INSTALL}} ⊂ {{EXTRAS_INSTALL_CMD}}, {{DIST_NAME}} ⊂
    # {{DIST_NAME_DASH}}) cannot corrupt the longer one.
    for token in sorted(mapping, key=len, reverse=True):
        text = text.replace(token, mapping[token])
    return text


def _apply_markers(text: str, params: WoodpeckerParams, root: Path | None) -> str:
    """Replace each ``# <<INSERT:NAME>>`` marker line with its fragment, or drop it.

    The marker occupies its own line. When the gate is True the whole line is
    replaced by the fragment text (already token-substituted + pre-indented);
    when False the line (and its trailing newline) is removed.
    """
    gates = params.gates
    out_lines: list[str] = []
    for line in text.splitlines(keepends=True):
        stripped = line.strip()
        if stripped.startswith("# <<INSERT:") and stripped.endswith(">>"):
            name = stripped[len("# <<INSERT:") : -len(">>")]
            if gates.get(name, False):
                fragment = canonical_text(f"woodpecker/{_FRAGMENTS[name]}", root)
                out_lines.append(_substitute(fragment, params))
            # gate False (or unknown marker): drop the marker line entirely.
            continue
        out_lines.append(line)
    return "".join(out_lines)


def render_woodpecker(member: Member, root: Path | None = None) -> str:
    """Render ``member``'s canonical ``.woodpecker.yml`` as text.

    Deterministic and pure-text: reads the template via ``_template.canonical_text``,
    substitutes the ``{{TOKEN}}`` parameters, and splices opt-in fragments at the
    ``# <<INSERT:NAME>>`` markers. Zig members render from ``base-zig.yml`` (no
    tokens today, but routed through the same path for a uniform contract).
    """
    root = root or federation_root()
    params = derive_params(member, root)

    if params.lang == "zig":
        return canonical_text("woodpecker/base-zig.yml", root)

    base = canonical_text("woodpecker/base.yml", root)
    base = _substitute(base, params)
    return _apply_markers(base, params, root)


def _template_root(root: Path | None = None) -> Path:
    """The directory holding the woodpecker template files (for tooling/tests)."""
    return template_dir(root) / "woodpecker"
