#!/usr/bin/env python3
"""The database map as one page for a person: every table, column and key, the catalogue's
spine drawn, the owner's own words mapped to the tables.

Sources of truth: app/models/ (the SQLAlchemy metadata, read through `docmap.load_models`) and
`docmap.AREAS` (which tables a reader sees together). Nothing about a column is typed here: the
names, types, keys, constraints and docstrings are the live metadata every time it runs. What is
typed is the drawing's layout, the one-line role under each box, the words for the owner's
vocabulary and each area's sentence; those are the parts a person reads first, and a wrong one
is visible at a glance. The checked, durable form of the same map is the Relationships block
`docmap.py` writes into docs/DATA-MODEL.md; this page is what the owner opens.

    python3 main/scripts/dbmap.py --html /path/to/out.html

Self-contained HTML (one file, two themes, no library): publish it as is.
"""
from __future__ import annotations

import argparse
import datetime as dt
import html
import pathlib
import subprocess
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from docmap import MAIN, ROOT, areas_of, load_models  # noqa: E402

# --- the owner's words -------------------------------------------------------------------------

WORDS = [
    ("Listings", "listings", "One product on one shop's shelf. The shop's own words are the <code>listed_*</code> "
     "columns; a pin or an ignore on the row is the decided layer at this level."),
    ("Products", "products", "One size of one variation, under <a href=\"#t-product_lines\">product_lines</a> (the real "
     "product, one page each) and <a href=\"#t-brands\">brands</a>. <a href=\"#t-variation_aliases\">variation_aliases</a> "
     "is the vocabulary a wording resolves through."),
    ("Brands", "brands", "One row per house however the shops spell it; an alias points at its house through "
     "<code>canonical_id</code>."),
    ("Collectors", "sources", "A collector's settings and its kill switch. Each pass is a row in "
     "<a href=\"#t-collection_runs\">collection_runs</a>. Which shops a collector reads is code "
     "(<code>services/collectors/registry.py</code>), not a table."),
    ("Airports", "locations", "One airport shop (<code>iata</code>, <code>visible</code>) under "
     "<a href=\"#t-retailers\">retailers</a>, the operator. Opening hours are readings in "
     "<a href=\"#t-airport_hours\">airport_hours</a>."),
    ("Prices", "price_observations", "Append-only: one row per sighting, with the currency the page declared and the "
     "run it came from. The fragment behind it is <a href=\"#t-raw_records\">raw_records</a>."),
    ("Medals", "awards", "A competition result on a product."),
    ("Your decisions", "overrides", "A field a person set over what the rules produced; also "
     "<code>listings.pinned_product_id</code> and <code>ignored_at</code>, the merge queue "
     "<a href=\"#t-merge_candidates\">merge_candidates</a> and the record <a href=\"#t-product_merges\">product_merges</a>."),
    ("People", "accounts", "The one identity table. Every <code>*_by</code> and <code>*_id</code> who-column elsewhere "
     "points here."),
]

AREA_INTRO = {
    "Catalogue": "What is sold and where: a retailer runs airport shops; a brand has lines, a line has products "
                 "(one size of one variation), and a listing is one product on one shop's shelf.",
    "Collection": "Who reads the shops and what came back: a source is one collector's settings, a run is one pass, "
                  "and every run leaves the fragment it read and the price it saw.",
    "Decisions and merges": "What a person decided over what the rules produced: an override on any field, the "
                            "merge queue and the record of every merge, and the queue for a fresh read.",
    "Quality": "Whether the data is true: verification runs and their per-listing checks, and the audit's snapshots.",
    "Accounts": "Who can sign in and what they may do. Every who-column elsewhere points at accounts.",
    "Discussion": "Conversations on every subject, the asks, flags and acknowledgements on them, the inbox, and "
                  "mail's ledger.",
    "Client and owner surfaces": "What the client and the owner record on the site: the feature ranking, the quote, "
                                 "the to-do list with its hand-ins, and the running list's states.",
    "Editorial": "Text people wrote: articles of three kinds, and the newsletter list.",
}

# --- the spine drawing -------------------------------------------------------------------------

