#!/usr/bin/env python3
"""Read the symbol atlas and the figure half, and watch Babelon's rooms for convergence.

  atlas.py symbol rainbow
  atlas.py search mirror
  atlas.py seed --world "Creation and Cosmos"
  atlas.py figure korrigan
  atlas.py figure-search wisp
  atlas.py figure-relations korrigan
  atlas.py convergence path/to/invisible-rooms/rooms

The first three read the built symbol atlas. The next three read the five files
jawnomicon.com publishes under /babelon/, because a reader that could resolve
`rainbow` and not `Korrigan` was answering half the question anyone arrives
with. They fetch on demand and cache for the run; `--data` reads a directory of
downloaded copies instead, and `--offline` refuses the network outright.

`figure-relations` is the one that needs saying out loud. A relation row carries
no sentence -- it carries `cited_from` and a `span` of character offsets, and the
warrant is whatever those offsets cut out of *our* description. So the reader
fetches descriptions.json and takes the slice, and what it prints is jawnomicon's
own machine-written prose, never a quotation from a reference work. What is
attested is which works name a figure; their wording is not published anywhere.

`convergence` is the one that earns its place. Opus 5's rule for the trial is
that a room must state a mechanism, because moods are why twenty rooms turn into
one room. This measures that directly: which words and images the rooms already
share, and how fast the shared vocabulary is growing. It reads only the rooms
you point it at -- it needs no atlas, and Babelon's rooms are CC0.
"""

from __future__ import annotations

import argparse
import collections
import difflib
import json
import os
import pathlib
import random
import re
import sys
import textwrap
import urllib.error
import urllib.request

HERE = pathlib.Path(__file__).resolve().parent


def _default_atlas() -> pathlib.Path:
    """Where to look for the atlas, in the order the caller probably meant.

    The third case is the one that matters for an outside agent: this file and
    `symbol-atlas.json` are both served from jawnomicon.com/babelon/, so
    downloading the two side by side into any directory is enough to run it.
    Nothing else is needed — no repository, no install, standard library only.
    """
    override = os.environ.get("BABELON_ATLAS")
    if override:
        return pathlib.Path(override)
    in_repo = HERE.parents[1] / "exports" / "babelon" / "symbol-atlas.json"
    if in_repo.is_file():
        return in_repo
    return HERE / "symbol-atlas.json"


DEFAULT_ATLAS = _default_atlas()

FIELD = re.compile(r"\*\*(MECHANISM|ACTION|CONSEQUENCE|DISCOVERABLE TEXT|THE CITY)\*\*\s*[-—]*\s*",
                   re.IGNORECASE)
WORD = re.compile(r"[a-z][a-z'-]{3,}")

# Function words plus the trial's own vocabulary: a room is not converging with
# another room because both say "room".
STOP = set("""
about above across after against along among around because before behind below
beneath beside between beyond both came come could does done down during each
either else even ever every from have having here hers herself him himself his
into itself just like made make many more most much must never next nothing once
only other others ours ourselves over same should since some such than that their
theirs them themselves then there these they this those through under until upon
very well were what when where which while whom whose will with within without
would your yours yourself
room rooms city cities visitor visitors text texts action consequence mechanism
discoverable archive archived place places thing things walk walks walked one
another each other people person
""".split())

# The figure half, published as five files under /babelon/. Only the ones a
# command actually needs are read: figures.json is 71 KB and answers "who is
# Korrigan and where is its page", while descriptions.json is 1.12 MB and is
# touched only when a span has to be resolved.
BABELON_BASE = os.environ.get("BABELON_BASE", "https://jawnomicon.com/babelon").rstrip("/")

# The leading 1_, 2_, gN_ on a canon id is the batch it entered canon in. It is
# part of the id and part of the URL, and nothing published spells it out, so
# `korrigan` still has to find g3_korrigan.
ID_PREFIX = re.compile(r"^g?\d+_")

# Said here because no field name carries it, in the same three steps
# site/src/lib/babelon.ts and /babelon/ state them.
CHAIN = ("reference works name the figure (recorded_in)"
         " › we wrote an original description"
         " › these relations were read out of THAT description")
OUR_PROSE = (
    "Each resolved warrant is jawnomicon's own machine-written prose, sliced out"
    " of descriptions.json at the offsets its row carries. None of it is quoted"
    " from a reference work: what is attested is which works name the figure,"
    " never their wording, and none of their wording is published to quote.")


