#!/usr/bin/env python3
"""Generate the reverse Hyperpart consumer map from the registry.

Forward edges live on each Hyperpart (``composes``, ``composes_with``,
``extensions``, ``does_not_compose``) and on Blueprints. Agents mutating a
lower-level part need the **reverse** view: who embeds me, who relates to me,
who deliberately refuses me.

    python packages/hatchi-maxchi/tools/consumer_map.py
    python packages/hatchi-maxchi/tools/consumer_map.py --write

Default prints to stdout; ``--write`` updates
``packages/hatchi-maxchi/CONSUMER_MAP.md``. CI drift gate:
``tests/unit/test_consumer_map_tool.py``.
"""

from __future__ import annotations

import argparse
import sys
from collections import defaultdict
from pathlib import Path

PKG = Path(__file__).resolve().parents[1]
REPO = PKG.parent.parent
sys.path.insert(0, str(PKG / "site"))

from blueprints import BLUEPRINTS  # noqa: E402
from registry import (  # noqa: E402
    HYPERPARTS,
    RECIPE_LABELS,
    NonComposition,
    effective_layer,
    effective_recipe,
)

OUT = PKG / "CONSUMER_MAP.md"


def _ext_part_id(controller_path: str) -> str:
    """controllers/dz-grid-edit.js → grid-edit (best-effort Hyperpart stem)."""
    name = Path(controller_path).name
    if name.startswith("dz-") and name.endswith(".js"):
        return name[3:-3]
    return name


def graph() -> dict[str, dict[str, list[tuple[str, str]]]]:
    """part_id → relation → list of (other_id, detail).

    Relations are reverse-oriented (edges *into* the part):
      embedded_by     — other.composes includes me
      related_from    — other.guidance.composes_with includes me
      extends_parent  — I am an extension controller declared on other
      refused_by      — other.does_not_compose names me
      used_by_blueprint — blueprint.composes includes me
    """
    ids = {h.id for h in HYPERPARTS}
    rev: dict[str, dict[str, list[tuple[str, str]]]] = {hid: defaultdict(list) for hid in ids}

    for h in HYPERPARTS:
        for child in h.composes:
            if child in rev:
                rev[child]["embedded_by"].append((h.id, "composes"))
        if h.guidance:
            for other in h.guidance.composes_with:
                if other in rev:
                    rev[other]["related_from"].append((h.id, "composes_with"))
        for ext in h.extensions:
            stem = _ext_part_id(ext)
            # Extension stems may not be Hyperpart ids (grid-edit is not a
            # top-level Hyperpart). Index under the parent only in forward
            # section; reverse still records "X rides parent".
            if stem in rev:
                rev[stem]["extends_parent"].append((h.id, ext))
            # Always attach on parent side via a synthetic forward dump later.
        for nc in h.does_not_compose:
            if nc.other in rev:
                detail = f"{nc.seam}: {nc.reason}"
                if nc.spike:
                    detail += f" (spike: {nc.spike})"
                rev[nc.other]["refused_by"].append((h.id, detail))

    for bp in BLUEPRINTS:
        for child in bp.composes:
            if child in rev:
                rev[child]["used_by_blueprint"].append((bp.id, "blueprint"))

    # freeze defaultdicts
    return {hid: dict(rels) for hid, rels in rev.items()}


def forward_non_compositions() -> list[tuple[str, NonComposition]]:
    out: list[tuple[str, NonComposition]] = []
    for h in HYPERPARTS:
        for nc in h.does_not_compose:
            out.append((h.id, nc))
    return out


