#!/usr/bin/env python3
"""Generate AI-agent-friendly module + event catalogs.

Sources:
- Module manifests via ``discover_modules()`` (entry points + filesystem)
- ``EventType`` constants from ``app.core.events.types``
- ``event_bus.publish(...)`` callsites grepped from
  ``backend/app/modules/`` and ``backend/app/core/`` (publishers)
- ``get_event_handlers()`` returns from each loaded module
  (subscribers)

Outputs:
- ``docs/modules-catalog.md`` — one row per module + one section per module
- ``docs/events-catalog.md`` — one row per event with publishers + subscribers

Usage::

    python backend/scripts/generate_catalogs.py           # write the catalogs
    python backend/scripts/generate_catalogs.py --check   # exit 1 on drift

The ``--check`` mode is what the CI ``catalog-freshness`` job runs. It
compares freshly generated content against the committed files and
fails the build if they differ.

The script is intentionally side-effect-free outside the two output
files. It does not need a database — manifest discovery is static.
"""

from __future__ import annotations

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

_default_repo_root = Path(__file__).resolve().parents[2]
_env_repo_root = os.environ.get("DENTALPIN_REPO_ROOT")
REPO_ROOT = Path(_env_repo_root).resolve() if _env_repo_root else _default_repo_root
_env_backend_root = os.environ.get("DENTALPIN_BACKEND_ROOT")
BACKEND_ROOT = Path(_env_backend_root).resolve() if _env_backend_root else REPO_ROOT / "backend"
MODULES_ROOT = BACKEND_ROOT / "app" / "modules"
DOCS_ROOT = REPO_ROOT / "docs"

MODULES_CATALOG = DOCS_ROOT / "modules-catalog.md"
EVENTS_CATALOG = DOCS_ROOT / "events-catalog.md"

GENERATED_HEADER = (
    "<!-- AUTO-GENERATED by backend/scripts/generate_catalogs.py — "
    "do not edit by hand. Re-run the script after changing manifests, "
    "events, or permissions. CI fails if this file drifts. -->\n"
)


def _bootstrap_env() -> None:
    """Set minimal env vars so ``app.config.settings`` loads.

    Mirrors the stubs the ``manifest-consistency`` CI job uses. Real
    deployments override these — this script is read-only.
    """
    os.environ.setdefault(
        "DATABASE_URL",
        "postgresql+asyncpg://stub:stub@localhost:5432/stub",
    )
    os.environ.setdefault("SECRET_KEY", "catalog-generator-stub-key-32chars-minimum")
    os.environ.setdefault("ENVIRONMENT", "test")
    os.environ.setdefault("TESTING", "true")
    os.environ.setdefault("DENTALPIN_DEV_MODULE_SCAN", "true")
    sys.path.insert(0, str(BACKEND_ROOT))


_bootstrap_env()

from app.core.events.types import EventType  # noqa: E402
from app.core.plugins.loader import discover_modules  # noqa: E402

# ---------------------------------------------------------------------------
# Event publisher discovery (regex over source — good enough, no AST needed)
# ---------------------------------------------------------------------------

# Captures the first argument of an ``event_bus.publish(...)`` call —
# ``\s*`` spans newlines so multi-line calls work. The arg is one of:
#   EventType.PATIENT_CREATED     -> dotted constant on the EventType enum
#   OdontogramEventType.TOOTH_...  -> dotted constant on a module-local enum
#   "patient.created"             -> string literal
#   specific / event_name         -> a bare identifier (dynamic dispatch)
PUBLISH_ARG_RE = re.compile(r"event_bus\.publish\(\s*(?P<arg>[A-Za-z_][\w.]*|[\"'][\w.]+[\"'])")
# Any ``<Enum>.<CONST>`` reference, and any local ``CONST = "dotted.value"``
# assignment (module-local event enums like ``OdontogramEventType``).
ENUM_REF_RE = re.compile(r"\b([A-Za-z_]\w*)\.([A-Z][A-Z0-9_]*)\b")
LOCAL_CONST_RE = re.compile(r"^\s*([A-Z][A-Z0-9_]*)\s*=\s*[\"']([\w.]+)[\"']", re.MULTILINE)


def _known_event_values() -> set[str]:
    return {
        v
        for v in (getattr(EventType, a) for a in dir(EventType) if not a.startswith("_"))
        if isinstance(v, str)
    }