def _default_data_dir() -> pathlib.Path | None:
    """Where to look for the published figure files before reaching the network.

    Same order as the atlas, and the last case is again the one that matters for
    an outside agent: figures.json and this file are both served from
    jawnomicon.com/babelon/, so downloading them side by side keeps working with
    no network at all. None means nothing local was found and the network is the
    remaining option — which is a fallback, not a requirement.
    """
    override = os.environ.get("BABELON_DATA")
    if override:
        return pathlib.Path(override)
    for candidate in (HERE.parents[1] / "site" / "dist" / "babelon",
                      HERE.parents[1] / "site" / "public" / "babelon",
                      HERE):
        if (candidate / "figures.json").is_file():
            return candidate
    return None


DEFAULT_DATA_DIR = _default_data_dir()


class DataUnavailable(Exception):
    """A published file could not be read — locally or over the wire.

    Its own exception so that being offline reads as a sentence rather than a
    traceback. A reader that dumps a stack when the network is down is a reader
    nobody keeps beside the data.
    """


class Published:
    """The published /babelon/ files, read at most once per process.

    Fetch on demand and hold in memory: descriptions.json is 1.12 MB, so reading
    it per relation is unusable, and vendoring it would freeze a corpus that
    moves every export. Nothing is written to disk — `--data` is how you keep a
    copy, and then it is yours to refresh.
    """

    def __init__(self, directory: pathlib.Path | None = None, base: str = BABELON_BASE,
                 offline: bool = False, timeout: float = 30.0) -> None:
        self.directory = directory
        self.base = base.rstrip("/")
        self.offline = offline
        self.timeout = timeout
        self._cache: dict[str, dict] = {}

    def get(self, name: str) -> dict:
        if name not in self._cache:
            self._cache[name] = self._read(name)
        return self._cache[name]

    def _read(self, name: str) -> dict:
        if self.directory is not None:
            path = self.directory / name
            if path.is_file():
                try:
                    return json.loads(path.read_text(encoding="utf-8"))
                except (OSError, ValueError) as exc:
                    raise DataUnavailable(f"{path} is not readable JSON — {exc}") from exc
        if self.offline:
            where = self.directory if self.directory is not None else "no local directory"
            raise DataUnavailable(
                f"no local {name} ({where}) and offline mode forbids fetching it;"
                f" download {self.base}/{name} into a directory and point --data"
                f" (or BABELON_DATA) at it")
        return self._fetch(name)

    def _fetch(self, name: str) -> dict:
        url = f"{self.base}/{name}"
        request = urllib.request.Request(url, headers={"User-Agent": "babelon-atlas.py"})
        try:
            with urllib.request.urlopen(request, timeout=self.timeout) as response:
                raw = response.read()
        except urllib.error.HTTPError as exc:
            raise DataUnavailable(f"{url} answered HTTP {exc.code} {exc.reason}") from exc
        except (urllib.error.URLError, TimeoutError, OSError) as exc:
            raise DataUnavailable(
                f"cannot reach {url} — {getattr(exc, 'reason', exc)}."
                f" If you are offline, download the /babelon/ files once and pass"
                f" --data <directory> (or set BABELON_DATA)") from exc
        try:
            return json.loads(raw.decode("utf-8"))
        except (UnicodeDecodeError, ValueError) as exc:
            raise DataUnavailable(f"{url} did not answer with JSON — {exc}") from exc


def load_atlas(path: pathlib.Path, published: Published | None = None) -> dict:
    """The atlas, from a local file if there is one and from the site if not.

    The symbol commands used to require a local copy and, failing that, said to
    "run build_atlas.py first" — advice only somebody holding this repository can
    take. An agent that followed llms.txt, downloaded atlas.py and asked about a
    mirror got told to run a script it does not have. The atlas is published at
    /babelon/symbol-atlas.json like everything else, so fetch it the way the
    figure commands already fetch theirs, and keep `--data` and `--offline`
    meaning the same thing on both halves of the reader.
    """
    if path.is_file():
        return json.loads(path.read_text(encoding="utf-8"))
    return (published or Published()).get("symbol-atlas.json")


def find(atlas: dict, needle: str) -> dict | None:
    key = re.sub(r"[^a-z0-9]", "", needle.lower())
    for symbol in atlas["symbols"]:
        if re.sub(r"[^a-z0-9]", "", symbol["name"].lower()) == key or symbol["slug"] == needle:
            return symbol
    return None


def creature_by_id(atlas: dict, cid: str) -> dict | None:
    return next((c for c in atlas["creatures"] if c["id"] == cid), None)