BOX_W, BOX_H = 190, 58
NODES = {  # table: (x, y, the one-line role under the name)
    "sources": (40, 30, "a collector's settings"),
    "collection_runs": (300, 30, "one pass of a collector"),
    "retailers": (40, 170, "one operator"),
    "locations": (300, 170, "an airport shop · visible"),
    "brands": (40, 330, "alias → house: canonical_id"),
    "product_lines": (300, 330, "the real product"),
    "products": (560, 250, "one size of one variation"),
    "listings": (820, 170, "one product on one shelf"),
    "awards": (820, 390, "a medal on a product"),
    "raw_records": (1080, 100, "the fragment read, per run"),
    "price_observations": (1080, 240, "a price at a moment"),
}
EMPHASIS = {"products", "listings"}
# (points, label, label position, style): an arrow leaves the table that holds the key.
EDGES = [
    ([(300, 59), (230, 59)], "source_id", (265, 45), "key"),
    ([(300, 199), (230, 199)], "retailer_id", (265, 185), "key"),
    ([(300, 359), (230, 359)], "brand_id", (265, 345), "key"),
    ([(560, 279), (525, 279), (525, 359), (490, 359)], "line_id", (525, 319), "key"),
    ([(655, 308), (655, 420), (135, 420), (135, 388)], "brand_id", (395, 420), "key"),
    ([(820, 199), (785, 199), (785, 279), (750, 279)], "product_id", (785, 239), "key"),
    ([(860, 170), (860, 140), (395, 140), (395, 170)], "location_id", (627, 140), "key"),
    ([(860, 390), (860, 360), (700, 360), (700, 308)], "product_id", (780, 360), "key"),
    ([(1080, 129), (1045, 129), (1045, 187), (1010, 187)], "listing_id", (1045, 158), "key"),
    ([(1080, 269), (1045, 269), (1045, 211), (1010, 211)], "listing_id", (1045, 240), "key"),
    ([(1270, 269), (1300, 269), (1300, 14), (395, 14), (395, 30)], "run_id", (800, 14), "key"),
    ([(1175, 100), (1175, 14)], "", None, "join"),
    ([(135, 88), (135, 110), (265, 110), (265, 178), (300, 178)], "paired in code", (200, 110), "code"),
]
VIEW_W, VIEW_H = 1330, 470


def svg_spine() -> str:
    out = [f'<svg viewBox="0 0 {VIEW_W} {VIEW_H}" role="img" aria-label="The catalogue\'s spine: retailers run '
           f'locations; sources have collection runs; brands have product lines, lines have products; a listing '
           f'is one product at one location; raw records and price observations hang off a listing and a run.">',
           '<defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" '
           'orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="currentColor"/></marker></defs>']
    for pts, label, at, style in EDGES:
        d = " ".join(f"{'M' if i == 0 else 'L'}{x},{y}" for i, (x, y) in enumerate(pts))
        cls = {"key": "e-key", "join": "e-join", "code": "e-code"}[style]
        out.append(f'<path class="{cls}" d="{d}"{"" if style == "join" else " marker-end=\"url(#arr)\""}/>')
    for pts, label, at, style in EDGES:
        if not label:
            continue
        w = len(label) * 6.7 + 12
        x, y = at
        out.append(f'<rect class="chip" x="{x - w / 2:.1f}" y="{y - 8}" width="{w:.1f}" height="16" rx="3"/>'
                   f'<text class="lbl" x="{x}" y="{y + 4}" text-anchor="middle">{html.escape(label)}</text>')
    for name, (x, y, sub) in NODES.items():
        cls = "box box--em" if name in EMPHASIS else "box"
        out.append(f'<a href="#t-{name}"><rect class="{cls}" x="{x}" y="{y}" width="{BOX_W}" height="{BOX_H}" rx="6"/>'
                   f'<text class="tn" x="{x + 12}" y="{y + 24}">{name}</text>'
                   f'<text class="ts" x="{x + 12}" y="{y + 43}">{html.escape(sub)}</text></a>')
    out.append("</svg>")
    return "\n".join(out)


# --- the cards -----------------------------------------------------------------------------------

def human_type(t: str) -> str:
    t = str(t).upper()
    if t.startswith("VARCHAR("):
        return f"text ≤{t[8:-1]}"
    if t.startswith("NUMERIC("):
        return f"number ({t[8:-1].replace(' ', '')})"
    return {"TEXT": "text", "INTEGER": "integer", "BOOLEAN": "yes / no", "DATETIME": "timestamp",
            "JSON": "json", "JSONB": "json"}.get(t, t.lower())


