#!/usr/bin/env python3
"""Regenerate the Lua API reference from the engine itself.

The public API is declared in one place - the trx.api registry in
src/lua/api/*.lua - and that declaration is what builds the metatables. A
member the engine can reach but no script declares is not reachable from Lua at
all.

`TRX --dump-lua-api` boots Lua and prints that registry as JSON. It is committed
to docs/trx/lua/api.json so this tool - and the pre-commit hook - need no
compiled binary; `just lua-api-check` re-dumps from a real build and fails on
drift, so the committed copy cannot go stale. Committing it also means an API
change shows up as a reviewable diff.

Reference pages are generated in full. Do not edit them by hand: edit the
declaration next to the implementation instead.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
import textwrap
from pathlib import Path

ROOT = Path(__file__).resolve().parents[3]
API_JSON = ROOT / "docs/trx/lua/api.json"
DOCS_DIR = ROOT / "docs/trx/lua/reference"

INDENT = "    "

# How a declared operator reads in a script, by the metamethod it is declared
# under. The name of the type stands in for either operand.
OPERATORS = {
    "band": "{0} & {0}",
    "bor": "{0} | {0}",
    "bnot": "~{0}",
}


def signature(path: str, params: list[dict] | None) -> str:
    parts = [
        f"[{p['name']}]" if p.get("optional") else p["name"] for p in params or []
    ]
    return f"{path}({', '.join(parts)})"


# path -> the number it declares, filled in from the dump before any page is
# rendered. What holds one is typed by it, and says only what is its own.
NUMBERS: dict[str, dict] = {}

# path -> the unit it declares. What is measured in one is typed by it, and
# says only what is its own.
UNITS: dict[str, dict] = {}


# The page being written, so a link knows whether it has to cross a file.
PAGE = ""

# The declaration being written, so a link to something near it can read as
# near. A description is written against the thing it belongs to.
CONTEXT = ""

# Every path the dump says can be pointed at, and the subset of them that are
# types. Both come from the dump: what exists is decided where things are
# declared, and nothing here guesses at a name.
ANCHORS: set[str] = set()
TYPE_PATHS: set[str] = set()
# path -> the declared type it hands back, and module -> the enum its container
# is keyed by. Both come from the declarations, and both are what lets a written
# path be walked rather than guessed at.
STANDS_FOR: dict[str, str] = {}
KEYED_BY: dict[str, str] = {}
# enum path -> its constants, upper-cased. A catalog runs to hundreds of them,
# so they are not anchored one by one and a constant links to its enum.
CONSTANTS: dict[str, set[str]] = {}
# Every declared enum, so a declaration that points at one is typed by it.
ENUM_PATHS: set[str] = set()
# A member reached through a module that stands for one thing -
# `trx.lara.air` - to where the type it stands for declares it. The anchor is
# written once, at the declaration, so that is where the link has to go.
ALIASES: dict[str, str] = {}


def anchor(path: str) -> str:
    """The name a declaration is linked to, and the page it lives on.

    A path is 'module.rest', and a module owns one page, so the module names
    the file and the whole path names the anchor within it. Anchors are written
    out rather than left to the renderer's heading ids: those move when a
    heading is reworded, and a link into another page would move with it.

    Both spellings are written. A fragment matches an `id` in HTML5, while
    `name` is what a renderer that predates it looks for, and which of the two
    survives depends on what the markdown is put through.
    """
    return f'<a id="{path}" name="{path}"></a>'


def link(path: str, text: str) -> str:
    """A link to a declaration, from a page that may or may not be its own."""
    module = path.split(".", 1)[0]
    target = "" if module.upper() == PAGE else f"{module.upper()}.md"
    return f"[{text}]({target}#{path})"


REFERENCE_RE = re.compile(r"(?<!\[)`trx\.([A-Za-z0-9_.:]+?)`")


def segments(path: str) -> list[str]:
    """A written path split into its parts, each carrying the separator before it.

    A method is written the way it is called, with a colon - `trx.items.Item:num`
    - so the separator is part of what the reader sees and has to survive being
    taken apart.
    """
    return re.findall(r"[.:]?[A-Za-z0-9_]+", path)


def shortened(path: str) -> str:
    """How a written path reads from where it is written.

    A declaration names what it points at in full, so the link has somewhere to
    go and the reference survives a rename. What the reader sees is measured
    against the declaration being described: a member of the same type or group
    reads as its own name, anything else as the whole path. Sharing a module is
    not enough - `trx.game.levels` says what `levels` on its own does not.
    """
    written = segments(path)
    here = segments(CONTEXT)
    shared = 0
    while (
        shared < min(len(written) - 1, len(here))
        and written[shared].lstrip(".:") == here[shared].lstrip(".:")
    ):
        shared += 1
    if shared < 2:
        return f"trx.{path}"
    return "".join(written[shared:]).lstrip(".:")


def keys_of(spec: dict, path: str) -> set[str]:
    """The keys a call's table argument or result declares.

    A key hangs off the argument that carries it - `opts.mode` under the call
    it belongs to - and a result's keys off the call itself, since a result has
    no name of its own.
    """
    out = set()
    for param in spec.get("params") or []:
        out.add(f"{path}.{param['name']}")
        for field in param.get("fields") or []:
            out.add(f"{path}.{param['name']}.{field['name']}")
        # A hook's callback has one set of arguments, so a table it is handed
        # hangs off the hook rather than off the callback.
        for arg in param.get("params") or []:
            out.add(f"{path}.{arg['name']}")
            for field in arg.get("fields") or []:
                out.add(f"{path}.{arg['name']}.{field['name']}")
    returns = spec.get("returns")
    for one in returns if isinstance(returns, list) else [returns] if returns else []:
        for field in one.get("fields") or []:
            out.add(f"{path}.{field['name']}")
    return out


def container_path(container: dict) -> str:
    """What a container is written as.

    A module is indexed on itself; a collection a module hands out has a name
    of its own, and `trx.sound.samples` is what a script writes.
    """
    if container.get("member"):
        return f"{container['module']}.{container['member']}"
    return container["module"]


def anchors_of(api: dict) -> set[str]:
    """Every path a declaration can be pointed at by.

    Read off the declarations themselves rather than a list beside them: what
    exists is what was declared, and a second copy of that is a second thing to
    keep in step. A module that stands for one thing answers for its members
    too, since a script writes `trx.lara.air` and not the type's own path.
    """
    out = {module["name"] for module in api["modules"]}
    out.update(container_path(c) for c in api.get("containers", []))
    for group in ("numbers", "units", "enums", "functions", "properties", "namespaces", "constants"):
        out.update(entry["path"] for entry in api.get(group, []))
    for group in ("functions", "namespaces"):
        for entry in api.get(group, []):
            out.update(keys_of(entry, entry["path"]))
    members: dict[str, list[str]] = {}
    for spec in api.get("types", []):
        out.add(spec["path"])
        members[spec["path"]] = [
            member["name"]
            for kind in ("fields", "methods", "extensions")
            for member in spec.get(kind) or []
        ]
        out.update(f"{spec['path']}.{name}" for name in members[spec["path"]])
        for method in spec.get("methods") or []:
            out.update(keys_of(method, f"{spec['path']}.{method['name']}"))
    for module in api["modules"]:
        stands_for = module.get("instance_type")
        for name in members.get(stands_for, []):
            out.add(f"{module['name']}.{name}")
    return out


def anchored(path: str) -> str:
    """Where a written path lands, walked a segment at a time.

    Through the thing itself where it is anchored, through what it hands back
    where that is a type of its own - `trx.stats.pickups.count` - and through
    the constants of an enum, whether they are its own or the ones its
    container is keyed by: `trx.objects.wolf`. Nothing is guessed. A path that
    lands nowhere has been renamed or was never there, and the engine refuses
    to dump one, so neither side is the lenient one.
    """
    path = path.replace(":", ".")
    if path in ANCHORS:
        return ALIASES.get(path, path)
    parts = path.split(".")
    at = parts[0]
    if at not in ANCHORS:
        raise SystemExit(unknown(path))
    for step in parts[1:]:
        if f"{at}.{step}" in ANCHORS:
            at = f"{at}.{step}"
        elif f"{STANDS_FOR.get(at, '')}.{step}" in ANCHORS:
            at = f"{STANDS_FOR[at]}.{step}"
        elif step.upper() in CONSTANTS.get(KEYED_BY.get(at, ""), set()):
            return at
        elif step.upper() in CONSTANTS.get(at, set()):
            return at
        else:
            raise SystemExit(unknown(path))
    return at


def where() -> str:
    """The declaration the text being read sits in.

    Most of what the writer needs: the same name can be written in a dozen
    places, and only one of them has to move.
    """
    return f" in `trx.{CONTEXT}`" if CONTEXT else ""


def unknown(path: str) -> str:
    """A name that lands nowhere, and the declaration it was read from."""
    return (
        f"error: `trx.{path}`{where()} names nothing the API declares.\n"
        f"  fix the reference in src/lua/api/, or declare what it names"
    )


# A page of the manual, written from the root of the repository so that what it
# names is checkable, and rewritten to reach it from the page being generated.
DOC_LINK_RE = re.compile(r"\]\((docs/[\w./-]+\.md)(#[\w.-]+)?\)")


def linked_docs(text: str) -> str:
    """Prose with every page of the manual it names pointed at from here.

    A page is named from the root, which is where it can be looked for; where
    it lands is this page's business, and depends on which page is being
    written.
    """

    def one(match: re.Match) -> str:
        target = ROOT / match.group(1)
        if not target.exists():
            raise SystemExit(
                f"error: {match.group(1)} names no page of the manual.\n"
                f"  name it from the root of the repository, as it is on disk"
            )
        return f"]({os.path.relpath(target, DOCS_DIR)}{match.group(2) or ''})"

    return DOC_LINK_RE.sub(one, text)


def linked_prose(text: str) -> str:
    """Prose with every `trx....` it names turned into a link.

    A description names the thing it is talking about, so the link is already
    written; it is only missing its destination. Descriptions carry no fenced
    code, and an example is a field of its own, so nothing inside sample code
    is reached from here.
    """

    def one(match: re.Match) -> str:
        written = match.group(1)
        return link(anchored(written), f"`{shortened(written)}`")

    return REFERENCE_RE.sub(one, linked_docs(unmarked(text)))


def typed(spec: dict) -> str:
    """The type a declaration reads as, and whether it holds one or many.

    A number that names an enum is that enum: `trx.assault.Track` says what
    `integer` does not, and the constants are a link away. `list` says the
    declaration holds several of them, which a description used to.
    """
    named = type_names(spec["type"])
    return f"a list of {named}" if spec.get("list") else named


def type_names(name) -> str:
    """What a declaration accepts: one type, or the several a key answers to."""
    if isinstance(name, list):
        return " or ".join(type_name(one) for one in name)
    return type_name(name)


def type_name(name: str) -> str:
    """A type as the reader would write it, linked to where it is declared.

    A declared type is named by its path, and reads as `trx.items.Item`. So is
    an enum and a number: what a declaration is typed by is what it means. A
    primitive - integer, table, vec3 - has no declaration and stays as it is.
    """
    if name in TYPE_PATHS or name in ENUM_PATHS or name in NUMBERS or name in UNITS:
        return link(name, f"trx.{name}")
    if "." in name:
        raise SystemExit(f"error: type `{name}`{where()} names no declared type")
    return name


def at(path: str) -> None:
    """Name the declaration the descriptions that follow are written against."""
    global CONTEXT
    CONTEXT = path


def describe(spec: dict) -> str:
    """What the spec says, and what it says by what it is typed by.

    A number says what it counts and where it counts from, once, where it is
    declared. What holds one is typed by it and says only what is its own, so
    nothing here reaches for someone else's words.
    """
    desc = linked_prose(spec.get("description") or "")
    base = spec.get("base")
    if base is not None:
        desc = f"{desc} Counted from {base}.".lstrip()
    return desc


def render_callback_args(param: dict, indent: str) -> list[str]:
    """The arguments a callback parameter is itself called with.

    `params` on a function-typed parameter describes the signature the engine
    invokes it with - which for an event hook is the only signature that matters,
    since the hook itself takes nothing but the callback.
    """
    args = param.get("params")
    if not args:
        return []
    out = [f"{indent}Called with:"]
    owner = CONTEXT
    for arg in args:
        out.extend(
            render_lead(
                f"{indent}- {anchor(f'{owner}.{arg['name']}')}"
                f"**`{arg['name']}`** ({typed(arg)}). ",
                describe(arg),
                f"{indent}  ",
            )
        )
        out.extend(
            render_fields(arg, f"{owner}.{arg['name']}", f"{indent}  ")
        )
    return out


def render_prose(text: str, indent: str) -> list[str]:
    """A description under a list item, indented so it stays inside the item.

    Descriptions can run to several paragraphs. Indenting only the first line
    would end the list at the blank line and dump the rest at the page's top
    level.
    """
    return [
        f"{indent}{line}".rstrip() for line in linked_prose(text).splitlines()
    ]


def render_lead(head: str, text: str, indent: str) -> list[str]:
    """A line whose text may run to several paragraphs.

    Only the first line fits after the head; the rest is indented so it stays
    inside the list item instead of ending it.
    """
    first, _, rest = text.partition("\n")
    out = [f"{head}{first}".rstrip()]
    if rest:
        out.extend(render_prose(rest, indent))
    return out


# enum path -> { value: constant name }, filled in from the dump before any page
# is rendered.
ENUM_CONSTANTS: dict[str, dict[int, str]] = {}


def render_default(spec: dict) -> str:
    """The default as a script would write it.

    A default that names an enum constant is stored as the constant's value,
    since that is what the wrapper substitutes when the argument is omitted. It
    reads as the constant, and links to the enum, which is where the constants
    are listed.
    """
    default = spec.get("default")
    named = spec.get("type", "")
    if isinstance(named, list):
        named = ""
    constants = ENUM_CONSTANTS.get(named, {})
    if default in constants and not isinstance(default, bool):
        return link(named, f"`trx.{named}.{constants[default]}`")
    if isinstance(default, bool):
        return "`true`" if default else "`false`"
    if isinstance(default, str):
        return f'`"{default}"`'
    return f"`{default}`"


def render_bits(spec: dict) -> str:
    """The type a member takes, and whether it may be left out."""
    bits = [typed(spec)]
    if spec.get("optional"):
        default = spec.get("default")
        bits.append(
            "optional"
            if default is None
            else f"optional, default {render_default(spec)}"
        )
    return ", ".join(bits)


def render_fields(
    spec: dict, prefix: str, indent: str
) -> list[str]:
    """The keys a table argument or result is made of.

    A table declares what it holds where it is declared, so each key has a type
    and an anchor of its own and is pointed at like anything else. Before this
    the keys were spelled out in the description, which left them unlinkable
    and said again in every place that passed the same table. A list says the
    same of each of its entries.
    """
    fields = spec.get("fields")
    if not fields:
        return []
    owner = CONTEXT
    out = ["", f"{indent}Each entry:" if spec.get("list") else f"{indent}Keys:"]
    for field in fields:
        at(f"{prefix}.{field['name']}")
        out.extend(
            render_lead(
                f"{indent}- {anchor(prefix + '.' + field['name'])}"
                f"**`{field['name']}`** ({render_bits(field)}). ",
                describe(field),
                f"{indent}  ",
            )
        )
    at(owner)
    return out


def render_params(params: list[dict] | None, indent: str) -> list[str]:
    if not params:
        return []
    out = ["", f"{indent}Parameters:"]
    for param in params:
        prefix = f"{CONTEXT}.{param['name']}"
        out.extend(
            render_lead(
                f"{indent}- {anchor(prefix)}**`{param['name']}`** "
                f"({render_bits(param)}). ",
                describe(param),
                f"{indent}  ",
            )
        )
        out.extend(render_callback_args(param, f"{indent}  "))
        out.extend(render_fields(param, prefix, f"{indent}  "))
    return out


def one_return(spec: dict) -> str:
    """What a call hands back, and whether it hands back one or many of them.

    A row that has a type of its own says `list` and names it; one declared
    where it is returned spells its keys out below instead.
    """
    bits = [typed(spec)]
    if spec.get("nullable"):
        bits.append("or `nil`")
    return f"{' '.join(bits)}. {describe(spec)}".rstrip()


def render_returns(returns: dict | list | None, indent: str) -> list[str]:
    """One return, or the several a Lua function is free to hand back."""
    if not returns:
        return []
    if isinstance(returns, dict):
        out = ["", *render_lead(f"{indent}Returns: ", one_return(returns), f"{indent}  ")]
        return out + render_fields(returns, CONTEXT, f"{indent}  ")
    out = ["", f"{indent}Returns:"]
    for spec in returns:
        out.extend(render_lead(f"{indent}- ", one_return(spec), f"{indent}  "))
        out.extend(render_fields(spec, CONTEXT, f"{indent}  "))
    return out


def render_examples(examples: list[str] | None, indent: str) -> list[str]:
    out = []
    for example in examples or []:
        out.append("")
        out.append(f"{indent}Example:")
        out.append(f"{indent}```lua")
        out.extend(f"{indent}{line}" for line in example.splitlines())
        out.append(f"{indent}```")
    return out


def render_function(func: dict) -> list[str]:
    at(func["path"])
    out = [
        f"- {anchor(func['path'])}[lua]"
        f"`trx.{signature(func['path'], func.get('params'))}`  "
    ]
    if func.get("description"):
        out.extend(render_prose(func["description"], "  "))
    out.extend(render_params(func.get("params"), "  "))
    out.extend(render_returns(func.get("returns"), "  "))
    out.extend(render_examples(func.get("examples"), "  "))
    out.append("")
    return out


def render_namespace(spec: dict) -> list[str]:
    """A grouping table on a module, holding related members under one name.

    Its members render as ordinary functions, since they carry the full path.
    What needs a bullet of its own is what the members' signatures cannot say:
    the group's own description, and, when it is callable, that the group may be
    called directly.
    """
    at(spec["path"])
    if spec.get("callable"):
        head = (
            f"- {anchor(spec['path'])}[lua]"
            f"`trx.{signature(spec['path'], spec.get('params'))}`  "
        )
    else:
        head = f"- {anchor(spec['path'])}[lua]`trx.{spec['path']}`  "
    out = [head]
    if spec.get("description"):
        out.extend(render_prose(spec["description"], "  "))
    out.extend(render_params(spec.get("params"), "  "))
    out.extend(render_returns(spec.get("returns"), "  "))
    out.extend(render_examples(spec.get("examples"), "  "))
    out.append("")
    return out


def render_const(spec: dict) -> list[str]:
    at(spec["path"])
    named = f" ({type_name(spec['type'])})" if spec.get("type") else ""
    out = [
        f"- {anchor(spec['path'])}[lua]`trx.{spec['path']}` = "
        f"`{spec['value']}`{named}  "
    ]
    if spec.get("description"):
        out.extend(render_prose(spec["description"], "  "))
    out.append("")
    return out


def render_enum_names(names: list[str] | None, indent: str) -> list[str]:
    """The names of a bulk enum, wrapped, and folded away."""
    if not names:
        return []
    out = [
        "",
        f"{indent}<details><summary>Click here to see a list of all symbols.</summary>",
        "",
    ]
    quoted = ", ".join(f"`{name}`" for name in names)
    for line in textwrap.wrap(quoted, width=76, break_long_words=False):
        out.append(f"{indent}{line}")
    out.append("")
    out.append(f"{indent}</details>")
    return out


def render_enum(spec: dict) -> list[str]:
    at(spec["path"])
    # A bulk enum is described as a whole: a count and a folded list of names,
    # rather than several hundred bullet points. The values stay out.
    if spec.get("bulk"):
        head = f"- {anchor(spec['path'])}[lua]`trx.{spec['path']}`"
        count = spec.get("count")
        if count:
            head += f" - {count} names"
        out = [head, ""]
        if spec.get("description"):
            out.extend(render_prose(spec["description"], INDENT))
            out.append("")
        out.extend(render_examples(spec.get("examples"), INDENT))
        out.extend(render_enum_names(spec.get("names"), INDENT))
        out.append("")
        return out

    out = [f"- {anchor(spec['path'])}[lua]`trx.{spec['path']}`", ""]
    if spec.get("description"):
        out.extend(render_prose(spec["description"], INDENT))
        out.append("")
    for value in spec.get("values") or []:
        at(f"{spec['path']}.{value['name']}")
        out.append(f"{INDENT}- `trx.{spec['path']}.{value['name']}` = `{value['value']}`  ")
        out.extend(render_prose(value.get("description") or "", f"{INDENT}    "))
    out.append("")
    return out


def render_property(spec: dict) -> list[str]:
    """A computed member on the module table.

    Reading it calls into the engine, so it is neither a function nor a constant.
    """
    at(spec["path"])
    suffix = "" if spec.get("writable") else " *(read-only)*"
    return render_lead(
        f"- {anchor(spec['path'])}**`trx.{spec['path']}`** "
        f"({typed(spec)}). ",
        f"{describe(spec)}{suffix}".lstrip(),
        "  ",
    )


def render_type(spec: dict) -> list[str]:
    at(spec["path"])
    out = [f"- {anchor(spec['path'])}[lua]`trx.{spec['path']}`", ""]
    if spec.get("description"):
        out.extend(render_prose(spec["description"], INDENT))
        out.append("")
    if spec.get("handle"):
        out.append(
            f"{INDENT}Handles are live references: if the underlying object is destroyed,"
        )
        out.append(
            f"{INDENT}using the handle raises an error rather than silently reading an"
        )
        out.append(f"{INDENT}unrelated one.")
        out.append("")

    if spec.get("fields"):
        out.append(f"{INDENT}Properties:")
        for field in spec["fields"]:
            at(f"{spec['path']}.{field['name']}")
            # A handle's field addresses a struct member and a Lua class's is a
            # pair of accessors, so either may be read-only. A record a call
            # hands back says nothing either way, and carries no key.
            suffix = "" if field.get("writable") is not False else " *(read-only)*"
            out.extend(
                render_lead(
                    f"{INDENT}- {anchor(spec['path'] + '.' + field['name'])}"
                    f"**`{field['name']}`**: {render_bits(field)}. ",
                    f"{describe(field)}{suffix}".lstrip(),
                    f"{INDENT}  ",
                )
            )
        out.append("")

    if spec.get("extensions"):
        out.append(f"{INDENT}Computed properties (derived, not stored on the object):")
        for ext in spec["extensions"]:
            at(f"{spec['path']}.{ext['name']}")
            out.extend(
                render_lead(
                    f"{INDENT}- {anchor(spec['path'] + '.' + ext['name'])}"
                    f"**`{ext['name']}`**: {typed(ext)}. ",
                    describe(ext),
                    f"{INDENT}  ",
                )
            )
        out.append("")

    if spec.get("operators"):
        name = spec["path"].rsplit(".", 1)[-1].lower()
        out.append(f"{INDENT}Operators:")
        for operator in spec["operators"]:
            out.extend(
                render_lead(
                    f"{INDENT}- **`{OPERATORS[operator['name']].format(name)}`**. ",
                    describe(operator),
                    f"{INDENT}  ",
                )
            )
        out.append("")

    if spec.get("methods"):
        out.append(f"{INDENT}Methods:")
        out.append("")

    for method in spec.get("methods") or []:
        at(f"{spec['path']}.{method['name']}")
        name = spec["path"].rsplit(".", 1)[-1].lower()
        out.append(
            f"{INDENT}- {anchor(spec['path'] + '.' + method['name'])}"
            f"[lua]`{name}:{signature(method['name'], method.get('params'))}`  "
        )
        if method.get("description"):
            out.extend(render_prose(method["description"], f"{INDENT}  "))
        out.extend(render_params(method.get("params"), f"{INDENT}  "))
        out.extend(render_returns(method.get("returns"), f"{INDENT}  "))
        out.extend(render_examples(method.get("examples"), f"{INDENT}  "))
        out.append("")
    return out


def render_container(name: str, spec: dict) -> list[str]:
    """A collection, and what a script may index it with.

    The key is named as well as the value: strict mode holds a script to it, so
    a reader who is handed an error about it has somewhere to have read it.
    """
    at(name)
    key, value = spec["key"], spec["value"]
    nullable = " or `nil`" if value.get("nullable") else ""
    out = []
    if spec.get("description"):
        out.extend(render_prose(spec["description"], ""))
        out.append("")
    out.extend(
        render_lead(
            f"- {anchor(name + '[]')}**`trx.{name}[key]`** "
            f"(key: {typed(key)}, value: {typed(value)}{nullable}). ",
            describe(key),
            "  ",
        )
    )
    if spec.get("countable"):
        out.append(f"- **`#trx.{name}`** (integer). How many there are.")
    out.extend(render_examples(spec.get("examples"), ""))
    out.append("")
    return out


def render_number(number: dict) -> list[str]:
    """A named number: what it counts and where it counts from.

    It is a type like any other - what a room number is is not the business of
    the thirteen declarations that hold one - so it renders among them, and
    everything that holds one names it and says only what is its own.
    """
    at(number["path"])
    out = [f"- {anchor(number['path'])}[lua]`trx.{number['path']}`", ""]
    out.extend(render_prose(describe(number), INDENT))
    out.append("")
    return out


def render_unit(unit: dict) -> list[str]:
    """A named unit: what a value of it is measured in.

    It renders among the types for the same reason a number does - what a
    world unit is is not the business of the six declarations measured in one.
    """
    at(unit["path"])
    out = [
        f"- {anchor(unit['path'])}[lua]`trx.{unit['path']}` ({unit['type']})",
        "",
    ]
    out.extend(render_prose(describe(unit), INDENT))
    out.append("")
    return out


def render_page(module: dict, api: dict) -> str:
    global PAGE
    name = module["name"]
    PAGE = name.upper()
    at(name)
    # Declare a title where capitalizing the module name makes a worse one.
    title = module.get("title") or name.capitalize()
    out = [
        "---",
        f"title: {title}",
        f"order: {module.get('order', 99)}",
        "---",
        "",
        "<!--",
        "  GENERATED FILE - do not edit.",
        "  Regenerate with: just lua-api-dump",
        "  The public API is declared next to its implementation, in",
        f"  src/lua/api/{name}.lua. Edit it there.",
        "-->",
        "",
        f"## {anchor(name)}{title} module",
        "",
    ]
    if module.get("description"):
        out.extend(render_prose(module["description"], ""))
        out.append("")

    numbers = [
        n for n in api.get("numbers", []) if n["path"].startswith(f"{name}.")
    ]
    units = [u for u in api.get("units", []) if u["path"].startswith(f"{name}.")]

    mine = [c for c in api.get("containers", []) if c["module"] == name]
    if mine:
        out.append("### Indexing")
        out.append("")
        for container in mine:
            out.extend(render_container(container_path(container), container))

    # A group raised by a property declaration has nothing to say that its
    # members do not: the properties carry the full dotted path already.
    spaces = [
        n
        for n in api.get("namespaces", [])
        if n["path"].startswith(f"{name}.") and not n.get("implicit")
    ]

    props = [p for p in api.get("properties", []) if p["path"].startswith(f"{name}.")]
    if props:
        out.append("### Properties")
        out.append("")
        for spec in props:
            out.extend(render_property(spec))
        out.append("")

    consts = [c for c in api.get("constants", []) if c["path"].startswith(f"{name}.")]
    if consts:
        out.append("### Constants")
        out.append("")
        for spec in consts:
            out.extend(render_const(spec))

    enums = [e for e in api.get("enums", []) if e["path"].startswith(f"{name}.")]
    if enums:
        out.append("### Enums")
        out.append("")
        for spec in enums:
            out.extend(render_enum(spec))

    types = [t for t in api["types"] if t["path"].startswith(f"{name}.")]
    if types or numbers or units:
        out.append("### Structures")
        out.append("")
        for number in numbers:
            out.extend(render_number(number))
        for unit in units:
            out.extend(render_unit(unit))
        for spec in types:
            out.extend(render_type(spec))

    funcs = [f for f in api["functions"] if f["path"].startswith(f"{name}.")]
    if funcs or spaces:
        out.append("### Functions")
        out.append("")
        for spec in spaces:
            out.extend(render_namespace(spec))
        for func in funcs:
            out.extend(render_function(func))

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


def read(api: dict) -> None:
    """Fill the tables a page is rendered against, from the dump.

    Everything the renderer needs beyond the entry in front of it - what
    exists, what stands for what, what an enum holds - is read off the dump
    once, here, so a caller that renders a page renders what the tool does.
    """
    ENUM_PATHS.update(enum["path"] for enum in api.get("enums", []))
    for enum in api.get("enums", []):
        ENUM_CONSTANTS[enum["path"]] = {
            value["value"]: value["name"] for value in enum.get("values", [])
        }
    for number in api.get("numbers", []):
        NUMBERS[number["path"]] = number
    for unit in api.get("units", []):
        UNITS[unit["path"]] = unit
    for spec in api.get("types", []):
        TYPE_PATHS.add(spec["path"])
        for kind in ("fields", "extensions"):
            for member in spec.get(kind) or []:
                STANDS_FOR[f"{spec['path']}.{member['name']}"] = member.get("type")
    ANCHORS.update(anchors_of(api))
    for prop in api.get("properties", []):
        STANDS_FOR[prop["path"]] = prop["type"]
    for module in api["modules"]:
        # A module standing for one thing answers for its members, so a path
        # walked through the module walks on through the type it stands for.
        stands_for = module.get("instance_type")
        if stands_for is None:
            continue
        spec = next(s for s in api["types"] if s["path"] == stands_for)
        for kind in ("fields", "extensions"):
            for member in spec.get(kind) or []:
                STANDS_FOR[f"{module['name']}.{member['name']}"] = member.get("type")
        for kind in ("fields", "methods", "extensions"):
            for member in spec.get(kind) or []:
                ALIASES[f"{module['name']}.{member['name']}"] = (
                    f"{stands_for}.{member['name']}"
                )
    for container in api.get("containers", []):
        accepted = (container.get("key") or {}).get("type")
        if isinstance(accepted, list):
            accepted = next((one for one in accepted if one in ENUM_PATHS), None)
        if accepted:
            KEYED_BY[container_path(container)] = accepted
    for enum in api.get("enums", []):
        ENUM_PATHS.add(enum["path"])
        CONSTANTS[enum["path"]] = {
            name.upper()
            for name in (enum.get("names") or [])
            + [v["name"] for v in enum.get("values") or []]
        }


def documented(spec: dict) -> bool:
    """Whether the reader is told anything: its own words, or the type it holds.

    A declaration typed by something this surface declares needs no words of
    its own: what it is, and what it may be, is what the type says, on the page
    the reader is a link away from. Words that only name the type again read as
    an afterthought hanging off it.
    """
    if spec.get("description"):
        return True
    named = spec.get("type")
    if isinstance(named, list):
        return True
    return (
        named in TYPE_PATHS
        or named in ENUM_PATHS
        or named in NUMBERS
        or named in UNITS
    )


def undocumented(api: dict) -> list[str]:
    """Declared names that say nothing - they render as a blank line."""
    out = []
    for func in api.get("functions", []):
        if not documented(func):
            out.append(func["path"])
    for const in api.get("constants", []):
        if not documented(const):
            out.append(const["path"])
    for prop in api.get("properties", []):
        if not documented(prop):
            out.append(prop["path"])
    for space in api.get("namespaces", []):
        # A property group renders as its members, so it has no prose of its own.
        if not space.get("description") and not space.get("implicit"):
            out.append(space["path"])
    for container in api.get("containers", []):
        if not container.get("description"):
            out.append(f"{container_path(container)}[]")
    for enum in api.get("enums", []):
        for value in enum.get("values") or []:
            if not value.get("description"):
                out.append(f"{enum['path']}.{value['name']}")
    for spec in api.get("types", []):
        for kind in ("fields", "methods", "extensions", "operators"):
            for member in spec.get(kind) or []:
                if not documented(member):
                    out.append(f"{spec['path']}.{member['name']}")
    for group in ("functions", "namespaces"):
        for entry in api.get(group, []):
            out.extend(blank_keys(entry, entry["path"]))
            out.extend(blank_args(entry, entry["path"]))
    for spec in api.get("types", []):
        for method in spec.get("methods") or []:
            path = f"{spec['path']}.{method['name']}"
            out.extend(blank_keys(method, path))
            out.extend(blank_args(method, path))
    return out


def blank_args(spec: dict, path: str) -> list[str]:
    """Arguments and results that say nothing.

    A parameter renders as its name and its type, and a result as its type
    alone, so one with no words leaves the reader to guess what it is for from
    the name. A `ref` counts as words: it says what the number is.

    A result holding several of something is named by the call that hands it
    back, and the words left to write above get_property_names would be "the
    names".
    """
    out = []
    for param in spec.get("params") or []:
        if not documented(param):
            out.append(f"{path}({param['name']})")
        for arg in param.get("params") or []:
            if not documented(arg):
                out.append(f"{path}({param['name']}) -> {arg['name']}")
    returns = spec.get("returns")
    for one in returns if isinstance(returns, list) else [returns] if returns else []:
        if not documented(one) and not one.get("list"):
            out.append(f"{path} returns {one['type']}")
    return out


# What a description may name in backticks without pointing at a declaration.
# Everything else identifier-shaped is either a path the API declares, or a
# literal marked as one - see NOREF.
LUA_NAMES = {
    # Lua itself, and the API's own name.
    "trx",
    "math",
    "nil",
    "true",
    "false",
    "pairs()",
    "ipairs()",
    "tostring()",
    "tonumber()",
    # The types a declaration is written in, which name themselves.
    "any",
    "boolean",
    "function",
    "integer",
    "number",
    "string",
    "table",
    "vec3",
}

# A description says this where a backticked name is not an API path but a
# literal a player or a file carries: a config key, a file name, a value a
# setting takes. The marker names them, so what is waived is on the page it is
# waived from, and it comes off before the text is rendered.
NOREF_RE = re.compile(r"<!--\s*noref:?\s*(.*?)\s*-->")

# Identifier-shaped, which is what a reference looks like. A number, a quoted
# string, a flag spelling or anything with a space in it is not one, and an
# enum constant is reached through the enum rather than by a path of its own.
NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.:]*(?:\(\))?")
BACKTICKED_RE = re.compile(r"`([^`]+)`")


def unmarked(text: str) -> str:
    """A description as the reader sees it, with the noref markers taken off.

    The marker is a note to whoever writes the declaration, so it comes off
    before the page is written, along with the space it sat in.
    """
    out = NOREF_RE.sub("", text)
    out = re.sub(r"[ \t]{2,}", " ", out)
    return "\n".join(line.rstrip() for line in out.splitlines()).strip()


def waived(text: str) -> set[str]:
    """The names a description marks as literals, and not references."""
    out: set[str] = set()
    for marker in NOREF_RE.findall(text):
        out.update(name.strip(" `") for name in marker.split(",") if name.strip())
    return out


def descriptions(api: dict) -> list[tuple[str, str]]:
    """Every description the dump carries, and the declaration it belongs to."""
    out: list[tuple[str, str]] = []

    def walk(spec: dict, path: str) -> None:
        if not isinstance(spec, dict):
            return
        if spec.get("description"):
            out.append((path, spec["description"]))
        for param in spec.get("params") or []:
            walk(param, path)
        for field in spec.get("fields") or []:
            if "name" in field:
                walk(field, path)
        returns = spec.get("returns")
        for one in (
            returns if isinstance(returns, list) else [returns] if returns else []
        ):
            walk(one, path)
        for kind in ("methods", "extensions", "operators", "values"):
            for member in spec.get(kind) or []:
                walk(member, f"{path}.{member['name']}")

    for module in api.get("modules", []):
        walk(module, module["name"])
    for group in ("numbers", "units", "enums", "functions", "properties", "namespaces", "constants"):
        for entry in api.get(group, []):
            walk(entry, entry["path"])
    for spec in api.get("types", []):
        walk(spec, spec["path"])
    for container in api.get("containers", []):
        walk(container, container_path(container))
        for side in ("key", "value"):
            if container.get(side):
                walk(container[side], container_path(container))
    return out


def unreferenced(api: dict) -> list[str]:
    """Names a description writes in backticks that point at nothing.

    A backticked name reads as something the reader can go and look at, so it
    is either a path the API declares - written in full, and rendered as a link
    - or a literal, and saying which is what the noref marker is for. A name
    that is neither is a reference to something renamed, or one the reader
    cannot follow.
    """
    out = []
    for path, text in descriptions(api):
        exempt = waived(text)
        for span in BACKTICKED_RE.findall(NOREF_RE.sub("", text)):
            if span.startswith("trx."):
                continue
            if not NAME_RE.fullmatch(span):
                continue
            if span.isupper() or span in LUA_NAMES or span in exempt:
                continue
            out.append(
                f"{path}: `{span}` names nothing.\n"
                f"    write the whole path, `trx....`, or mark it a literal with "
                f"<!--noref: {span}-->"
            )
    return out


def respelled(api: dict) -> list[str]:
    """Where a number's or a unit's own words are written somewhere else.

    A named number says what it counts and where it counts from, once, and a
    named unit says what a value of it is measured in. A declaration that holds
    one names it and says only what is its own, so the same sentence written
    again is a copy that will drift, and a base written again is one that can
    disagree.
    """
    numbers = {number["path"]: number for number in api.get("numbers", [])}
    units = {unit["path"]: unit for unit in api.get("units", [])}
    # The first clause is the sentence's claim: "Room number", "Cutscene
    # number". What follows it is detail nobody would repeat by accident.
    claims = {
        path: re.split(r"[,.]", number["description"])[0].strip().lower()
        for path, number in numbers.items()
    }
    # A unit is not recognised by its first clause the way a number is - "an
    # angle in the engine's own units" is not what a declaration holding one
    # would write. It names the words that mean it instead.
    spellings = {
        path: [s.lower() for s in unit.get("spellings") or []]
        for path, unit in units.items()
    }
    pointed = set()
    out = []

    def check(spec: dict, where: str) -> None:
        if not isinstance(spec, dict):
            return
        named = spec.get("type")
        if isinstance(named, list):
            named = None
        if named in numbers:
            pointed.add(named)
            if spec.get("base") is not None:
                out.append(
                    f"{where}: counts from its own base while naming "
                    f"`trx.{named}`, which declares one"
                )
        if named in units:
            pointed.add(named)
        text = (spec.get("description") or "").lower()
        for path, written in spellings.items():
            for one in written:
                if one in text and named != path:
                    out.append(
                        f"{where}: writes out what `trx.{path}` is.\n"
                        f"    name the unit - type it `trx.{path}` - and say only "
                        f"what is this one's own"
                    )
        for path, claim in claims.items():
            if claim and claim in text and named != path:
                out.append(
                    f"{where}: says what `trx.{path}` says.\n"
                    f"    name the number - type it `trx.{path}` - and say only "
                    f"what is this one's own"
                )

    for where, spec in walk(api):
        check(spec, where)
    for path in numbers:
        if path not in pointed:
            out.append(f"{path}: a number nothing holds. Delete it, or type what holds one")
    for path in units:
        if path not in pointed:
            out.append(f"{path}: a unit nothing is measured in. Delete it, or type what is")
    return out


def walk(api: dict) -> list[tuple[str, dict]]:
    """Every spec the dump carries, and the declaration it belongs to."""
    out: list[tuple[str, dict]] = []

    def rec(spec: dict, where: str) -> None:
        if not isinstance(spec, dict):
            return
        out.append((where, spec))
        for param in spec.get("params") or []:
            rec(param, f"{where}({param.get('name')})")
        for field in spec.get("fields") or []:
            if isinstance(field, dict) and "name" in field:
                rec(field, f"{where}.{field['name']}")
        returns = spec.get("returns")
        for one in (
            returns if isinstance(returns, list) else [returns] if returns else []
        ):
            rec(one, f"{where} returns")
        for kind in ("methods", "extensions", "values"):
            for member in spec.get(kind) or []:
                if isinstance(member, dict):
                    rec(member, f"{where}.{member.get('name')}")

    for group in ("modules", "functions", "properties", "namespaces", "constants", "enums", "types"):
        for entry in api.get(group, []):
            rec(entry, entry.get("path") or entry.get("name"))
    for container in api.get("containers", []):
        for side in ("key", "value"):
            if container.get(side):
                rec(container[side], f"{container_path(container)}[{side}]")
    return out


def blank_keys(spec: dict, path: str) -> list[str]:
    """Declared keys that say nothing."""
    return [key for key in sorted(keys_of(spec, path)) if not described_key(spec, key)]


def described_key(spec: dict, key: str) -> bool:
    """Whether the key named by a path carries a description."""
    name = key.rsplit(".", 1)[-1]
    everything = []
    for param in spec.get("params") or []:
        everything.extend(param.get("fields") or [])
        for arg in param.get("params") or []:
            everything.extend(arg.get("fields") or [])
    returns = spec.get("returns")
    for one in returns if isinstance(returns, list) else [returns] if returns else []:
        everything.extend(one.get("fields") or [])
    return all(documented(f) for f in everything if f["name"] == name)


def dump_api(binary: Path) -> str:
    result = subprocess.run(
        [str(binary), "--dump-lua-api"], capture_output=True, text=True, check=True
    )
    # The engine logs to stdout alongside the JSON, so take the payload rather
    # than the whole stream. Validate before writing: a broken build must not be
    # able to poison the committed artifact.
    payload = next(
        (line for line in result.stdout.splitlines() if line.startswith("{")), None
    )
    if payload is None:
        raise SystemExit(
            f"error: {binary} --dump-lua-api produced no JSON.\n{result.stdout[-500:]}"
        )
    return json.dumps(json.loads(payload), indent=2, sort_keys=True) + "\n"


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--dump-from",
        type=Path,
        metavar="BINARY",
        help=f"run BINARY --dump-lua-api and refresh {API_JSON.name} first",
    )
    parser.add_argument(
        "--check",
        action="store_true",
        help="fail if any page would change instead of rewriting it",
    )
    args = parser.parse_args()

    # The api.json refresh needs a built engine to query. --dump-from names it
    # explicitly; the hook has no binary to pass, so it reads TRX_BINARY and
    # refreshes only when that points at a real build. Without it the tool
    # regenerates the pages from the committed api.json.
    binary = args.dump_from
    if binary is None:
        env_binary = os.environ.get("TRX_BINARY")
        if env_binary and Path(env_binary).exists():
            binary = Path(env_binary)

    if binary is not None:
        API_JSON.write_text(dump_api(binary))

    if not API_JSON.exists():
        raise SystemExit(
            f"error: {API_JSON} is missing. Build TRX, then run:\n"
            f"  just lua-api-dump"
        )
    api = json.loads(API_JSON.read_text())

    read(api)

    blank = undocumented(api)
    if blank:
        print("error: declared with no description:", file=sys.stderr)
        for path in blank:
            print(f"  {path}", file=sys.stderr)
        print("document it in src/lua/api/, next to the declaration", file=sys.stderr)
        return 1

    loose = unreferenced(api)
    if loose:
        print("error: a description names something unreachable:", file=sys.stderr)
        for report in loose:
            print(f"  {report}", file=sys.stderr)
        return 1

    copied = respelled(api)
    if copied:
        print("error: a number is described away from itself:", file=sys.stderr)
        for report in copied:
            print(f"  {report}", file=sys.stderr)
        return 1

    stale = []
    for module in api["modules"]:
        path = DOCS_DIR / f"{module['name'].upper()}.md"
        new = render_page(module, api)
        old = path.read_text() if path.exists() else None
        if new == old:
            continue
        if args.check:
            stale.append(path.relative_to(ROOT))
        else:
            path.write_text(new)
            print(f"generated {path.relative_to(ROOT)}")

    if stale:
        print("error: generated Lua docs are out of date:", file=sys.stderr)
        for path in stale:
            print(f"  {path}", file=sys.stderr)
        print("run: just lua-api-dump", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