def show(atlas: dict, symbol: dict, verbose: bool = True) -> None:
    portrayals = symbol.get("portrayals") or [symbol["register"]]
    print(f"{symbol['name']}  [{' + '.join(portrayals)}]  in: {', '.join(symbol['in'])}")
    if symbol.get("registry_id"):
        census = symbol.get("census") or {}
        print(f"  registry {symbol['registry_id']}"
              f"  (class {census.get('class')}, {census.get('status')})")
    if symbol.get("gloss"):
        print(f"  {symbol['gloss']}")
    if symbol.get("attestations"):
        print(f"  attested in {symbol['attestations']} sources")
    if symbol.get("caveats"):
        print(f"  CAVEAT: {', '.join(symbol['caveats'])} — this row holds more than"
              f" one referent; say which one the room means")

    # The seam. One referent that both corpora portray: a figure with motifs on
    # one side, a symbol-domain with a denotation set on the other. Neither is
    # corrected into the other — that is what makes it an atlas of attested
    # portrayals rather than a claim about the thing.
    canon = symbol.get("canon_creature")
    if canon:
        creature = creature_by_id(atlas, canon)
        name = creature["name"] if creature else canon
        print(f"  ALSO A FIGURE: {name} ({canon})")
        if creature and creature.get("motifs"):
            print(f"    motifs: {', '.join(creature['motifs'][:8])}")
        if creature and creature.get("powers"):
            print(f"    powers: {', '.join(creature['powers'][:8])}")

    domain = symbol.get("domain")
    if domain:
        how = symbol.get("joined_by")
        note = ""
        if how == "title_bundle" and domain.get("bundles"):
            note = (f"  [bundled with {', '.join(domain['bundles'])} —"
                    f" the domain covers both, the registry keeps them apart]")
        print(f"  domain: {domain['world']} › {domain['sub']}{note}")
        print(f"    {domain['sense_count']} senses, {domain['furniture_count']} furniture objects"
              f"  ({', '.join(domain['sources'])})")
        for label in ("senses", "furniture"):
            if verbose and domain.get(label):
                items = domain[label]
                print(f"    {label}: {', '.join(items[:12])}"
                      + (f" … +{len(items) - 12}" if len(items) > 12 else ""))
    for neighbour in symbol.get("denotation_neighbours") or []:
        print(f"  denotes with: {neighbour['slug']}"
              f"   (each gloss names the other)")

    for link in symbol.get("creatures") or []:
        creature = creature_by_id(atlas, link["id"])
        name = creature["name"] if creature else link["id"]
        print(f"  creature: {name} ({link['status']}) — \"{link['evidence']}\"")
        if creature and verbose and creature.get("motifs"):
            print(f"    motifs: {', '.join(creature['motifs'][:8])}")


def cmd_symbol(args) -> int:
    atlas = load_atlas(args.atlas, _published(args))
    symbol = find(atlas, args.name)
    if not symbol:
        print(f"no symbol {args.name!r}; try: atlas.py search {args.name}")
        return 1
    show(atlas, symbol)
    return 0


def cmd_search(args) -> int:
    atlas = load_atlas(args.atlas, _published(args))
    needle = args.query.lower()
    hits = [
        s for s in atlas["symbols"]
        if needle in s["name"].lower() or needle in (s.get("gloss") or "").lower()
    ]
    print(f"{len(hits)} hit(s)")
    for symbol in hits[:args.limit]:
        marker = "+" if len(symbol["in"]) == 2 else " "
        gloss = (symbol.get("gloss") or "").split(".")[0][:90]
        print(f" {marker} {symbol['name']:<28} {gloss}")
    if len(hits) > args.limit:
        print(f"   … +{len(hits) - args.limit} more")
    return 0


def cmd_seed(args) -> int:
    """Draw a room seed: a symbol with furniture, and an inhabitant if it has one."""
    atlas = load_atlas(args.atlas, _published(args))
    pool = [s for s in atlas["symbols"] if s.get("domain")]
    if args.world:
        pool = [s for s in pool if s["domain"]["world"].lower().startswith(args.world.lower())]
    if args.linked:
        pool = [s for s in pool if s.get("creatures")]
    if not pool:
        print("no symbol matches that filter")
        return 1
    rng = random.Random(args.seed)
    for symbol in rng.sample(pool, min(args.count, len(pool))):
        show(atlas, symbol)
        print()
    return 0