def first_paragraph(doc: str) -> str:
    para = doc.split("\n\n", 1)[0] if doc else ""
    return " ".join(para.split())


def card(t, doc: str, module: str, pointed: dict[str, list[tuple[str, str]]]) -> str:
    cols = list(t.columns)
    single_idx = {next(iter(i.columns)).name for i in t.indexes if len(i.columns) == 1}
    multi_idx = [i for i in t.indexes if len(i.columns) > 1]
    uniques = [k for k in t.constraints if k.__class__.__name__ == "UniqueConstraint"]
    single_uq = {next(iter(k.columns)).name for k in uniques if len(k.columns) == 1}
    multi_uq = [k for k in uniques if len(k.columns) > 1]
    rows = []
    for c in cols:
        fk = next(iter(c.foreign_keys)).column if c.foreign_keys else None
        marks = []
        if c.primary_key:
            marks.append('<b class="m m-pk" title="primary key">PK</b>')
        if fk is not None:
            marks.append('<b class="m m-fk" title="foreign key">FK</b>')
        if c.unique or c.name in single_uq:
            marks.append('<b class="m" title="unique">U</b>')
        if c.index or c.name in single_idx:
            marks.append('<b class="m" title="indexed">I</b>')
        to = ""
        if fk is not None:
            same = fk.table.name == t.name
            to = f'<a href="#t-{fk.table.name}">{fk.table.name}</a>.{fk.name}'
            if same:
                to += ' <span class="soft">(itself)</span>'
        cls = " opt" if (c.nullable and not c.primary_key) else ""
        rows.append(f'<tr data-c="{html.escape(c.name.lower())}" class="c{cls}"><td class="cn"><code>{c.name}</code>'
                    f'{"<span class=q title=\"may be empty\">?</span>" if cls else ""}</td>'
                    f'<td class="ct">{html.escape(human_type(c.type))}</td><td class="cm">{" ".join(marks)}</td>'
                    f'<td class="cf">{to}</td></tr>')
    facts = []
    if multi_uq:
        facts.append("Unique together: " + "; ".join(
            "(" + ", ".join(f"<code>{c.name}</code>" for c in k.columns) + ")" for k in multi_uq))
    if multi_idx:
        facts.append("Indexed together: " + "; ".join(
            "(" + ", ".join(f"<code>{c.name}</code>" for c in i.columns) + ")" for i in multi_idx))
    if pointed.get(t.name):
        facts.append("Pointed at by: " + ", ".join(
            f'<a href="#t-{tb}">{tb}</a>.<code>{cn}</code>' for tb, cn in pointed[t.name] if tb != t.name)
            or "itself")
    para = first_paragraph(doc)
    doc_html = f'<p class="doc">{html.escape(para)}</p>' if para else '<p class="doc doc--none">No docstring on the model yet.</p>'
    colnames = " ".join(c.name.lower() for c in cols)
    return (f'<article class="t" id="t-{t.name}" data-name="{t.name}" data-cols="{html.escape(colnames)}" '
            f'data-doc="{html.escape(para.lower())}">'
            f'<header class="t-head"><h3><code>{t.name}</code></h3>'
            f'<span class="t-meta">{len(cols)} columns · <code>models/{module}.py</code></span></header>'
            f'{doc_html}'
            f'<div class="cols-wrap"><table class="cols"><thead><tr><th>column</th><th>type</th><th>keys</th>'
            f'<th>points to</th></tr></thead><tbody>{"".join(rows)}</tbody></table></div>'
            f'{"<footer class=t-foot>" + " ".join(f"<p>{f}</p>" for f in facts) + "</footer>" if facts else ""}'
            f'</article>')


# --- the page --------------------------------------------------------------------------------------

