#!/usr/bin/env python3
"""Build figures/moat.html with the polar chart rendered server-side.

The SVG is emitted as static markup so the figure exists without JavaScript;
JS only layers on hover, legend muting and the table toggle. All text is ASCII
plus HTML entities so the file cannot be mis-decoded.
"""
import json, math, html
import os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

FIG = os.path.join(ROOT, "figures")
D = json.load(open(f"{FIG}/moat_data.json"))
# Verified against the Wikipedia API -- never a constructed URL. "parent" means the
# article covers the parent species because the subspecies has none of its own.
WIKI = json.load(open(f"{FIG}/wiki_links.json"))

CX = CY = 450.0
RMAX = 372.0
MAXD = D["maxd"]
N = D["n"]
# No gridline falls inside the moat: the void is drawn as one unbroken
# empty band, and its own two walls carry the scale via the annotation.
RINGS = [3, 5, 10, 15, 20, 25]

SUBCSS = {"Halcyoninae": "halcyon", "Alcedininae": "alcedo",
          "Cerylinae": "ceryle", "outgroup": "outgroup"}
GROUPS = [("Halcyoninae", "tree kingfishers"), ("Alcedininae", "river kingfishers"),
          ("Cerylinae", "water kingfishers"), ("outgroup", "non-kingfishers")]


def ascii_safe(s):
    """ASCII + numeric entities, so the file cannot be mis-decoded whatever the charset."""
    return html.escape(s).encode("ascii", "xmlcharrefreplace").decode("ascii")


def r_of(d):
    return math.sqrt(d / MAXD) * RMAX


def a_of(i):
    return (i / N) * math.tau - math.pi / 2


def f(x):
    return f"{x:.2f}".rstrip("0").rstrip(".")


# ── points: positions computed once, reused by the SVG and by the JS hover ──
pts = []
for p in D["pts"]:
    a, r = a_of(p["i"]), r_of(p["d"])
    q = dict(p)
    q["x"] = round(CX + r * math.cos(a), 2)
    q["y"] = round(CY + r * math.sin(a), 2)
    pts.append(q)

# ── voids ───────────────────────────────────────────────────────────────────
def circle_path(cx, cy, r):
    """Full circle as two half-arcs. A single near-zero-chord arc is degenerate:
    the implied centre is numerically unstable and browsers blow the shape up."""
    return (f"M {f(cx - r)} {f(cy)} A {f(r)} {f(r)} 0 1 0 {f(cx + r)} {f(cy)} "
            f"A {f(r)} {f(r)} 0 1 0 {f(cx - r)} {f(cy)} Z")


void_svg = []
for k, g in enumerate(D["gaps"]):
    moat = k == 0
    ro, ri = r_of(g["hi"]), r_of(g["lo"])
    cls = "v-moat" if moat else "v-extra"
    fill = "var(--moat)" if moat else "var(--void)"
    edge = "var(--moat-edge)" if moat else "var(--void-edge)"
    sw = 1.15 if moat else 0.7
    void_svg.append(f'<path class="{cls}" fill-rule="evenodd" fill="{fill}" '
                    f'd="{circle_path(CX, CY, ro)} {circle_path(CX, CY, ri)}"/>')
    for r in (ri, ro):
        void_svg.append(f'<circle class="{cls}" cx="{f(CX)}" cy="{f(CY)}" r="{f(r)}" '
                        f'fill="none" stroke="{edge}" stroke-width="{sw}"/>')

# ── grid rings + labels ─────────────────────────────────────────────────────
grid_svg = []
for v in RINGS:
    grid_svg.append(f'<circle cx="{f(CX)}" cy="{f(CY)}" r="{f(r_of(v))}" fill="none" '
                    f'stroke="var(--ring)" stroke-width="0.5" opacity="0.55"/>')