def _norm(text: str) -> str:
    return re.sub(r"[^a-z0-9]", "", text.lower())


def figure_keys(figure: dict) -> list[str]:
    """Every string a reader might reasonably type for this figure.

    Name and aliases are the obvious half. The other half is identity: an
    `absorbed_id` is a figure ruled to be this same being, so `Zaratan` has to
    land on Aspidochelone rather than nothing, and the batch prefix comes off
    every id because nobody types `g3_`.
    """
    ids = [figure["id"], figure["slug"], *(figure.get("absorbed_ids") or [])]
    spellings = [figure["name"], *(figure.get("aliases") or []), *ids,
                 *(ID_PREFIX.sub("", i) for i in ids)]
    return [_norm(s) for s in spellings if s]


def figure_index(figures: dict) -> dict[str, list[dict]]:
    index: dict[str, list[dict]] = {}
    for figure in figures["figures"]:
        for key in set(figure_keys(figure)):
            index.setdefault(key, []).append(figure)
    return index


def find_figures(figures: dict, needle: str) -> list[dict]:
    """Figures answering to a name, alias, id or absorbed name.

    A list rather than one figure: the file promises no uniqueness across every
    alias it carries, and two figures sharing a spelling is exactly the case
    where picking one quietly is the wrong move. Today it resolves 2,059 keys
    with no collision; the caller is still told when that changes.
    """
    return figure_index(figures).get(_norm(needle), [])


def search_figures(figures: dict, query: str) -> list[dict]:
    """Rank by where the query landed: a name beats an alias beats a lede,
    because a reader who types a name means the name. When nothing matches as a
    substring the query is usually a misspelling rather than an absence, so fall
    back to close matches over names and aliases instead of reporting none.

    Returns every hit, ranked; the caller decides how many to show, the same way
    `search` does, so the count it reports is the count that exists.
    """
    needle = query.strip().lower()
    best: dict[str, dict] = {}

    def offer(figure: dict, rank: int, how: str) -> None:
        got = best.get(figure["id"])
        if got is None or rank < got["rank"]:
            best[figure["id"]] = {"rank": rank, "how": how, "figure": figure}

    for figure in figures["figures"]:
        name = figure["name"].lower()
        aliases = [a.lower() for a in figure.get("aliases") or []]
        if name == needle:
            offer(figure, 0, "name")
        elif name.startswith(needle):
            offer(figure, 1, "name")
        elif needle in name:
            offer(figure, 2, "name")
        elif needle in aliases:
            offer(figure, 3, "alias")
        elif any(needle in alias for alias in aliases):
            offer(figure, 4, "alias")
        elif needle in (figure.get("lede") or "").lower():
            offer(figure, 5, "lede")

    if not best:
        spellings: dict[str, dict] = {}
        for figure in figures["figures"]:
            for spelling in (figure["name"], *(figure.get("aliases") or [])):
                spellings.setdefault(spelling.lower(), figure)
        for match in difflib.get_close_matches(needle, list(spellings), n=20, cutoff=0.7):
            offer(spellings[match], 6, f"close to {match!r}")

    return sorted(best.values(), key=lambda hit: (hit["rank"], hit["figure"]["name"]))


def vocab_names(vocab: dict) -> dict[str, str]:
    """Target id -> display name, for the targets that are ids rather than labels.

    relations.json is opaque without vocab.json and says so. Motif and power
    targets are already their own labels; tradition and region targets are not,
    and `breton` reads worse than `Breton folklore`.
    """
    names = {}
    for kind in ("traditions", "regions"):
        for row in vocab["vocab"].get(kind) or []:
            names[row["id"]] = row.get("name") or row["id"]
    return names


def _corpus(*stamped: tuple[str, dict | None]) -> dict:
    """Check that the files read came out of one export.

    A span is an offset into one export's prose. Mixing a cached
    descriptions.json with a fresh relations.json moves every one of them
    silently, so the stamps every published file carries get compared rather
    than assumed — that is what they are for.
    """
    stamps = {name: (doc.get("export_version"), doc.get("canon_commit"))
              for name, doc in stamped if doc}
    seen = set(stamps.values())
    version, commit = next(iter(seen), (None, None))
    out = {"export_version": version, "canon_commit": commit, "warning": None}
    if len(seen) > 1:
        out["warning"] = (
            "these files came from different exports ("
            + "; ".join(f"{name} v{v} {c}" for name, (v, c) in sorted(stamps.items()))
            + ") — a span is an offset into one export's prose, so re-fetch"
              " until they agree before trusting a warrant")
    return out