CSS = r"""
:root {
  --page: #F6F3EC; --card: #FFFFFF; --sunken: #ECE8DF; --ink: #20242C; --soft: #626A73; --line: #E4E0D8;
  --line-strong: #D3CDC0; --navy: #2F3E4E; --accent: #2E7D8C; --accent-ink: #256977; --accent-pale: #DCF0F4;
  --hit: #FFF1C2; --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
  --serif: "Libre Baskerville", Georgia, "Times New Roman", serif;
  --sans: Inter, "Helvetica Neue", Arial, sans-serif;
  color-scheme: light;
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
  --page: #1B2129; --card: #232B35; --sunken: #1F262F; --ink: #E8EAEE; --soft: #A9B2BC; --line: #37414D;
  --line-strong: #4A5664; --navy: #E8EAEE; --accent: #5FB8C9; --accent-ink: #8ED2DF; --accent-pale: #223E45;
  --hit: #4A3F14; color-scheme: dark;
} }
:root[data-theme="dark"] {
  --page: #1B2129; --card: #232B35; --sunken: #1F262F; --ink: #E8EAEE; --soft: #A9B2BC; --line: #37414D;
  --line-strong: #4A5664; --navy: #E8EAEE; --accent: #5FB8C9; --accent-ink: #8ED2DF; --accent-pale: #223E45;
  --hit: #4A3F14; color-scheme: dark;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--page); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.5; }
a { color: var(--accent-ink); text-decoration: none; }
a:hover { text-decoration: underline; }
code { font-family: var(--mono); font-size: 0.92em; }
h1, h2, h3 { margin: 0; font-family: var(--serif); font-weight: 600; letter-spacing: -0.01em; line-height: 1.15; text-wrap: balance; color: var(--navy); }
.wrap { display: grid; grid-template-columns: 250px minmax(0, 1fr); gap: 32px; max-width: 1400px; margin: 0 auto; padding-block: 28px 64px; padding-inline: 20px; }
main { min-width: 0; display: flex; flex-direction: column; gap: 40px; }

.rail { position: sticky; top: 16px; align-self: start; max-height: calc(100vh - 32px); overflow: auto; display: flex; flex-direction: column; gap: 14px; font-size: 13px; }
.rail input { width: 100%; font: inherit; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--card); color: var(--ink); }
.rail input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.rail .count { color: var(--soft); font-size: 12px; }
.rail h4 { margin: 10px 0 4px; font-family: var(--sans); font-size: 11px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--soft); }
.rail ul { list-style: none; margin: 0; padding: 0; }
.rail li a { display: flex; justify-content: space-between; gap: 8px; padding: 2px 6px; border-radius: 4px; color: var(--ink); font-family: var(--mono); font-size: 12px; }
.rail li a:hover { background: var(--sunken); text-decoration: none; }
.rail li a span { color: var(--soft); font-family: var(--sans); font-variant-numeric: tabular-nums; }
.rail details summary { cursor: pointer; font-weight: 600; }

.eyebrow { font-size: 11px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: var(--soft); }
.masthead { display: flex; flex-direction: column; gap: 10px; }
.masthead h1 { font-size: 34px; }
.lede { margin: 0; max-width: 68ch; color: var(--ink); font-size: 15px; }
.facts { display: flex; flex-wrap: wrap; gap: 8px 22px; margin: 0; padding: 0; list-style: none; color: var(--soft); font-size: 13px; font-variant-numeric: tabular-nums; }
.facts b { color: var(--ink); font-weight: 600; }
.legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12px; color: var(--soft); align-items: center; }
.m { display: inline-block; font-family: var(--sans); font-size: 10px; font-weight: 700; letter-spacing: 0.04em; padding: 1px 5px; border-radius: 3px; background: var(--sunken); color: var(--soft); vertical-align: 1px; }
.m-pk { background: var(--navy); color: var(--page); }
.m-fk { background: var(--accent-pale); color: var(--accent-ink); }
.q { color: var(--soft); margin-left: 2px; }

.words { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
.words h2, .area h2 { font-size: 22px; }
.section-head { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.section-head p { margin: 0; max-width: 72ch; color: var(--soft); }
.word { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; }
.word .w { font-family: var(--serif); font-size: 16px; color: var(--navy); }
.word .to { font-family: var(--mono); font-size: 12px; }
.word p { margin: 2px 0 0; font-size: 13px; color: var(--soft); }
.word p code { color: var(--ink); }

figure.spine { margin: 0; display: flex; flex-direction: column; gap: 10px; }
.spine-wrap { overflow-x: auto; background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px; }
.spine svg { display: block; width: 100%; min-width: 900px; height: auto; color: var(--ink); }
.spine .box { fill: var(--card); stroke: var(--line-strong); stroke-width: 1.2; }
.spine .box--em { stroke: var(--accent); stroke-width: 2; }
.spine a:hover .box { stroke: var(--accent); }
.spine .tn { font-family: var(--mono); font-size: 13px; font-weight: 600; fill: var(--ink); }
.spine .ts { font-family: var(--sans); font-size: 11px; fill: var(--soft); }
.spine .e-key, .spine .e-join { fill: none; stroke: currentColor; stroke-width: 1.3; }
.spine .e-code { fill: none; stroke: currentColor; stroke-width: 1.3; stroke-dasharray: 2 4; stroke-linecap: round; }
.spine .chip { fill: var(--card); stroke: var(--line); }
.spine .lbl { font-family: var(--mono); font-size: 11px; fill: var(--ink); }
figcaption { font-family: var(--serif); font-style: italic; font-size: 13px; color: var(--soft); max-width: 80ch; }

.area { display: flex; flex-direction: column; }
.cards { display: flex; flex-direction: column; gap: 16px; }
.t { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; scroll-margin-top: 16px; display: flex; flex-direction: column; gap: 8px; }
.t:target { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-pale); }
.t-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; }
.t-head h3 { font-family: var(--mono); font-size: 16px; color: var(--ink); }
.t-meta { font-size: 12px; color: var(--soft); }
.doc { margin: 0; max-width: 78ch; font-size: 13.5px; }
.doc--none { color: var(--soft); font-style: italic; }
.cols-wrap { overflow-x: auto; }
.cols { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.cols th { text-align: left; font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--soft); padding: 4px 8px 6px 0; border-bottom: 1px solid var(--line-strong); }
.cols td { padding: 3px 8px 3px 0; border-bottom: 1px solid var(--line); vertical-align: baseline; }
.cols tr:last-child td { border-bottom: 0; }
.cols .cn { white-space: nowrap; width: 1%; }
.cols .ct { color: var(--soft); white-space: nowrap; width: 1%; font-variant-numeric: tabular-nums; }
.cols .cm { white-space: nowrap; width: 1%; }
.cols .cf { color: var(--ink); }
.cols .opt .cn code { color: var(--soft); }
.cols tr.hit td { background: var(--hit); }
.t-foot { display: flex; flex-direction: column; gap: 2px; font-size: 12.5px; color: var(--soft); border-top: 1px solid var(--line); padding-top: 8px; }
.t-foot p { margin: 0; }
.t-foot code { color: var(--ink); }
.soft { color: var(--soft); }
.area.empty, .rail li.empty { display: none; }
footer.colophon { color: var(--soft); font-size: 12.5px; max-width: 80ch; border-top: 1px solid var(--line); padding-top: 14px; }
footer.colophon code { color: var(--ink); }

@media (max-width: 900px) {
  .wrap { grid-template-columns: minmax(0, 1fr); gap: 20px; }
  .rail { position: static; max-height: none; }
  .rail nav { display: none; }
  .rail details[open] nav { display: block; }
  .masthead h1 { font-size: 28px; }
}
@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }
"""