def _resolve_token(arg: str, local_consts: dict[str, str]) -> str | None:
    """Resolve a publish first-arg token to its event value, or None if it
    is a bare identifier (dynamic dispatch that must be handled separately)."""
    if arg[0] in "\"'":
        return arg.strip("\"'")
    if "." in arg:
        cls, _, const = arg.rpartition(".")
        if cls == "EventType":
            value = getattr(EventType, const, None)
            return value if isinstance(value, str) else None
        # Module-local enum (e.g. OdontogramEventType.SURFACE_UPDATED):
        # resolve by the constant's same-file string assignment.
        return local_consts.get(const)
    return None  # bare identifier → dynamic


def _scan_publishers() -> dict[str, list[tuple[str, str, int]]]:
    """Walk source files, return {event_value: [(module, relpath, lineno)]}.

    Handles three publish shapes so the catalog never falsely reports a
    live event as unpublished (audit S4, #94):
      1. ``publish(EventType.X, ...)`` / string literal — direct.
      2. ``publish(ModuleEnum.X, ...)`` — resolved via the constant's
         same-file string assignment.
      3. ``publish(<var>, ...)`` — dynamic dispatch (agenda status map,
         clinical_notes note-type map, notifications gateway). Every event
         constant *referenced in that file* is attributed to its module.

    ``module`` is the module name for files under
    ``backend/app/modules/<name>/``; otherwise ``core:<segment>``.
    """
    known_values = _known_event_values()
    publishers: set[tuple[str, str, str, int]] = set()
    roots = [MODULES_ROOT, BACKEND_ROOT / "app" / "core"]

    for root in roots:
        if not root.exists():
            continue
        for path in sorted(root.rglob("*.py")):
            text = path.read_text(encoding="utf-8", errors="replace")
            if "event_bus.publish" not in text:
                continue
            relpath = path.relative_to(REPO_ROOT).as_posix()
            module_name = _module_name_for(path)

            # Same-file constant table (module-local event enums).
            local_consts = {
                name: value for name, value in LOCAL_CONST_RE.findall(text) if value in known_values
            }

            has_dynamic = False
            for match in PUBLISH_ARG_RE.finditer(text):
                lineno = text.count("\n", 0, match.start()) + 1
                value = _resolve_token(match.group("arg"), local_consts)
                if value is not None:
                    publishers.add((value, module_name, relpath, lineno))
                else:
                    has_dynamic = True

            if has_dynamic:
                # Attribute every event constant referenced anywhere in the
                # file — the dispatch variable is fed from these.
                for m in re.finditer(r"EventType\.([A-Z_]+)", text):
                    value = getattr(EventType, m.group(1), None)
                    if isinstance(value, str):
                        lineno = text.count("\n", 0, m.start()) + 1
                        publishers.add((value, module_name, relpath, lineno))
                for m in ENUM_REF_RE.finditer(text):
                    value = local_consts.get(m.group(2))
                    if value is not None:
                        lineno = text.count("\n", 0, m.start()) + 1
                        publishers.add((value, module_name, relpath, lineno))

    result: dict[str, list[tuple[str, str, int]]] = defaultdict(list)
    seen: dict[str, set[tuple[str, str]]] = defaultdict(set)
    for value, module_name, relpath, lineno in sorted(publishers):
        # One row per (event, module, file) — keep the earliest line.
        if (module_name, relpath) in seen[value]:
            continue
        seen[value].add((module_name, relpath))
        result[value].append((module_name, relpath, lineno))
    return result


def _module_name_for(path: Path) -> str:
    """Best-effort attribution of a source file to a module name."""
    try:
        rel = path.relative_to(MODULES_ROOT)
    except ValueError:
        try:
            rel = path.relative_to(BACKEND_ROOT / "app")
            return f"core:{rel.parts[0]}" if rel.parts else "core"
        except ValueError:
            return "?"
    return rel.parts[0] if rel.parts else "?"


# ---------------------------------------------------------------------------
# EventType inventory
# ---------------------------------------------------------------------------


def _all_event_types() -> list[tuple[str, str]]:
    """Return [(constant_name, value), ...] sorted by value."""
    out: list[tuple[str, str]] = []
    for attr in dir(EventType):
        if attr.startswith("_"):
            continue
        value = getattr(EventType, attr)
        if isinstance(value, str):
            out.append((attr, value))
    out.sort(key=lambda pair: pair[1])
    return out


