#!/usr/bin/env python3
"""Reproduce the births figure: read births-three-ways.csv → HTML+SVG → 2x PNG via headless Chrome.

Sits in public/data/ because it is published: readers can regenerate the figure from the CSV.
Not part of the site build — the PNG is rendered once here and committed.

Usage:
  python3 public/data/make_figure_births.py
Writes build/fig-births.html + public/figures/births-three-ways.png
Data source: see the CSV header (births-three-ways.csv).
"""
import csv
import functools
import http.server
import json
import os
import re
import socketserver
import subprocess
import sys
import threading
from decimal import Decimal, ROUND_HALF_UP

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
CSV = os.path.join(HERE, "births-three-ways.csv")
OUT_HTML = os.path.join(ROOT, "build", "fig-births.html")
OUT_PNG = os.path.join(ROOT, "public", "figures", "births-three-ways.png")
W = 1440
WIN = W - 80
SCALE = 2

# ---- data ------------------------------------------------------------------
rows = []
with open(CSV) as f:
    for r in csv.DictReader([l for l in f if not l.startswith("#")]):
        rows.append({k: (int(v) if k == "year" else (float(v) if v not in ("", None) else None))
                     for k, v in r.items() if k})
by_year = {r["year"]: r for r in rows}
YEARS = [r["year"] for r in rows]
assert YEARS == list(range(2013, 2025 + 1)), YEARS

# invariants carried in the CSV (reproduced from the article's tables) are re-derived here
assert by_year[2016]["nbs_first_printed_mn"] == 17.86 and by_year[2016]["health_live_births_mn"] == 18.46
assert by_year[2015]["nbs_first_printed_mn"] == 16.55 and by_year[2015]["nbs_later_vintage_mn"] == 16.54
assert by_year[2020]["nbs_first_printed_mn"] is None          # no count printed that year
assert by_year[2020]["nbs_later_vintage_mn"] == 12.02 and by_year[2020]["health_live_births_mn"] == 12.03
for y in (2020, 2021, 2022, 2023, 2024, 2025):
    r = by_year[y]
    cand = (r["primary_intake_mn"] - r["cohort_calendar_mn"]) / r["cohort_calendar_mn"] * 100
    cwin = (r["primary_intake_mn"] - r["cohort_sep_aug_mn"]) / r["cohort_sep_aug_mn"] * 100
    print("  intake %d: calendar %+.1f%%  window %+.1f%%" % (y, cand, cwin))
assert by_year[2025]["primary_intake_mn"] == 14.62
assert abs((by_year[2022]["primary_intake_mn"] - by_year[2022]["cohort_calendar_mn"])
           / by_year[2022]["cohort_calendar_mn"] * 100 + 4.7) < 0.1

YEAR_STEP = True

PANELS = [
    dict(box=(0, 0, WIN, 430), title="Births, by source", unit="million",
         lo=8, hi=20, years=list(range(2013, 2026)), yticks=[8, 11, 14, 17, 20],
         lines=[dict(col="nbs_first_printed_mn", cls="l-focus", dot="dot-focus", elabel="el-focus",
                     label="Statistics bureau, as first printed"),
                dict(col="nbs_later_vintage_mn", cls="l-grey", dot="dot", elabel="", label="same years, later vintage"),
                dict(col="health_live_births_mn", cls="l-blue", dot="dot-blue", elabel="el-blue",
                     label="Health commission, hospital live births", dashed=True)],
         vnote=dict(year=2020, text="2020: no count printed in the communiqué", dy=34)),
    dict(box=(0, 490, WIN, 500), title="Six years on: the primary-school intake",
         unit="million", lo=14, hi=19, years=list(range(2020, 2026)), yticks=[14, 15, 16, 17, 18, 19],
         lines=[dict(col="primary_intake_mn", cls="l-focus", dot="dot-focus", elabel="el-focus",
                     label="First-grade intake, September"),
                dict(col="cohort_calendar_mn", cls="l-grey", dot="dot", elabel="", label="Births, same calendar year"),
                dict(col="cohort_sep_aug_mn", cls="l-teal", dot="dot-teal", elabel="el-teal",
                     label="Births, September–August window", dashed=True)]),
]