JS = r"""
(function () {
  var q = document.getElementById('q'), count = document.getElementById('count');
  var cards = [].slice.call(document.querySelectorAll('article.t'));
  var areas = [].slice.call(document.querySelectorAll('section.area'));
  var links = [].slice.call(document.querySelectorAll('.rail li[data-t]'));
  var total = cards.length;
  function apply() {
    var s = q.value.trim().toLowerCase(), shown = {};
    cards.forEach(function (c) {
      var hit = !s || c.dataset.name.indexOf(s) >= 0 || (' ' + c.dataset.cols + ' ').indexOf(s) >= 0 || c.dataset.doc.indexOf(s) >= 0;
      c.hidden = !hit; shown[c.dataset.name] = hit;
      [].forEach.call(c.querySelectorAll('tr[data-c]'), function (r) { r.classList.toggle('hit', !!s && r.dataset.c.indexOf(s) >= 0); });
    });
    areas.forEach(function (a) { a.classList.toggle('empty', ![].some.call(a.querySelectorAll('article.t'), function (c) { return !c.hidden; })); });
    links.forEach(function (l) { l.classList.toggle('empty', !shown[l.dataset.t]); });
    var n = Object.keys(shown).filter(function (k) { return shown[k]; }).length;
    count.textContent = s ? n + ' of ' + total + ' tables match' : total + ' tables';
  }
  q.addEventListener('input', apply);
  apply();
  // On a phone the index sits above the content, so it starts folded.
  var index = document.getElementById('index');
  if (index && window.matchMedia('(max-width: 900px)').matches) index.removeAttribute('open');
})();
"""