# ---------------------------------------------------------------------------
# Catalog rendering
# ---------------------------------------------------------------------------


def _render_modules_catalog(modules, publishers) -> str:
    lines: list[str] = [
        GENERATED_HEADER,
        "# Modules catalog\n",
        "Single source of truth for every module loaded into the running "
        "DentalPin instance. Generated from module manifests, "
        "`get_permissions()`, `get_event_handlers()`, and grep of "
        "`event_bus.publish` callsites.\n",
        "Maintained by `backend/scripts/generate_catalogs.py`. CI fails "
        "if a manifest changes without re-generation.\n",
        "## Summary\n",
        "| Module | Version | Category | Depends | Install | Removable | Permissions | Emits | Consumes | FE layer |",
        "|--------|---------|----------|---------|---------|-----------|-------------|-------|----------|----------|",
    ]

    by_name = {m.name: m for m in modules}
    for name in sorted(by_name):
        module = by_name[name]
        manifest = module.get_manifest()
        depends = ", ".join(manifest.depends) or "—"
        install = "auto" if manifest.auto_install else ("manual" if manifest.installable else "no")
        removable = "yes" if manifest.removable else "no"
        perms = len(module.get_permissions())
        emitted = sum(
            1
            for event, sites in publishers.items()
            for module_name, _, _ in sites
            if module_name == name
        )
        consumed = len(module.get_event_handlers())
        fe = "yes" if (manifest.frontend or {}).get("layer_path") else "no"
        lines.append(
            f"| `{name}` | {manifest.version} | {manifest.category.value} | "
            f"{depends} | {install} | {removable} | {perms} | {emitted} | "
            f"{consumed} | {fe} |"
        )

    lines.append("")
    lines.append("## Modules\n")

    for name in sorted(by_name):
        module = by_name[name]
        manifest = module.get_manifest()
        permissions = sorted(module.get_permissions())
        consumed = sorted(module.get_event_handlers().keys())
        emitted = sorted(
            {
                event
                for event, sites in publishers.items()
                for module_name, _, _ in sites
                if module_name == name
            }
        )

        lines.append(f"### `{name}` — v{manifest.version}\n")
        if manifest.summary:
            lines.append(manifest.summary + "\n")
        lines.append(f"- **Author:** {manifest.author or '—'}")
        lines.append(f"- **License:** {manifest.license or '—'}")
        lines.append(f"- **Category:** {manifest.category.value}")
        lines.append(
            "- **Install policy:** "
            f"installable={manifest.installable} · "
            f"auto_install={manifest.auto_install} · "
            f"removable={manifest.removable}"
        )
        lines.append(
            "- **Depends:** "
            + (", ".join(f"`{d}`" for d in manifest.depends) if manifest.depends else "—")
        )
        lines.append(
            "- **Frontend layer:** "
            + (
                f"`{(manifest.frontend or {}).get('layer_path')}`"
                if (manifest.frontend or {}).get("layer_path")
                else "—"
            )
        )
        if permissions:
            lines.append("- **Permissions:**")
            for perm in permissions:
                lines.append(f"  - `{name}.{perm}`")
        else:
            lines.append("- **Permissions:** —")
        if emitted:
            lines.append("- **Events emitted:**")
            for event in emitted:
                lines.append(f"  - `{event}`")
        else:
            lines.append("- **Events emitted:** —")
        if consumed:
            lines.append("- **Events consumed:**")
            for event in consumed:
                lines.append(f"  - `{event}`")
        else:
            lines.append("- **Events consumed:** —")

        per_module_doc = MODULES_ROOT / name / "CLAUDE.md"
        if per_module_doc.exists():
            rel = per_module_doc.relative_to(REPO_ROOT).as_posix()
            lines.append(f"- **Module CLAUDE.md:** [`{rel}`](../{rel})")
        lines.append("")

    return "\n".join(lines).rstrip() + "\n"


