#!/usr/bin/env python3
"""Render each flat file in data/ as a styled, self-contained HTML view.

The .txt files stay canonical: every page here links back to its own raw file,
and nothing in data/ is rewritten. This script only adds .html siblings.

Design follows index.html and adds no new colour. The validated triad
(icon/index/symbol) is applied only where it carries meaning -- MODE codes --
because that distinction is the argument the page makes. Every other family
is set in neutral ink, so colour stays reinforcement and never the only channel.

Run:  python3 scripts/build_data_pages.py
"""

import html
import re
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "data"

# ── shared style, inlined into every page (no external requests, as index.html) ──
CSS = """
:root{
  color-scheme:dark;
  --void:#060a09; --panel:#0b1211; --panel-2:#101917;
  --rule:#1d2b28; --rule-hot:#2b3d39;
  --ink:#dfe6e3; --ink-2:#93a4a0; --ink-3:#61726e;
  --icon:#2b93cc; --index:#d6428a; --symbol:#c08a1e;
  --degen:#61726e;
  --mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,"DejaVu Sans Mono",monospace;
  --sans:"Helvetica Neue",Helvetica,Inter,ui-sans-serif,system-ui,Arial,sans-serif;
  --readout:0.34em;
}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{
  margin:0;background:var(--void);background-size:140px 140px,auto;color:var(--ink);
  font:16px/1.62 var(--sans);-webkit-font-smoothing:antialiased;
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E"),
    repeating-linear-gradient(0deg,rgba(223,230,227,.012) 0 1px,transparent 1px 3px);
  background-blend-mode:overlay;
}
.wrap{max-width:1140px;margin:0 auto;padding:0 28px 96px}
.crumb{font:10.5px/1.7 var(--mono);letter-spacing:var(--readout);text-transform:uppercase;
  color:var(--ink-3);padding:26px 0 0}
.crumb a{color:var(--ink-3);text-decoration:none;border-bottom:1px solid var(--rule)}
.crumb a:hover{color:var(--icon);border-color:var(--icon)}
header.top{padding:40px 0 0;border-bottom:1px solid var(--rule);margin-bottom:30px}
h1{font:700 clamp(30px,5.2vw,54px)/0.94 var(--sans);letter-spacing:-.04em;margin:0 0 4px;
  text-transform:uppercase;transform:scaleX(.9);transform-origin:left;color:#f2f6f4}
.kicker{font:10.5px/1.7 var(--mono);letter-spacing:.26em;text-transform:uppercase;
  color:var(--symbol);margin:0 0 20px;padding-top:15px}
.kicker i{font-style:normal;letter-spacing:0;margin-right:.85em;opacity:.8}
.standfirst{font-size:17px;line-height:1.55;color:var(--ink-2);max-width:70ch;margin:0 0 22px}
.raw{font:10.5px/1 var(--mono);letter-spacing:.18em;text-transform:uppercase;margin:0 0 30px}
.raw a{color:var(--icon);text-decoration:none;border-bottom:1px solid var(--rule-hot)}
.raw a:hover{color:var(--index);border-color:var(--index)}
h2{font:400 12px/1.3 var(--mono);letter-spacing:var(--readout);text-transform:uppercase;
  color:var(--ink-2);margin:48px 0 4px;padding-bottom:10px;border-bottom:1px solid var(--rule)}
h2::before{content:"\\2593\\2592\\2591 ";color:var(--symbol);letter-spacing:0}
a{color:var(--icon)}
a:hover{color:var(--index)}
p{max-width:70ch;margin:0 0 17px}

/* ── record tables ─────────────────────────── */
.tbl{width:100%;border-collapse:collapse;margin-top:22px;font-size:14px}
.tbl th{font:10px/1.3 var(--mono);letter-spacing:.2em;text-transform:uppercase;
  color:var(--ink-3);text-align:left;padding:0 14px 10px 0;border-bottom:1px solid var(--rule);
  white-space:nowrap;vertical-align:bottom}
.tbl td{padding:11px 14px 11px 0;border-bottom:1px solid var(--rule);vertical-align:top}
.tbl tr:hover td{background:rgba(43,147,204,.045)}
.doc{font:11.5px var(--mono);color:var(--ink-2);white-space:nowrap}
.pid{font:11.5px var(--mono);color:var(--ink-3);white-space:nowrap}
.quote{color:var(--ink);max-width:76ch;line-height:1.55}
.chapter{font:11.5px var(--mono);color:var(--ink-2);white-space:nowrap}

/* ── code chips ────────────────────────────── */
.chips{display:flex;flex-wrap:wrap;gap:5px}
.chip{display:inline-flex;align-items:baseline;gap:.45em;font:11px var(--mono);
  background:var(--panel-2);border:1px solid var(--rule);border-left-width:2px;
  border-left-color:var(--rule-hot);border-radius:2px;padding:2px 7px 3px;color:var(--ink);
  white-space:nowrap}
.chip u{text-decoration:none;font-size:9.5px;letter-spacing:.14em;color:var(--ink-3)}
.chip.icon{border-left-color:var(--icon)}
.chip.index{border-left-color:var(--index)}
.chip.symbol{border-left-color:var(--symbol)}
.chip.degen{border-left-color:var(--degen)}

/* ── fixed-width report blocks ─────────────── */
pre.report{background:var(--panel);border:1px solid var(--rule);border-left:2px solid var(--symbol);
  border-radius:2px;padding:18px 20px;overflow-x:auto;margin:0 0 26px;
  font:12.5px/1.62 var(--mono);color:var(--ink);white-space:pre;
  font-variant-numeric:tabular-nums}
pre.report::-webkit-scrollbar{height:9px}
pre.report::-webkit-scrollbar-thumb{background:var(--rule-hot);border-radius:5px}

/* ── codebook ──────────────────────────────── */
.fam{margin-top:40px}
.fam h3{font:400 11px/1.3 var(--mono);letter-spacing:var(--readout);text-transform:uppercase;
  color:var(--symbol);margin:0 0 2px;padding-bottom:9px;border-bottom:1px solid var(--rule)}
.code{border-bottom:1px solid var(--rule);padding:15px 0}
.code .nm{font:12.5px var(--mono);color:var(--icon);margin-bottom:7px}
.code .def{font-size:14.5px;color:var(--ink);max-width:82ch;margin:0 0 8px;line-height:1.55}
.code .ie{font-size:13px;color:var(--ink-2);max-width:82ch;margin:0 0 3px;line-height:1.5}
.code .ie b{font:9.5px var(--mono);letter-spacing:.18em;text-transform:uppercase;
  color:var(--ink-3);font-weight:400;margin-right:.7em}

/* ── passage blocks ────────────────────────── */
.psg{border-left:2px solid var(--rule-hot);background:var(--panel);padding:14px 18px;
  margin:0 0 14px;max-width:86ch}
.psg .n{font:9.5px var(--mono);letter-spacing:.2em;text-transform:uppercase;
  color:var(--symbol);display:block;margin-bottom:7px}
.psg p{margin:0;font-size:14.5px;color:var(--ink);line-height:1.6}

/* ── directory ─────────────────────────────── */
.res{border-top:1px solid var(--rule);margin:26px 0 0;max-width:88ch}
.res div{display:grid;grid-template-columns:250px 1fr;gap:18px;
  border-bottom:1px solid var(--rule);padding:13px 0}
.res .f{text-decoration:none;font:11.5px/1.5 var(--mono);color:var(--icon);word-break:break-all}
.res .d{font-size:14px;color:var(--ink-2)}
.res .d s{display:block;text-decoration:none;font:10px var(--mono);letter-spacing:.16em;
  text-transform:uppercase;color:var(--ink-3);margin-top:5px}
@media(max-width:640px){.res div{grid-template-columns:1fr;gap:4px}
  .tbl,.tbl tbody,.tbl tr,.tbl td{display:block;width:100%}
  .tbl thead{display:none}
  .tbl td{border:0;padding:3px 0}
  .tbl tr{display:block;border-bottom:1px solid var(--rule);padding:12px 0}}
.colophon{margin-top:64px;padding-top:20px;border-top:1px solid var(--rule);
  font:11.5px/1.85 var(--mono);color:var(--ink-3);max-width:80ch}
.colophon a{color:var(--ink-2)}
"""