def build() -> str:
    Base, docs, modules = load_models()
    tables = Base.metadata.tables
    areas = areas_of(tables, modules)
    pointed: dict[str, list[tuple[str, str]]] = {}
    n_fk = 0
    for t in tables.values():
        for c in t.columns:
            if c.foreign_keys:
                n_fk += 1
                target = next(iter(c.foreign_keys)).column.table.name
                pointed.setdefault(target, []).append((t.name, c.name))
    n_cols = sum(len(t.columns) for t in tables.values())
    try:
        sha = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, capture_output=True, text=True,
                             check=True).stdout.strip()
    except Exception:  # noqa: BLE001 - a copy outside git still gets a page
        sha = "working tree"
    today = dt.date.today().strftime("%-d %B %Y")

    rail = []
    for area, names in areas:
        rail.append(f"<h4>{html.escape(area)}</h4><ul>" + "".join(
            f'<li data-t="{n}"><a href="#t-{n}">{n}<span>{len(tables[n].columns)}</span></a></li>' for n in names)
            + "</ul>")
    words = "".join(
        f'<div class="word"><span class="w">{html.escape(w)}</span><a class="to" href="#t-{t}">{t}</a><p>{p}</p></div>'
        for w, t, p in WORDS)
    sections = []
    for area, names in areas:
        intro = AREA_INTRO.get(area, "")
        sections.append(
            f'<section class="area" id="a-{area.lower().replace(" ", "-")}"><div class="section-head">'
            f'<h2>{html.escape(area)}</h2>{f"<p>{html.escape(intro)}</p>" if intro else ""}</div><div class="cards">'
            + "".join(card(tables[n], docs.get(n, ""), modules.get(n, "?"), pointed) for n in names)
            + "</div></section>")

    return f"""<title>Duty Free Professor Database Map</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Inter:wght@400;500;600;700&display=swap">
<style>{CSS}</style>
<div class="wrap">
<aside class="rail">
  <label for="q" class="eyebrow">Find</label>
  <input id="q" type="search" placeholder="a table or a column…" autocomplete="off">
  <div class="count" id="count">{len(tables)} tables</div>
  <details open id="index"><summary>Every table, by area</summary><nav>{"".join(rail)}</nav></details>
</aside>
<main>
<header class="masthead">
  <span class="eyebrow">Duty Free Professor · internal</span>
  <h1>The database, table by table</h1>
  <p class="lede">Every table the app has, what each column holds, which columns are keys and where they point. Read from the models, not from memory, so what is here is what Postgres has.</p>
  <ul class="facts"><li><b>{len(tables)}</b> tables</li><li><b>{n_cols}</b> columns</li><li><b>{n_fk}</b> keys between them</li><li>from <code>main/app/models/</code> at <code>{html.escape(sha)}</code>, {today}</li></ul>
  <div class="legend"><span><b class="m m-pk">PK</b> primary key</span><span><b class="m m-fk">FK</b> points at another table</span><span><b class="m">U</b> unique</span><span><b class="m">I</b> indexed</span><span><code>name</code><span class="q">?</span> may be empty</span></div>
</header>

<section class="words-section">
  <div class="section-head"><h2>In your words</h2><p>The things you name, and the tables that hold them.</p></div>
  <div class="words">{words}</div>
</section>

<figure class="spine">
  <div class="spine-wrap">{svg_spine()}</div>
  <figcaption>The catalogue's spine. An arrow leaves the table that holds the key and names its column; the dotted line is a pairing that lives in code, not in a key. The decided layer (overrides, merges, a pin or an ignore on a listing) and the who-columns that point at accounts are in the cards below. Click a box to jump to its table.</figcaption>
</figure>

{"".join(sections)}

<footer class="colophon">Generated by <code>main/scripts/dbmap.py</code> from the SQLAlchemy models; the same relationships, in a form the doc gates check, are in <code>main/docs/DATA-MODEL.md</code>. A table with no docstring shows that gap on purpose: it is the signal to write one.</footer>
</main>
</div>
<script>{JS}</script>
"""


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("--html", required=True, help="where to write the page")
    a = ap.parse_args()
    out = pathlib.Path(a.html)
    out.write_text(build())
    print(f"dbmap: wrote {out} ({out.stat().st_size // 1024} KB)")
    return 0


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