def render_md() -> str:
    rev = graph()
    lines: list[str] = [
        "# Hyperpart consumer map (reverse index)",
        "",
        "Auto-generated by `tools/consumer_map.py`. Do not hand-edit.",
        "",
        "Regenerate: `python packages/hatchi-maxchi/tools/consumer_map.py --write`",
        "",
        "When you mutate a lower-level part (contract attrs, controller seams,",
        "box metrics, submit shape), check **who consumes it** before shipping.",
        "Hard embeds (`composes`) and blueprint uses are blast-radius. Soft",
        "`composes_with` is agent guidance only. **Refused by** is deliberate",
        "non-composition — do not wire that part in without flipping the registry",
        "entry and completing the linked spike.",
        "",
        "## How edges are declared",
        "",
        "| Edge | Source | Meaning |",
        "|------|--------|---------|",
        "| `embedded_by` | `Hyperpart.composes` | Parent embeds this part in partial/exchange |",
        "| `related_from` | `guidance.composes_with` | Soft agent link (not a runtime import) |",
        "| `extends_parent` | `Hyperpart.extensions` | Extension controller rides this parent |",
        "| `refused_by` | `Hyperpart.does_not_compose` | Parent resembles this part but uses a local primitive |",
        "| `used_by_blueprint` | `Blueprint.composes` | Full-page motif includes this part |",
        "",
        "## Explicit non-compositions (local primitives)",
        "",
        "These are the high-risk lookalikes. CI locks (`require_substrings` /",
        "`forbid_substrings`) keep the local implementation honest.",
        "",
        "| Parent | Does not use | Seam | Reason | Spike |",
        "|--------|--------------|------|--------|-------|",
    ]
    for parent, nc in sorted(forward_non_compositions(), key=lambda x: (x[0], x[1].other)):
        spike = f"`{nc.spike}`" if nc.spike else "—"
        reason = nc.reason.replace("|", "\\|")
        lines.append(f"| `{parent}` | `{nc.other}` | {nc.seam} | {reason} | {spike} |")
    if not forward_non_compositions():
        lines.append("| — | — | — | (none declared) | — |")

    # ── By recipe (L0) — compression for agents picking a surface ──
    by_recipe: dict[str, list[str]] = defaultdict(list)
    unassigned: list[str] = []
    for h in HYPERPARTS:
        r = effective_recipe(h)
        if r:
            by_recipe[r].append(h.id)
        else:
            unassigned.append(h.id)

    lines.extend(
        [
            "",
            "## By recipe (L0)",
            "",
            "Same **job** can have multiple **surfaces**. Pick from "
            "`docs/agent/pick-a-surface.md`; do not merge L1s by visual shape.",
            "",
        ]
    )
    for slug in sorted(by_recipe.keys()):
        label = RECIPE_LABELS.get(slug, slug)
        parts = ", ".join(f"`{pid}`" for pid in sorted(by_recipe[slug]))
        lines.append(f"- **`{slug}`** ({label}): {parts}")
    if unassigned:
        # Keep this short — unset is fine for pure chrome until seeded.
        n = len(unassigned)
        lines.append(
            f"- **_(unset)_** ({n} parts): open pick-a-surface or seed "
            f"`_RECIPE_SEED` in `site/registry.py` when a part sits on the matrix"
        )
    lines.append("")

    lines.extend(
        [
            "## Reverse index by part",
            "",
        ]
    )

    for h in sorted(HYPERPARTS, key=lambda x: x.id):
        rels = rev.get(h.id, {})
        # Always emit a heading so every part is greppable; thin parts show —.
        lines.append(f"### `{h.id}`")
        lines.append("")
        layer = effective_layer(h)
        recipe = effective_recipe(h)
        meta = f"**Layer:** `{layer}`"
        if recipe:
            meta += f" · **Recipe:** `{recipe}` ({RECIPE_LABELS.get(recipe, recipe)})"
        lines.append(meta)
        lines.append("")
        any_edge = False
        for key, label in (
            ("embedded_by", "Embedded by (hard `composes`)"),
            ("used_by_blueprint", "Used by blueprints"),
            ("related_from", "Related from (`composes_with`)"),
            ("extends_parent", "Extension of parent"),
            ("refused_by", "Refused by (explicit non-composition)"),
        ):
            edges = rels.get(key) or []
            if not edges:
                continue
            any_edge = True
            lines.append(f"**{label}**")
            lines.append("")
            for other, detail in sorted(edges):
                lines.append(f"- `{other}` — {detail}")
            lines.append("")
        # Forward: who I refuse / embed (helps when editing this part)
        if h.composes:
            any_edge = True
            lines.append("**Embeds (forward `composes`)**")
            lines.append("")
            for c in h.composes:
                lines.append(f"- `{c}`")
            lines.append("")
        if h.does_not_compose:
            any_edge = True
            lines.append("**Does not compose (forward)**")
            lines.append("")
            for nc in h.does_not_compose:
                spike = f" — spike `{nc.spike}`" if nc.spike else ""
                lines.append(f"- `{nc.other}` @ {nc.seam}: {nc.reason}{spike}")
            lines.append("")
        if h.extensions:
            any_edge = True
            lines.append("**Extensions on this part**")
            lines.append("")
            for e in h.extensions:
                lines.append(f"- `{e}`")
            lines.append("")
        if not any_edge:
            lines.append("_No composition edges declared._")
            lines.append("")

    lines.extend(
        [
            "## Related",
            "",
            "- `AGENTS.md` — curriculum (stems, layers, invention ladder)",
            "- `docs/agent/compose-or-refuse.md` — host playbook",
            "- `docs/agent/pick-a-surface.md` — L0 → L1 matrix",
            "- `docs/decisions/0003-composition-declared.md`",
            "- `docs/spikes/combobox-in-grid-cell.md` — design spike for flipping grid↛combobox",
            "- `CONTRACT_SURFACE.md` — DOM/model surface drift gate (breaking-change detector)",
            "- `DUAL_LOCK_COVERAGE.md` — dual-lock inventory",
            "",
        ]
    )
    return "\n".join(lines)


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--write", action="store_true", help=f"write {OUT}")
    args = ap.parse_args(argv)
    md = render_md()
    if args.write:
        OUT.write_text(md, encoding="utf-8")
        print(f"wrote {OUT}")
    else:
        print(md, end="" if md.endswith("\n") else "\n")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