# Google Analytics (GA4). A third-party script, and the one external request
# these pages make -- see the README note on what that changes.
GA = """<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-48RJDXZTPJ"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'G-48RJDXZTPJ');
</script>
"""

MODE_CLASS = {
    "icon": "icon",
    "index": "index",
    "symbol": "symbol",
    "degenerate": "degen",
}


def esc(s):
    return html.escape(s, quote=True)


def page(title, kicker, standfirst, raw_name, body, rows=None):
    """Wrap rendered body in the shared self-contained shell."""
    count = f"<p class=\"standfirst\">{esc(standfirst)}</p>" if standfirst else ""
    raw = ""
    if raw_name:
        raw = (
            f'<p class="raw">&#9608; <a href="{esc(raw_name)}">'
            f"raw flat file &#8212; {esc(raw_name)}</a></p>"
        )
    meta = f" &#183; {rows}" if rows else ""
    return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{esc(title)} &#8212; neuromancer-semiotics</title>
<meta name="description" content="{esc(standfirst)[:180]}">
<style>{CSS}</style>
{GA}</head>
<body>
<div class="wrap">

<p class="crumb"><a href="https://krisrockwell.io/">krisrockwell.io</a> &nbsp;/&nbsp;
<a href="../">neuromancer-semiotics</a> &nbsp;/&nbsp;
<a href="./">data</a> &nbsp;/&nbsp; {esc(raw_name or title)}</p>