HEADLINE = "China counts the same year's births more than once, and the counts do not agree."
DECK = 'Statistics bureau as first printed, the same years in a later vintage, hospital live births from the health commission — and six years afterwards the primary-school intake arrives as another reading of the same cohort. All figures in million.'
FOOT = '<b>Data:</b> NBS statistical communiqués and later yearbook/census vintages; NHC hospital live births (quoted from a single secondary reading of the yearbook — see the article); MOE annual education statistics bulletins for the intake. Read 2026-10-14 (CST). The September–August window is my own construction (1/3 of the prior calendar year plus 2/3 of the stated year), described in the article. <b>Reuse:</b> figure and data CC BY 4.0 &mdash; copy them, link back to this page.'


def fmt1(v):
    """One decimal, half away from zero — printed labels must not depend on float repr."""
    return f"{Decimal(str(v)).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP):+.1f}"


def fmt_tick(t):
    return f"{t:+g}" if any(p["lo"] < 0 for p in PANELS) else f"{t:g}"


OFFSCALE = []


def panel(p):
    x0, y0, w, h = p["box"]
    pad_l, pad_b, pad_t, pad_r = 96, 52, 60, 300
    px, py = x0 + pad_l, y0 + pad_t
    pw, ph = w - pad_l - pad_r, h - pad_t - pad_b
    lo, hi = p["lo"], p["hi"]
    years = p["years"]
    first, last = years[0], years[-1]

    def sx(year):
        return px + (year - first) / (last - first) * pw

    def sy(v):
        return py + ph - (v - lo) / (hi - lo) * ph

    out = [f'<text class="ptitle" x="{x0}" y="{y0 + 26}">{p["title"]}</text>',
           f'<text class="pun" x="{x0}" y="{y0 + 50}">{p["unit"]}</text>']
    for t in p["yticks"]:
        yy = sy(t)
        out.append(f'<line class="hair" x1="{px}" y1="{yy:.1f}" x2="{px + pw:.1f}" y2="{yy:.1f}"/>')
        out.append(f'<text class="tick" x="{px - 12}" y="{yy + 5:.1f}" text-anchor="end">{fmt_tick(t)}</text>')
    for yr in years:
        if YEAR_STEP and yr % 2 and len(years) > 8:
            continue
        xx = sx(yr)
        out.append(f'<text class="tick" x="{xx:.1f}" y="{py + ph + 30:.1f}" text-anchor="middle">{yr}</text>')
    if lo < 0:
        out.append(f'<line class="zero" x1="{px}" y1="{sy(0):.1f}" x2="{px + pw:.1f}" y2="{sy(0):.1f}"/>')
    out.append(f'<line class="axis" x1="{px}" y1="{py}" x2="{px}" y2="{py + ph}"/>')

    slot = pw / (last - first)
    for s in p.get("lines", []):
        pts = [(y, by_year[y][s["col"]]) for y in years if by_year[y].get(s["col"]) is not None]
        for y, v in pts:
            if v > hi or v < lo:
                OFFSCALE.append((s["col"], y, v))
        if len(pts) < 2:
            if pts:
                out.append(f'<circle class="dot {s["dot"]}" cx="{sx(pts[0][0]):.1f}" cy="{sy(pts[0][1]):.1f}" r="4"/>')
            continue
        d = " ".join(f'{"M" if i == 0 else "L"}{sx(y):.1f},{sy(v):.1f}' for i, (y, v) in enumerate(pts))
        dash = ' stroke-dasharray="7 5"' if s.get("dashed") else ""
        out.append(f'<path class="{s["cls"]}" style="fill:none{dash}" d="{d}"/>')
        ly, lv = pts[-1]
        out.append(f'<circle class="{s["dot"]}" cx="{sx(ly):.1f}" cy="{sy(lv):.1f}" r="4"/>')
        out.append(f'<text class="endlabel {s["elabel"]}" x="{sx(ly) + 12:.1f}" y="{sy(lv) + 5:.1f}">{s["label"]}</text>')

    if p.get("bars"):
        b = p["bars"]
        bw = slot * 0.46
        for y in years:
            v = by_year[y].get(b["col"])
            if v is None:
                continue
            x = sx(y) - bw / 2
            hgt = abs(sy(v) - sy(b["base"]))
            top = sy(max(v, b["base"]))
            out.append(f'<rect class="{b["cls"]}" x="{x:.1f}" y="{top:.1f}" width="{bw:.1f}" height="{hgt:.1f}"/>')
            ty = top - 12 if v >= b["base"] else top + hgt + 24
            out.append(f'<text class="barval" x="{x + bw / 2:.1f}" y="{ty:.1f}" text-anchor="middle">'
                       f'{fmt1(v)}</text>')

    if p.get("vnote"):
        vn = p["vnote"]
        xx = sx(vn["year"])
        out.append(f'<line class="vline" x1="{xx:.1f}" y1="{py}" x2="{xx:.1f}" y2="{py + ph}"/>')
        out.append(f'<text class="annot" x="{xx + 14:.1f}" y="{py + (vn.get("dy") or 34)}">{vn["text"]}</text>')
    return "\n".join(out)