def figure_profile(published: Published, figure: dict) -> dict:
    """One figure's 29-axis scores, each rendered as the sentence its band says.

    A bare 0.2 on `Cognition` is unreadable: near which end, and meaning what?
    eidogen.json now carries `axis_vocabulary` — both poles and the seven
    written bands per axis — so the number can be turned back into the sentence
    a rater was choosing between. Before that the file sent a reader to
    vocab.json for the poles and vocab.json had none, which is jawnomicon-o6m1.

    `not-applicable` is a sentinel, never a low score, and is kept in its own
    list rather than banded. Banding it would read every honest gap as an
    extreme position, which is the exact misreading eidogen.json warns about.
    """
    eidogen = published.get("eidogen.json")
    row = next((f for f in eidogen["figures"] if f["id"] == figure["id"]), None)
    if row is None:
        return {"figure": figure, "scored": [], "not_applicable": [], "missing": True}

    vocabulary = eidogen.get("axis_vocabulary") or {}
    confidences = row.get("confidences") or {}
    scored, na = [], []
    for axis in sorted(row.get("dimensions") or {}):
        value = row["dimensions"][axis]
        if not isinstance(value, (int, float)):
            na.append(axis)
            continue
        entry = vocabulary.get(axis) or {}
        bands = entry.get("bands") or []
        hit = next((b for b in bands if value >= b.get("min", 0)), None)
        # Neutral between .4 and .6, matching `lean()` in site/src/lib and the
        # middle band's own wording. Splitting at .5 made 0.42 print "toward
        # Rigidity" directly above a band reading "balances flexibility with
        # rigidity" — the reader claiming a lean the score does not support.
        toward = None
        if value > 0.6:
            toward = entry.get("high")
        elif value < 0.4:
            toward = entry.get("low")
        scored.append({
            "axis": axis,
            "value": value,
            "toward": toward,
            "poles": (entry.get("low"), entry.get("high")),
            "reads": (hit or {}).get("reads"),
            "percent": (hit or {}).get("percent"),
            "confidence": confidences.get(axis),
        })
    return {"figure": figure, "scored": scored, "not_applicable": na,
            "has_vocabulary": bool(vocabulary), "missing": False}


def show_profile(payload: dict) -> None:
    figure = payload["figure"]
    print(f"{figure['name']}  ({figure['id']})  {figure.get('url','')}")
    if payload.get("missing"):
        print("  no eidogen row for this figure")
        return
    scored, na = payload["scored"], payload["not_applicable"]
    print(f"  {len(scored)} axes scored, {len(na)} not applicable")
    if not payload.get("has_vocabulary"):
        print("  (this eidogen.json carries no axis_vocabulary — scores shown bare)")
    print("  Machine-rated by two model families against a written 29-axis schema."
          " A score is a reading of our own description, not a source's judgement.")
    print()
    for s in scored:
        low, high = s["poles"]
        span = f"{low} ─ {high}" if low and high else "poles unknown"
        conf = f"  conf {s['confidence']:.2f}" if isinstance(s["confidence"], (int, float)) else ""
        print(f"  {s['axis']}  {s['value']:.2f}  [{span}]{conf}")
        lean = f"toward {s['toward']}" if s["toward"] else "neither pole strongly"
        if s["percent"] or s["toward"]:
            print(f"    {lean}" + (f"  ({s['percent']})" if s["percent"] else ""))
        if s["reads"]:
            for line in textwrap.wrap(s["reads"], 74):
                print(f"      {line}")
    if na:
        print()
        print("  not-applicable (a sentinel, NOT a low score — the axis does not apply):")
        for line in textwrap.wrap(", ".join(na), 74):
            print(f"    {line}")