<header class="top">
  <h1>{title}</h1>
  <p class="kicker"><i>&#9608;&#9608;&#9593;</i>{esc(kicker)}{meta}</p>
  {count}
  {raw}
</header>

{body}

<p class="colophon">Rendered from <b>{esc(raw_name or '')}</b> by
<a href="../scripts/build_data_pages.py">scripts/build_data_pages.py</a>.
The flat file is canonical; this page is a view of it.
&#183; <a href="../">back to the study</a></p>

</div>
</body>
</html>
"""


def chips(payload):
    """Render a semicolon-separated code list as chips, colouring MODE only."""
    out = []
    for raw in payload.split(";"):
        raw = raw.strip()
        if not raw:
            continue
        if ":" in raw:
            fam, val = raw.split(":", 1)
            fam, val = fam.strip(), val.strip()
        else:
            fam, val = "", raw
        cls = "chip"
        if fam == "MODE":
            cls += " " + MODE_CLASS.get(val, "")
        famtag = f"<u>{esc(fam)}</u>" if fam else ""
        out.append(f'<span class="{cls.strip()}">{famtag}{esc(val)}</span>')
    return '<div class="chips">' + "".join(out) + "</div>"


def read(name):
    return (DATA / name).read_text(encoding="utf-8")


def lines(name):
    return [l for l in read(name).splitlines() if l.strip()]


# ── renderers ────────────────────────────────────────────────────────────────

def render_coded(name, title, kicker, standfirst, quote_col=False):
    """doc | passage-id | payload  (payload = codes, or a quotation)."""
    rows = []
    for l in lines(name):
        doc, pid, payload = l.split("|", 2)
        cell = (
            f'<td class="quote">{esc(payload)}</td>'
            if quote_col
            else f"<td>{chips(payload)}</td>"
        )
        rows.append(
            f'<tr><td class="doc">{esc(doc)}</td>'
            f'<td class="pid">{esc(pid)}</td>{cell}</tr>'
        )
    head = "passage" if quote_col else "codes applied"
    body = (
        f'<table class="tbl"><thead><tr><th>document</th><th>id</th>'
        f"<th>{head}</th></tr></thead><tbody>"
        + "".join(rows)
        + "</tbody></table>"
    )
    return page(title, kicker, standfirst, name, body, f"{len(rows)} rows")


def render_pairs(name, title, kicker, standfirst, left, right, right_codes):
    """two-field records: id|codes  or  chapter|quotation."""
    rows = []
    for l in lines(name):
        a, b = l.split("|", 1)
        cell = f"<td>{chips(b)}</td>" if right_codes else f'<td class="quote">{esc(b)}</td>'
        klass = "pid" if right_codes else "chapter"
        rows.append(f'<tr><td class="{klass}">{esc(a)}</td>{cell}</tr>')
    body = (
        f'<table class="tbl"><thead><tr><th>{esc(left)}</th><th>{esc(right)}</th>'
        "</tr></thead><tbody>" + "".join(rows) + "</tbody></table>"
    )
    return page(title, kicker, standfirst, name, body, f"{len(rows)} rows")


def render_codebook(name, title, kicker, standfirst):
    """CODE :: DEF: ... INCLUDE: ... EXCLUDE: ..., grouped by family."""
    fams, order = {}, []
    for l in lines(name):
        code, rest = l.split(" :: ", 1)
        fam = code.split(":", 1)[0].strip()
        if fam not in fams:
            fams[fam] = []
            order.append(fam)
        parts = re.split(r"\b(DEF:|INCLUDE:|EXCLUDE:)\s*", rest)
        seg = {}
        for i in range(1, len(parts) - 1, 2):
            seg[parts[i].rstrip(":")] = parts[i + 1].strip()
        fams[fam].append((code.strip(), seg))
    out, total = [], 0
    for fam in order:
        items = []
        for code, seg in fams[fam]:
            total += 1
            bits = [f'<div class="nm">{esc(code)}</div>']
            if seg.get("DEF"):
                bits.append(f'<p class="def">{esc(seg["DEF"])}</p>')
            for k in ("INCLUDE", "EXCLUDE"):
                if seg.get(k):
                    bits.append(
                        f'<p class="ie"><b>{k.lower()}</b>{esc(seg[k])}</p>'
                    )
            items.append(f'<div class="code">{"".join(bits)}</div>')
        out.append(
            f'<section class="fam"><h3>{esc(fam)} &#183; {len(fams[fam])} codes</h3>'
            + "".join(items)
            + "</section>"
        )
    return page(title, kicker, standfirst, name, "".join(out),
                f"{total} codes &#183; {len(order)} families")


def render_report(name, title, kicker, standfirst):
    """Fixed-width tables. Banner rules become headings; every content line is
    kept verbatim inside <pre>, so column alignment survives untouched."""
    raw = read(name).splitlines()
    blocks, buf, i = [], [], 0
    rule = re.compile(r"^[=\-]{8,}\s*$")
    # some reports title their sections with a bare uppercase line instead of
    # a fenced banner; the trailing group allows an em-dash gloss
    bare = re.compile(r"^[A-Z][A-Z0-9 ]{5,}(\s*[—-].*)?$")

    def flush():
        while buf and not buf[-1].strip():
            buf.pop()
        while buf and not buf[0].strip():
            buf.pop(0)
        if buf:
            blocks.append(("pre", "\n".join(buf)))
        buf.clear()

    while i < len(raw):
        line = raw[i]
        # a title fenced by rule lines above and/or below
        if rule.match(line):
            nxt = raw[i + 1] if i + 1 < len(raw) else ""
            after = raw[i + 2] if i + 2 < len(raw) else ""
            if nxt.strip() and not rule.match(nxt) and rule.match(after):
                flush()
                blocks.append(("h2", nxt.strip()))
                i += 3
                continue
            if nxt.strip() and not rule.match(nxt) and not raw[i - 1:i]:
                pass
            i += 1
            continue
        if bare.match(line):
            flush()
            blocks.append(("h2", line.strip()))
            i += 1
            continue
        buf.append(line)
        i += 1
    flush()

    out = []
    for kind, text in blocks:
        if kind == "h2":
            out.append(f"<h2>{esc(text)}</h2>")
        else:
            out.append(f'<pre class="report">{esc(text)}</pre>')
    return page(title, kicker, standfirst, name, "".join(out))


def render_passages(name, title, kicker, standfirst):
    """[P1] ... [P2] ... prose passages."""
    text = read(name)
    parts = re.split(r"\[(P\d+)\]", text)
    out = []
    for i in range(1, len(parts) - 1, 2):
        tag, body = parts[i], parts[i + 1].strip()
        out.append(
            f'<div class="psg"><span class="n">{esc(tag)}</span>'
            f"<p>{esc(body)}</p></div>"
        )
    return page(title, kicker, standfirst, name, "".join(out), f"{len(out)} passages")


# ── file registry ────────────────────────────────────────────────────────────

FILES = [
    ("codes_matrix_pass2.txt", "The Matrix", "second pass · every code applied",
     "Every coded passage after the second pass: the chapter document, the passage id, "
     "and each code applied to it. Every number on the study page is computed from this "
     "file. MODE codes carry the validated triad; all other families are set in neutral ink.",
     lambda: render_coded("codes_matrix_pass2.txt", "The Matrix",
                          "second pass · every code applied",
                          "Every coded passage after the second pass: the chapter document, "
                          "the passage id, and each code applied to it. Every number on the "
                          "study page is computed from this file.")),
    ("pass2_tables.txt", "Co-occurrence", "all crosstabs · second pass",
     "Every co-occurrence matrix the study reports: cue against mode, target against mode, "
     "target against verdict, target against cue, verdict against inference.",
     lambda: render_report("pass2_tables.txt", "Co-occurrence",
                           "all crosstabs · second pass",
                           "Every co-occurrence matrix the study reports: cue against mode, "
                           "target against mode, target against verdict, target against cue, "
                           "verdict against inference. Column alignment is preserved exactly.")),
    ("codebook.txt", "The Instrument", "69 codes · fourteen families",
     "The codebook itself: every code with its definition, what it includes and what it "
     "excludes. This is the instrument that was applied — and the only thing the blind "
     "coder was given besides the passages.",
     lambda: render_codebook("codebook.txt", "The Instrument",
                             "69 codes · fourteen families",
                             "Every code with its definition, inclusions and exclusions. This is "
                             "the instrument that was applied, and the only thing the blind coder "
                             "was given besides the passages.")),
    ("blind_input.txt", "Blind Input", "exactly what the second coder saw",
     "The 36 passages handed to an independent coder — chapter, passage id, and the text. "
     "No codes, no context, no argument.",
     lambda: render_coded("blind_input.txt", "Blind Input",
                          "exactly what the second coder saw",
                          "The 36 passages handed to an independent coder — chapter, passage "
                          "id, and the text. No codes, no context, no argument.",
                          quote_col=True)),
    ("blind_output.txt", "Blind Output", "exactly what they returned",
     "What the independent coder returned, against passage id. Compare with the matrix to "
     "see where the two readings part.",
     lambda: render_pairs("blind_output.txt", "Blind Output",
                          "exactly what they returned",
                          "What the independent coder returned, against passage id. Compare with "
                          "the matrix to see where the two readings part.",
                          "passage id", "codes returned", True)),
    ("blind_test_results.txt", "Agreement", "where the two coders diverged",
     "Agreement statistics by family and every passage-level disagreement. The verdict "
     "family — which carries every headline — agreed 33% of the time.",
     lambda: render_report("blind_test_results.txt", "Agreement",
                           "where the two coders diverged",
                           "Agreement statistics by family and every passage-level disagreement. "
                           "The verdict family, which carries every headline, agreed 33% of the time.")),
    ("selection_test_results.txt", "Selection", "what the corpus missed",
     "A blind selection test on three chapters, spans only. The independent reader marked "
     "passages this study never saw: 38% coverage overall.",
     lambda: render_report("selection_test_results.txt", "Selection",
                           "what the corpus missed",
                           "A blind selection test on three chapters, spans only. The independent "
                           "reader marked passages this study never saw: 38% coverage overall.")),
    ("blind_spans.txt", "Blind Spans", "every span the independent reader marked",
     "The raw spans an independent reader selected across three chapters, before any codes "
     "were applied. The basis of the selection test.",
     lambda: render_pairs("blind_spans.txt", "Blind Spans",
                          "every span the independent reader marked",
                          "The raw spans an independent reader selected across three chapters, "
                          "before any codes were applied. The basis of the selection test.",
                          "chapter", "span", False)),
    ("loopA_test.txt", "Loop A", "instrument shakedown · eleven passages",
     "An early shakedown pass: eleven passages pulled to test whether the codebook could be "
     "applied at all before the full corpus was attempted.",
     lambda: render_passages("loopA_test.txt", "Loop A",
                             "instrument shakedown · eleven passages",
                             "An early shakedown pass: eleven passages pulled to test whether the "
                             "codebook could be applied at all before the full corpus was attempted.")),
]


def build_directory():
    rows = []
    for fname, title, kicker, blurb, _ in FILES:
        stem = fname[:-4]
        size = (DATA / fname).stat().st_size
        n = len([l for l in (DATA / fname).read_text(encoding="utf-8").splitlines() if l.strip()])
        rows.append(
            f'<div><a class="f" href="{stem}.html">{esc(title)}</a>'
            f'<span class="d">{esc(blurb)}'
            f"<s>{esc(fname)} &#183; {n} lines &#183; {size:,} bytes &#183; "
            f'<a href="{esc(fname)}">raw</a></s></span></div>'
        )
    body = (
        "<p>Nine flat files. Each is rendered here as a readable view; each view links "
        "back to the raw file it was built from. The flat files are canonical — the "
        "whole matrix can be recomputed from them at any point, which is what makes the "
        "audit possible.</p>"
        '<div class="res">' + "".join(rows) + "</div>"
    )
    return page("Data", "the coded corpus · nine flat files",
                "Everything the study was computed from: the coded matrix, the instrument, "
                "the blind re-code, and the tests that refuted it.",
                None, body)


def main():
    written = []
    for fname, _t, _k, _b, fn in FILES:
        out = DATA / (fname[:-4] + ".html")
        out.write_text(fn(), encoding="utf-8")
        written.append((out.name, out.stat().st_size))
    idx = DATA / "index.html"
    idx.write_text(build_directory(), encoding="utf-8")
    written.append((idx.name, idx.stat().st_size))
    for name, size in written:
        print(f"  wrote data/{name}  {size:,} bytes")
    print(f"{len(written)} pages")


if __name__ == "__main__":
    main()