for v in RINGS:
    y = CY - r_of(v)
    txt = str(int(v))
    w = len(txt) * 7 + 13
    grid_svg.append(f'<rect x="{f(CX - w / 2)}" y="{f(y - 8)}" width="{w}" height="16" '
                    f'rx="2" fill="var(--chip)"/>')
    grid_svg.append(f'<text x="{f(CX)}" y="{f(y + 4)}" text-anchor="middle" class="rl">{txt}</text>')
grid_svg.append(f'<text x="{f(CX)}" y="{f(CY - r_of(25) - 16)}" text-anchor="middle" '
                f'class="ru">ND2 % DIVERGENCE</text>')

# ── marks ───────────────────────────────────────────────────────────────────
pt_svg = []
for q in pts:
    css = "sihek" if q["sihek"] else SUBCSS[q["sub"]]
    if q["sihek"]:
        mark = (f'<circle class="pt p-sihek" cx="{q["x"]}" cy="{q["y"]}" r="4.6" '
                f'fill="var(--sihek)" stroke="var(--ground)" stroke-width="2"/>')
    else:
        mark = (f'<circle class="pt p-{css}" data-sub="{q["sub"]}" cx="{q["x"]}" '
                f'cy="{q["y"]}" r="2.5" fill="var(--{css})" fill-opacity="0.82"/>')
    # A real anchor, not a scripted window.open: sandboxed frames block popups,
    # but they honour a genuine link activation.
    w = WIKI.get(q["sp"])
    if w:
        title = ascii_safe(q["sp"]) + " -- Wikipedia: " + ascii_safe(w["title"])
        mark = (f'<a class="mk" href="{w["url"]}" target="_blank" rel="noopener noreferrer">'
                f'<title>{title}</title>{mark}</a>')
    pt_svg.append(mark)

# ── annotations ─────────────────────────────────────────────────────────────
def text_chip(x, y, txt, cls, anchor="start", size=12.5):
    """Label plus a surface chip, so annotation never fights the marks under it."""
    plain = (txt.replace("&middot;", ".").replace("&ndash;", "-")
                .replace("&rarr;", ">").replace("&mdash;", "-"))
    w = len(plain) * size * 0.6 + 10
    x0 = {"start": x - 5, "middle": x - w / 2, "end": x - w + 5}[anchor]
    return (f'<rect x="{f(x0)}" y="{f(y - size * 0.92)}" width="{f(w)}" '
            f'height="{f(size * 1.32)}" rx="2" fill="var(--chip)"/>'
            f'<text x="{f(x)}" y="{f(y)}" text-anchor="{anchor}" class="{cls}">{txt}</text>')

moat_g = D["gaps"][0]
rm = r_of(moat_g["hi"])
my = CY + rm + 22
anno = [
    # sihek callout points LEFT, into the empty half of the inner disc
    f'<line x1="{f(CX - 7)}" y1="{f(CY)}" x2="{f(CX - 96)}" y2="{f(CY - 34)}" '
    f'stroke="var(--sihek)" stroke-width="1"/>',
    text_chip(CX - 102, CY - 30, "SIHEK", "a-lead", "end", 12.5),
    text_chip(CX - 102, CY - 15, "3 individuals, 0.10&ndash;0.29% apart", "a-sub", "end", 10.5),
    f'<line x1="{f(CX)}" y1="{f(CY)}" x2="{f(CX)}" y2="{f(CY + rm)}" '
    f'stroke="var(--moat-edge)" stroke-width="1" stroke-dasharray="1 3"/>',
    text_chip(CX, my, f'{moat_g["w"]:.2f} points of nothing', "a-lead", "middle", 12.5),
    text_chip(CX, my + 16, f'{moat_g["lo"]:.2f}% to {moat_g["hi"]:.2f}%, zero sequences',
              "a-sub", "middle", 10.5),
]

# ── legend / stats / table ──────────────────────────────────────────────────
counts = {}
for p in D["pts"]:
    counts[p["sub"]] = counts.get(p["sub"], 0) + 1