def figure_relations(published: Published, figure: dict) -> dict:
    """Read one figure's relations and resolve each warrant to the text it names.

    This is the whole reason the file cites the way it does. A row carries no
    sentence: it carries `cited_from` — which description field — and `span`,
    [start, end) character offsets into that field. Resolving one means fetching
    descriptions.json and taking the slice, so you reach into OUR prose to get
    the passage and cannot mistake whose sentence you are holding. Calling it
    `evidence` and shipping the string would invite exactly that mistake.

    137 of 5,245 rows did something other than cite a description. Those carry
    `span: null` and a literal `note`, and are reported as notes — kept visible
    rather than blended in, because they are the rows whose warrant is of a
    different kind.
    """
    relations = published.get("relations.json")
    rows = next((f["relations"] for f in relations["figures"] if f["id"] == figure["id"]), [])

    descriptions, described = None, {}
    if any(row.get("span") for row in rows):
        descriptions = published.get("descriptions.json")
        described = next((f for f in descriptions["figures"] if f["id"] == figure["id"]), {})

    names, vocab_note = {}, None
    if any(row.get("type") in ("FROM_TRADITION", "ORIGIN_REGION") for row in rows):
        try:
            names = vocab_names(published.get("vocab.json"))
        except DataUnavailable as exc:
            vocab_note = f"targets left as raw ids — {exc}"

    out = []
    for row in rows:
        field, span = row.get("cited_from"), row.get("span")
        if span and field:
            start, end = span
            text = (described.get(field) or "")[start:end]
            if text:
                warrant = {"kind": "our-prose", "text": text,
                           "from": f"our prose, {field}[{start}:{end}]"}
            else:
                warrant = {"kind": "unresolved", "text": "",
                           "from": f"{field}[{start}:{end}] does not land in this figure's"
                                   f" prose — are these files from one export?"}
        else:
            warrant = {"kind": "literal-note", "text": row.get("note") or "",
                       "from": "NOT our prose — the row records a literal note"}
        out.append({
            "type": row.get("type"),
            "target": row.get("target"),
            "target_name": names.get(row.get("target")),
            "weight": row.get("weight"),
            "method": row.get("method"),
            "cited_from": field,
            "span": span,
            "warrant": warrant,
        })
    out.sort(key=lambda row: (row["type"] or "", row["target"] or ""))

    kinds = collections.Counter(row["warrant"]["kind"] for row in out)
    return {
        "figure": {"id": figure["id"], "name": figure["name"], "url": figure["url"]},
        "counts": {
            "relations": len(out),
            "cited_as_span": kinds["our-prose"],
            "literal_note": kinds["literal-note"],
            "unresolved": kinds["unresolved"],
            "by_type": dict(collections.Counter(row["type"] for row in out)),
        },
        "provenance": {"chain": CHAIN, "warrants_are": OUR_PROSE},
        "corpus": _corpus(("relations.json", relations),
                          ("descriptions.json", descriptions)),
        "vocab": vocab_note,
        "relations": out,
    }


def show_figure(figure: dict) -> None:
    """One figure, in the order a reader arrives asking for it: who it is, where
    its page is, what portrays it, and which reference works name it."""
    print(f"{figure['name']}  ({figure['id']})")
    print(f"  page: {figure['url']}")
    if figure.get("aliases"):
        print(f"  also called: {', '.join(figure['aliases'])}")
    if figure.get("absorbed_ids"):
        print(f"  absorbed: {', '.join(figure['absorbed_ids'])}"
              f" — ruled the same being, so those names resolve here")
    if figure.get("family"):
        print(f"  family: {figure['family']['name']} ({figure['family']['slug']})")
    for label in ("traditions", "regions", "kinds"):
        if figure.get(label):
            print(f"  {label}: {', '.join(figure[label])}")
    if figure.get("symbols"):
        print(f"  symbols borne: {', '.join(figure['symbols'])}"
              f"   (atlas.py symbol {figure['symbols'][0]})")
    if figure.get("recorded_in"):
        print(f"  recorded in {len(figure['recorded_in'])} reference work(s) — the"
              f" attested part, and the only part of this entry that is:")
        for title in figure["recorded_in"]:
            print(f"    {title}")
    if figure.get("lede"):
        print("  lede — ours, machine-written, not a source's wording:")
        print(textwrap.fill(figure["lede"], width=78,
                            initial_indent="    ", subsequent_indent="    "))
    blank = [label for label in
             ("traditions", "regions", "kinds", "symbols", "recorded_in", "family")
             if not figure.get(label)]
    if blank:
        print(f"  not yet surveyed: {', '.join(blank)}"
              f" — an empty field never means 'has none'")