HTML_T = """<!doctype html><meta charset="utf-8"><title>__TITLE__</title>
<style>
  @font-face { font-family:'Inter'; src:url('http://127.0.0.1:__PORT__/public/fonts/inter-var.woff2') format('woff2-variations');
               font-weight:100 900; font-style:normal; }
  :root { --ink:#1c1a17; --ink2:#57534b; --ink3:#8b857a; --hair:#e6e1d8; --accent:#b8562f;
           --grey:#8d8880; --neg:#4f6f8f; --paper:#fdfcfa; }
  * { box-sizing:border-box }
  html,body { margin:0; background:var(--paper); }
  .fig { width:__W__px; padding:44px 40px 28px; font-family:'Inter','Helvetica Neue',Arial,sans-serif; color:var(--ink); }
  .headline { font-size:38px; font-weight:700; letter-spacing:-.01em; margin:0 0 12px; line-height:1.15 }
  .deck { font-size:19px; color:var(--ink2); margin:0 0 26px; max-width:1180px; line-height:1.45 }
  .ptitle { font-size:23px; font-weight:650; fill:var(--ink) }
  .pun { font-size:17px; fill:var(--ink2) }
  .tick { font-size:16px; fill:var(--ink3); font-variant-numeric:tabular-nums }
  .hair { stroke:var(--hair); stroke-width:1 }
  .axis { stroke:#b9b2a6; stroke-width:1.4 }
  .zero { stroke:#8b857a; stroke-width:1.6 }
  .l-focus { fill:none; stroke:var(--accent); stroke-width:3.4; stroke-linejoin:round }
  .l-grey { fill:none; stroke:var(--grey); stroke-width:2.4 }
  .l-blue { fill:none; stroke:var(--neg); stroke-width:2.6 }
  .l-teal { fill:none; stroke:#2f6f63; stroke-width:2.4 }
  .dot { fill:var(--grey) } .dot-focus { fill:var(--accent) } .dot-blue { fill:var(--neg) } .dot-teal { fill:#2f6f63 }
  .endlabel { font-size:17px; fill:var(--ink2) } .el-focus { fill:var(--accent); font-weight:650 }
  .el-blue { fill:var(--neg) } .el-teal { fill:#2f6f63 }
  .bar-pos { fill:var(--accent) } .bar-neg { fill:var(--neg) } .bar-grey { fill:var(--grey) }
  .barval { font-size:16px; fill:var(--ink2); font-variant-numeric:tabular-nums }
  .annot { font-size:21px; fill:var(--ink2) }
  .vline { stroke:var(--ink3); stroke-width:1; stroke-dasharray:5 5; opacity:.7 }
  .foot { margin-top:26px; font-size:15px; color:var(--ink3); line-height:1.5 }
  .foot b { color:var(--ink2); font-weight:600 }
</style>
<div class="fig" data-qa>
  <div class="headline" data-qa>__HEADLINE__</div>
  <div class="deck" data-qa>__DECK__</div>
  <svg width="__WIN__" height="__SVGH__" viewBox="0 0 __WIN__ __SVGH__" data-qa>
    __PANELS__
  </svg>
  <div class="foot" data-qa>__FOOT__</div>
</div>
<pre id="qa" style="display:none"></pre>
<script>
  const innermost = [...document.querySelectorAll('[data-qa]')].filter(el => !el.querySelector('[data-qa]'));
  const fig = document.querySelector('.fig').getBoundingClientRect();
  const boxes = innermost.map(el => ({ el, r: el.getBoundingClientRect(), t: (el.textContent||'').trim().slice(0,40) }))
                         .filter(b => b.t.length);
  const outOfBox = boxes.filter(b => b.r.left < fig.left - 0.5 || b.r.right > fig.right + 0.5 ||
                                     b.r.top < fig.top - 0.5 || b.r.bottom > fig.bottom + 0.5)
                        .map(b => b.t);
  const overlaps = [];
  for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
    const a = boxes[i].r, b = boxes[j].r;
    const ox = Math.min(a.right,b.right) - Math.max(a.left,b.left);
    const oy = Math.min(a.bottom,b.bottom) - Math.max(a.top,b.top);
    if (ox > 0 && oy > 0 && ox * oy > 80) overlaps.push([boxes[i].t, boxes[j].t, Math.round(ox*oy)]);
  }
  const svg = document.querySelector('svg');
  const svgBox = svg.getBoundingClientRect();
  const clipped = [...svg.querySelectorAll('text')].filter(t => {
    const r = t.getBoundingClientRect();
    return r.left < svgBox.left - 0.5 || r.right > svgBox.right + 0.5 || r.top < svgBox.top - 0.5 || r.bottom > svgBox.bottom + 0.5;
  }).map(t => (t.textContent||'').slice(0,40));
  document.fonts.ready.then(() => {
    document.getElementById('qa').textContent = JSON.stringify({
      height: Math.ceil(document.body.scrollHeight), width: document.body.scrollWidth,
      outOfBox, overlaps, clipped,
      font_inter: document.fonts.check('16px Inter'),
      fonts_status: document.fonts.status,
      overflowX: document.body.scrollWidth > __W__
    });
  });
</script>
"""