legend = "".join(
    f'<button class="lg" type="button" data-sub="{k}" aria-pressed="false" title="{t}">'
    f'<span class="dot" style="background:var(--{SUBCSS[k]})"></span>{k} '
    f'<span class="n">{counts.get(k, 0)}</span></button>' for k, t in GROUPS)
legend += ('<span class="lg lg-static"><span class="dot" style="background:var(--sihek)">'
           '</span>Sihek <span class="n">3</span></span>')

stats = [("0", "sequences in the moat"),
         (f'{moat_g["w"]:.2f}<span class="u"> pp</span>', "width of the void"),
         ('2.30<span class="u"> %</span>', "nearest other kingfisher"),
         ('0.29<span class="u"> %</span>', "spread within the sihek"),
         (str(len(D["gaps"])), "empty annuli in the family")]
stats_html = "".join(f'<div class="stat"><div class="v">{v}</div><div class="k">{k}</div></div>'
                     for v, k in stats)

rows = []
for q in sorted(pts, key=lambda z: z["d"]):
    css = "sihek" if q["sihek"] else SUBCSS[q["sub"]]
    tr = ' class="is-sihek"' if q["sihek"] else ""
    w = WIKI.get(q["sp"])
    name = f'<em>{ascii_safe(q["sp"])}</em>'
    if w:
        lk = "lnk lnk-parent" if w["kind"] == "parent" else "lnk"
        hint = (f'Wikipedia: {ascii_safe(w["title"])}'
                + (" (article covers the parent species)" if w["kind"] == "parent" else ""))
        name = (f'<a class="{lk}" href="{w["url"]}" target="_blank" '
                f'rel="noopener noreferrer" title="{hint}">{name}</a>')
    rows.append(
        f'<tr{tr}>'
        f'<td class="sp"><span class="swatch" style="background:var(--{css})"></span>{name}</td>'
        f'<td>{q["acc"]}</td><td>{q["sub"]}</td>'
        f'<td class="num">{q["d"]:.4f}</td><td class="num">{q["n"]}</td></tr>')

def hov(q):
    r = {"x": q["x"], "y": q["y"], "s": q["sp"], "a": q["acc"],
         "u": q["sub"], "d": q["d"], "n": q["n"]}
    w = WIKI.get(q["sp"])
    if w:
        r["w"] = w["url"]
        r["wt"] = w["title"]
        r["wk"] = w["kind"]
    return r


hover = json.dumps([hov(q) for q in pts], separators=(",", ":"), ensure_ascii=True)

TPL = open(os.path.join(ROOT, "scripts", "moat_template.html")).read()
TOKENS = open(os.path.join(ROOT, "scripts", "tokens.css")).read()
out = (TPL.replace("/*TOKENS*/", TOKENS)
          .replace("<!--VOIDS-->", "\n".join(void_svg))
          .replace("<!--GRID-->", "\n".join(grid_svg))
          .replace("<!--PTS-->", "\n".join(pt_svg))
          .replace("<!--ANNO-->", "\n".join(anno))
          .replace("<!--LEGEND-->", legend)
          .replace("<!--STATS-->", stats_html)
          .replace("<!--ROWS-->", "\n".join(rows))
          .replace("/*HOVER*/", hover))

for marker in ("/*TOKENS*/", "<!--VOIDS-->", "<!--GRID-->", "<!--PTS-->", "<!--ANNO-->",
               "<!--LEGEND-->", "<!--STATS-->", "<!--ROWS-->", "/*HOVER*/"):
    assert marker not in out, f"unfilled placeholder {marker}"
non_ascii = sorted({c for c in out if ord(c) > 127})
assert not non_ascii, f"non-ASCII characters present: {non_ascii}"
open(f"{FIG}/moat.html", "w").write(out)
print(f"wrote {FIG}/moat.html  {len(out)} bytes  ({len(pts)} marks, {len(D['gaps'])} voids)")