def show_relations(payload: dict) -> None:
    figure, counts = payload["figure"], payload["counts"]
    print(f"{figure['name']}  ({figure['id']})  {figure['url']}")
    tail = f", {counts['unresolved']} whose offsets do not land" if counts["unresolved"] else ""
    print(f"  {counts['relations']} relations — {counts['cited_as_span']} resolved from a span"
          f" of our prose, {counts['literal_note']} carrying a literal note{tail}")
    print(textwrap.fill(f"chain: {CHAIN}", width=78,
                        initial_indent="  ", subsequent_indent="    "))
    print(textwrap.fill(payload["provenance"]["warrants_are"], width=78,
                        initial_indent="  ", subsequent_indent="  "))
    if payload["corpus"]["warning"]:
        print(f"  CORPUS: {payload['corpus']['warning']}")
    if payload.get("vocab"):
        print(f"  VOCAB: {payload['vocab']}")
    print()
    for row in payload["relations"]:
        target = (row["target"] or "?") + (f" ({row['target_name']})"
                                          if row["target_name"] else "")
        weight = f"   weight {row['weight']}" if row["weight"] is not None else ""
        print(f"  {row['type']}  {target}{weight}")
        print(f"    {row['method']} · {row['warrant']['from']}")
        if row["warrant"]["text"]:
            print(textwrap.fill(row["warrant"]["text"], width=76,
                                initial_indent="      ", subsequent_indent="      "))


def _published(args) -> Published:
    return Published(args.data, offline=args.offline)


def _one_figure(published: Published, name: str) -> dict | None:
    """Resolve a name to exactly one figure, or say why not.

    Returning None after printing is the same shape cmd_symbol uses for a miss:
    a reader gets the next command to try rather than an empty result.
    """
    figures = published.get("figures.json")
    found = find_figures(figures, name)
    if not found:
        print(f"no figure {name!r}; try: atlas.py figure-search {name}")
        return None
    if len(found) > 1:
        print(f"{len(found)} figures answer to {name!r} — say which one you mean:")
        for figure in found:
            print(f"  {figure['name']:<28} {figure['id']:<20} {figure['url']}")
        return None
    return found[0]


def cmd_figure(args) -> int:
    published = _published(args)
    try:
        figure = _one_figure(published, args.name)
    except DataUnavailable as exc:
        sys.exit(str(exc))
    if not figure:
        return 1
    show_figure(figure)
    return 0


def cmd_figure_search(args) -> int:
    published = _published(args)
    try:
        figures = published.get("figures.json")
    except DataUnavailable as exc:
        sys.exit(str(exc))
    hits = search_figures(figures, args.query)
    print(f"{len(hits)} hit(s)" + ("" if hits else
                                   f"; nothing matches {args.query!r} by name, alias or lede"))
    for hit in hits[:args.limit]:
        figure = hit["figure"]
        marker = "+" if hit["rank"] <= 1 else " "
        # The slug, not the id: `2_will-o'-the-wisp` is the id and
        # `2_will-o--the-wisp` is the path, and printing the first beside the
        # word "page" is how a reader ends up at a 404.
        path = f"/creature/{figure['slug']}/"
        print(f" {marker} {figure['name']:<28} {path:<42} {hit['how']}")
        lede = (figure.get("lede") or "").split(".")[0][:88]
        if lede:
            print(f"     {lede}")
    if len(hits) > args.limit:
        print(f"   … +{len(hits) - args.limit} more")
    if hits:
        print("   paths hang off jawnomicon.com — never rebuild one from a name")
    return 0


def cmd_figure_profile(args) -> int:
    published = _published(args)
    try:
        figure = _one_figure(published, args.name)
        if not figure:
            return 1
        payload = figure_profile(published, figure)
    except DataUnavailable as exc:
        sys.exit(str(exc))
    show_profile(payload)
    return 0


def cmd_figure_relations(args) -> int:
    published = _published(args)
    try:
        figure = _one_figure(published, args.name)
        if not figure:
            return 1
        payload = figure_relations(published, figure)
    except DataUnavailable as exc:
        sys.exit(str(exc))
    show_relations(payload)
    return 0


def parse_room(path: pathlib.Path) -> dict:
    text = path.read_text(encoding="utf-8")
    meta = {}
    if text.startswith("---"):
        end = text.find("\n---", 3)
        block, text = text[3:end], text[end + 4:]
        for line in block.strip().splitlines():
            if ":" in line:
                k, v = line.split(":", 1)
                meta[k.strip()] = v.strip()
    fields, current = {}, None
    for chunk in FIELD.split(text):
        if chunk.upper() in ("MECHANISM", "ACTION", "CONSEQUENCE", "DISCOVERABLE TEXT", "THE CITY"):
            current = chunk.upper()
        elif current:
            fields[current] = chunk.strip()
            current = None
    return {"slug": path.stem, "meta": meta, "fields": fields, "text": text}