class ReuseServer(socketserver.TCPServer):
    allow_reuse_address = True


def serve(root, port=0):
    handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=root)
    httpd = ReuseServer(("127.0.0.1", port), handler)
    threading.Thread(target=httpd.serve_forever, daemon=True).start()
    return httpd, httpd.server_address[1]


os.makedirs(os.path.dirname(OUT_HTML), exist_ok=True)
os.makedirs(os.path.dirname(OUT_PNG), exist_ok=True)

svg_h = max(p["box"][1] + p["box"][3] for p in PANELS) + 10
panels = [panel(p) for p in PANELS]
httpd, PORT = serve(ROOT)
HTML = (HTML_T.replace("__TITLE__", "fig-births")
        .replace("__HEADLINE__", HEADLINE)
        .replace("__DECK__", DECK)
        .replace("__FOOT__", FOOT)
        .replace("__PANELS__", "".join(panels))
        .replace("__SVGH__", str(svg_h))
        .replace("__WIN__", str(WIN))
        .replace("__W__", str(W))
        .replace("__PORT__", str(PORT)))
open(OUT_HTML, "w").write(HTML)
print("serving", ROOT, "on", PORT, "->", OUT_HTML)

URL = f"http://127.0.0.1:{PORT}/build/fig-births.html"
dump = subprocess.run(["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
                       "--force-device-scale-factor=1", "--virtual-time-budget=6000", "--dump-dom", URL],
                      capture_output=True, text=True).stdout
m = re.search(r'<pre id="qa"[^>]*>(.*?)</pre>', dump, re.S)
qa = json.loads(m.group(1)) if m and m.group(1).strip() else {"error": "qa not found"}
print("QA:", json.dumps(qa, ensure_ascii=False)[:700])
print("OFFSCALE (points outside the y range):", OFFSCALE if OFFSCALE else "0")
problems = []
if OFFSCALE:
    problems.append("series point outside the drawn y range")
if not (qa.get("font_inter") and qa.get("fonts_status") == "loaded"):
    problems.append("site font did not load (would silently fall back)")
for k in ("outOfBox", "overlaps", "clipped"):
    if qa.get(k):
        problems.append(f"{k}: {qa[k]}")
if qa.get("overflowX"):
    problems.append("page overflows horizontally")

subprocess.run(["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
                f"--force-device-scale-factor={SCALE}", "--virtual-time-budget=6000",
                f"--window-size={W},{qa.get('height', 1200)}", f"--screenshot={OUT_PNG}", URL],
               capture_output=True)
httpd.shutdown()
print("wrote", OUT_PNG, os.path.getsize(OUT_PNG), "bytes")
if problems:
    print("!! figure QA problems:", problems, file=sys.stderr)
    sys.exit(3)
sys.exit(0)