def _render_events_catalog(modules, publishers) -> str:
    lines: list[str] = [
        GENERATED_HEADER,
        "# Events catalog\n",
        "Every event declared in `app.core.events.types.EventType`, with "
        "its publishers (grepped from `event_bus.publish` callsites) and "
        "subscribers (modules that return the event from "
        "`get_event_handlers()`).\n",
        "Maintained by `backend/scripts/generate_catalogs.py`.\n",
        "## Summary\n",
        "| Event | Constant | Publishers | Subscribers |",
        "|-------|----------|------------|-------------|",
    ]

    subscribers_by_event: dict[str, list[str]] = defaultdict(list)
    for module in modules:
        for event_name in module.get_event_handlers():
            subscribers_by_event[event_name].append(module.name)
    for entries in subscribers_by_event.values():
        entries.sort()

    for const, value in _all_event_types():
        pubs = publishers.get(value, [])
        subs = subscribers_by_event.get(value, [])
        pub_modules = sorted({m for m, _, _ in pubs})
        lines.append(
            f"| `{value}` | `EventType.{const}` | "
            f"{', '.join(f'`{m}`' for m in pub_modules) or '—'} | "
            f"{', '.join(f'`{s}`' for s in subs) or '—'} |"
        )

    # Surface "feral" events — strings published in code but not in the enum.
    feral = sorted(set(publishers) - {value for _, value in _all_event_types()})
    if feral:
        lines.append("")
        lines.append("## Events published but missing from `EventType` enum\n")
        lines.append(
            "These literals appear in `event_bus.publish(...)` but do not "
            "match any `EventType` constant. Add them to "
            "`backend/app/core/events/types.py` to keep the enum "
            "authoritative.\n"
        )
        for event in feral:
            sites = publishers[event]
            lines.append(f"- `{event}` — {len(sites)} site(s):")
            for _, relpath, lineno in sites:
                lines.append(f"  - `{relpath}:{lineno}`")

    lines.append("")
    lines.append("## Detail\n")

    for const, value in _all_event_types():
        pubs = publishers.get(value, [])
        subs = subscribers_by_event.get(value, [])
        lines.append(f"### `{value}`\n")
        lines.append(f"- **Constant:** `EventType.{const}`")
        if pubs:
            lines.append("- **Publishers:**")
            for module_name, relpath, lineno in pubs:
                lines.append(f"  - `{module_name}` — `{relpath}:{lineno}`")
        else:
            lines.append("- **Publishers:** _none in tree — declared but unused_")
        if subs:
            lines.append("- **Subscribers:**")
            for module_name in subs:
                lines.append(f"  - `{module_name}`")
        else:
            lines.append("- **Subscribers:** —")
        lines.append("")

    return "\n".join(lines).rstrip() + "\n"


# ---------------------------------------------------------------------------
# Driver
# ---------------------------------------------------------------------------


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--check",
        action="store_true",
        help="Verify the committed catalogs match the freshly generated content; exit 1 on drift.",
    )
    args = parser.parse_args(argv)

    modules = discover_modules()
    publishers = _scan_publishers()

    new_modules_text = _render_modules_catalog(modules, publishers)
    new_events_text = _render_events_catalog(modules, publishers)

    if args.check:
        drift = False
        for path, expected in (
            (MODULES_CATALOG, new_modules_text),
            (EVENTS_CATALOG, new_events_text),
        ):
            actual = path.read_text(encoding="utf-8") if path.exists() else ""
            if actual != expected:
                drift = True
                rel = path.relative_to(REPO_ROOT).as_posix()
                print(
                    f"::error::{rel} is stale. "
                    f"Run `python backend/scripts/generate_catalogs.py` and commit.",
                    file=sys.stderr,
                )

        # Fail if any ``event_bus.publish`` callsite uses a string literal
        # that is not in ``EventType``. The catalog already lists them;
        # this turns the listing into an actionable CI gate so the enum
        # stays the source of truth as third-party modules accrue events.
        feral = sorted(set(publishers) - {value for _, value in _all_event_types()})
        if feral:
            for event in feral:
                sites = publishers[event]
                first = sites[0]
                print(
                    f"::error file={first[1]},line={first[2]}::"
                    f"Feral event '{event}' — add to EventType or remove publish",
                    file=sys.stderr,
                )
            return 1

        return 1 if drift else 0

    DOCS_ROOT.mkdir(parents=True, exist_ok=True)
    MODULES_CATALOG.write_text(new_modules_text, encoding="utf-8")
    EVENTS_CATALOG.write_text(new_events_text, encoding="utf-8")
    print(f"Wrote {MODULES_CATALOG.relative_to(REPO_ROOT)}")
    print(f"Wrote {EVENTS_CATALOG.relative_to(REPO_ROOT)}")
    print(f"Modules: {len(modules)}  Events: {len(_all_event_types())}")
    return 0


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