def cmd_convergence(args) -> int:
    rooms_dir = args.rooms
    paths = sorted(p for p in rooms_dir.glob("*.md") if p.stem != "TEMPLATE")
    if not paths:
        sys.exit(f"no rooms in {rooms_dir}")
    rooms = [parse_room(p) for p in paths]

    use: dict[str, set[str]] = collections.defaultdict(set)
    for room in rooms:
        body = room["fields"].get("THE CITY", room["text"]).lower()
        for word in set(WORD.findall(body)):
            if word not in STOP:
                use[word].add(room["slug"])

    n = len(rooms)
    shared = sorted(
        ((w, sorted(r)) for w, r in use.items() if len(r) > 1),
        key=lambda item: (-len(item[1]), item[0]),
    )
    everywhere = [(w, r) for w, r in shared if len(r) == n]

    print(f"{n} rooms: {', '.join(r['slug'] for r in rooms)}")
    print(f"{len(shared)} words shared by 2+ rooms; {len(everywhere)} in every room")
    print()
    print("Shared vocabulary — the tic watch")
    for word, where in shared[:args.limit]:
        print(f"  {len(where)}/{n}  {word:<18} {', '.join(where)}")
    print()

    print("Mechanism check — one sentence a builder could implement")
    for room in rooms:
        mech = " ".join((room["fields"].get("MECHANISM") or "").split())
        if not mech:
            print(f"  {room['slug']:<12} MISSING")
            continue
        sentences = len([s for s in re.split(r"(?<=[.!?])\s+", mech) if s.strip()])
        flag = "" if sentences == 1 else f"  <- {sentences} sentences"
        print(f"  {room['slug']:<12} {len(mech.split()):>3}w{flag}")

    missing = [
        (room["slug"], field)
        for room in rooms
        for field in ("MECHANISM", "ACTION", "CONSEQUENCE", "DISCOVERABLE TEXT", "THE CITY")
        if not room["fields"].get(field)
    ]
    if missing:
        print()
        print("Incomplete rooms")
        for slug, field in missing:
            print(f"  {slug}: no {field}")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__,
                                     formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--atlas", type=pathlib.Path, default=DEFAULT_ATLAS)
    parser.add_argument("--data", type=pathlib.Path, default=DEFAULT_DATA_DIR,
                        help="directory of downloaded /babelon/ files; otherwise they are"
                             " fetched from jawnomicon.com and cached for this run")
    parser.add_argument("--offline", action="store_true",
                        help="never fetch — read only what --data already holds")
    sub = parser.add_subparsers(dest="cmd", required=True)

    p = sub.add_parser("symbol", help="look up one symbol")
    p.add_argument("name")
    p.set_defaults(func=cmd_symbol)

    p = sub.add_parser("search", help="search names and glosses")
    p.add_argument("query")
    p.add_argument("--limit", type=int, default=20)
    p.set_defaults(func=cmd_search)

    p = sub.add_parser("seed", help="draw a room seed")
    p.add_argument("--world")
    p.add_argument("--count", type=int, default=1)
    p.add_argument("--seed", type=int)
    p.add_argument("--linked", action="store_true", help="only symbols with a creature")
    p.set_defaults(func=cmd_seed)

    p = sub.add_parser("figure", help="look up one figure by name, alias, id or absorbed name")
    p.add_argument("name")
    p.set_defaults(func=cmd_figure)

    p = sub.add_parser("figure-search", help="search figure names, aliases and ledes")
    p.add_argument("query")
    p.add_argument("--limit", type=int, default=10)
    p.set_defaults(func=cmd_figure_search)

    p = sub.add_parser("figure-relations",
                       help="one figure's relations, each warrant sliced from our own prose")
    p.add_argument("name")
    p.set_defaults(func=cmd_figure_relations)

    p = sub.add_parser("figure-profile",
                       help="one figure's 29 axes, each score read back as its written band")
    p.add_argument("name")
    p.set_defaults(func=cmd_figure_profile)

    p = sub.add_parser("convergence", help="tic watch over a rooms/ directory")
    p.add_argument("rooms", type=pathlib.Path)
    p.add_argument("--limit", type=int, default=25)
    p.set_defaults(func=cmd_convergence)

    args = parser.parse_args()
    try:
        return args.func(args)
    except DataUnavailable as exc:
        # Caught here as well as in the figure commands, because the symbol
        # commands can now reach the network too and a missing file is a
        # sentence, not a stack trace — the whole reason this exception exists.
        sys.exit(str(exc))


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