"""Guards for ../widget.html: its data block, its size, that its counting gives the same numbers and words as safety_filter.py, and what a reader sees."""
import json
import re
import subprocess

import safety_filter as sf
from embed_widget import CONFIG_BLOCK, GEO, WIDGET, config, embed

HTML = WIDGET.read_text(encoding="utf-8")
ROOT = HTML[HTML.index('<div class="sfw-root">'):HTML.index("<script")]
CONFIG = config()
MSGS = CONFIG["messages"]                       # the test messages in score order, each {t, h, s, y}
LABELS, SCORES = [m["h"] for m in MSGS], [m["s"] for m in MSGS]
THRESHOLDS = range(1, 100)
OPENING = CONFIG["opening"]["pick"]
COMMAS = [0, 7, 999, 1000, 12345, 1234567]
SHARES = [(35, 36), (1, 2), (1, 8), (88, 212), (14, 14)]        # (1, 2) and (1, 8) end in a half that must round up

# Reads the model out of the widget between its marker comments, runs it with Node, and compares every sentence, count and readout
# with the ones Python wrote into the fixture, character for character.
PARITY = r"""
import fs from 'node:fs';
const [fixture, widget] = process.argv.slice(2);
const fx = JSON.parse(fs.readFileSync(fixture, 'utf8'));
const html = fs.readFileSync(widget, 'utf8');
const cfg = JSON.parse(html.match(/<script type="application\/json" id="sfw-config">([\s\S]*?)<\/script>/)[1]);
const js = html.slice(html.indexOf('/* ---------- the model ('), html.indexOf('/* ---------- display'));
const api = new Function('cfg', "'use strict';" + js + '; return {counts, atTraffic, commas, noun, share, view, readout};')(cfg);
const bad = [];
let checks = 0;
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
for (const rare of [false, true]) {
  for (let pct = 1; pct <= 99; pct++) {
    const v = api.view(pct, rare), want = fx.views[String(rare)][String(pct)];
    checks++;
    if (!same({ counts: v.counts, says: v.says, precision: v.precision }, want)) bad.push(['view', pct, rare, v, want]);
  }
}
for (let pct = 1; pct <= 99; pct++) {
  cfg.messages.forEach((m, i) => {
    checks++;
    if (api.readout(m, pct) !== fx.readouts[String(pct)][i]) bad.push(['readout', pct, m.t, api.readout(m, pct), fx.readouts[String(pct)][i]]);
  });
}
fx.commas.forEach(([n, text]) => { checks++; if (api.commas(n) !== text) bad.push(['commas', n, api.commas(n), text]); });
fx.shares.forEach(([part, whole, text]) => { checks++; if (api.share(part, whole) !== text) bad.push(['share', part, whole, api.share(part, whole), text]); });
console.log(JSON.stringify({ checks, bad: bad.slice(0, 4) }));
process.exit(bad.length ? 1 : 0);
"""

# Runs the page script against a small stand-in for the DOM, moves the slider, presses the buttons, taps the plot and ticks the box,
# and reads back what a reader sees.
PAGE = r"""
import fs from 'node:fs';
const [widget, fixture] = process.argv.slice(2);
const html = fs.readFileSync(widget, 'utf8');
const fx = JSON.parse(fs.readFileSync(fixture, 'utf8'));
const config = html.match(/<script type="application\/json" id="sfw-config">([\s\S]*?)<\/script>/)[1];
const code = html.match(/<script>([\s\S]*?)<\/script>/)[1];

function el(tag) {
  const e = { tag, listeners: {}, value: '', checked: false, attrs: {}, textContent: '', innerHTML: '' };
  e.setAttribute = (k, v) => { e.attrs[k] = v; };
  e.addEventListener = (type, f) => (e.listeners[type] = e.listeners[type] || []).push(f);
  e.getBoundingClientRect = () => e.rect || { left: 0, top: 0, width: 300, height: 214 };
  return e;
}
function open() {
  const page = { root: el('html'), messages: [], parent: { other: true } };
  fx.ids.forEach((id) => { page[id] = el('x'); });
  page['sfw-config'].textContent = config;
  const doc = { getElementById: (id) => page[id], documentElement: page.root };
  const win = { addEventListener: (type, f) => page.messages.push(f) };
  new Function('document', 'window', 'parent', code)(doc, win, page.parent);
  return page;
}
const fire = (e, type, extra) => (e.listeners[type || 'click'] || []).forEach((f) => f({ target: e, ...extra }));
const bad = [];
const want = (what, got, expected) => { if (JSON.stringify(got) !== JSON.stringify(expected)) bad.push([what, got, expected]); };
const near = (what, got, expected) => { if (!(Math.abs(Number(got) - expected) < 0.06)) bad.push([what, got, expected]); };
const count = (text, needle) => text.split(needle).length - 1;
const group = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
const spoken = (v, read) => v.says + ' ' + v.precision + ' ' + read;
function look(page, pct, rare, pick, what) {          // compares the page with what Python expects at one threshold and one choice
  const v = fx.views[String(rare)][String(pct)], x = 10 + (pct / 100) * 280, line = page['sfw-line'].attrs, shade = page['sfw-shade'].attrs;
  want(what + ': sentence', page['sfw-says'].textContent, v.says);
  want(what + ': threshold text', page['sfw-t'].textContent, fx.texts[String(pct)]);
  want(what + ': counts', [page['sfw-caught'], page['sfw-missed'], page['sfw-false'], page['sfw-passed']].map((e) => e.textContent), v.counts.map(group));
  want(what + ': precision line', page['sfw-prec'].textContent, v.precision);
  want(what + ': readout', page['sfw-read'].textContent, fx.readouts[String(pct)][pick]);
  near(what + ': line x1', line.x1, x); near(what + ': line x2', line.x2, x); near(what + ': shade x', shade.x, x); near(what + ': shade width', shade.width, 290 - x);
  const marks = page['sfw-marks'].innerHTML;
  want(what + ': mark counts', [count(marks, 'class="sfw-h"'), count(marks, 'class="sfw-l"'), count(marks, 'class="sfw-pick"')], [fx.harmful, fx.harmless, 1]);
  want(what + ': rings are the mistakes on the test messages', count(marks, 'class="sfw-ring"'), fx.mistakes[String(pct)]);
  const m = fx.messages[pick], at = marks.match(/<(?:rect|circle) class="sfw-pick"([^>]*)>/)[1];
  if (m.h) { near(what + ': pick x', Number(at.match(/ x="([\d.-]+)"/)[1]) + 7, fx.xs[pick]); near(what + ': pick y', Number(at.match(/ y="([\d.-]+)"/)[1]) + 7, fx.ys[pick]); }
  else { near(what + ': pick cx', at.match(/cx="([\d.-]+)"/)[1], fx.xs[pick]); near(what + ': pick cy', at.match(/cy="([\d.-]+)"/)[1], fx.ys[pick]); }
}

const first = open();
want('slider at the opening', [first['sfw-th'].value, first['sfw-rare'].checked, first['sfw-live'].textContent], [String(fx.openingPct), false, '']);
look(first, fx.openingPct, false, fx.openingPick, 'opening state');

// every threshold, with the box off and on, by dragging (input event, silent) and by releasing (change event, spoken)
[false, true].forEach((rare) => {
  const page = open();
  page['sfw-rare'].checked = rare;
  for (let pct = 1; pct <= 99; pct++) {
    page['sfw-th'].value = String(pct);
    page['sfw-live'].textContent = '';
    fire(page['sfw-th'], 'input');
    look(page, pct, rare, fx.openingPick, 'pct ' + pct + ' rare ' + rare);
    want('dragging stays silent at ' + pct, page['sfw-live'].textContent, '');
    want('slider reads out', page['sfw-th'].attrs['aria-valuetext'], 'Threshold ' + fx.texts[String(pct)]);
    fire(page['sfw-th'], 'change');
    want('release speaks at ' + pct, page['sfw-live'].textContent, spoken(fx.views[String(rare)][String(pct)], fx.readouts[String(pct)][fx.openingPick]));
  }
});

// ticking the box recomputes at the current threshold and speaks
const ticked = open();
ticked['sfw-rare'].checked = true; fire(ticked['sfw-rare'], 'change');
look(ticked, fx.openingPct, true, fx.openingPick, 'box ticked');
want('box ticked speaks', ticked['sfw-live'].textContent, spoken(fx.views.true[String(fx.openingPct)], fx.readouts[String(fx.openingPct)][fx.openingPick]));

// the two buttons walk the messages in score order and wrap round at either end
const walk = open(), n = fx.messages.length;
fire(walk['sfw-higher']);
look(walk, fx.openingPct, false, fx.openingPick + 1, 'higher score');
fire(walk['sfw-lower']); fire(walk['sfw-lower']);
look(walk, fx.openingPct, false, fx.openingPick - 1, 'lower score');
for (let i = 1; i < fx.openingPick; i++) fire(walk['sfw-lower']);       // from one below the opening choice down to the first
look(walk, fx.openingPct, false, 0, 'lowest message');
fire(walk['sfw-lower']);
look(walk, fx.openingPct, false, n - 1, 'lower than the lowest wraps to the highest');
fire(walk['sfw-higher']);
look(walk, fx.openingPct, false, 0, 'higher than the highest wraps to the lowest');
want('a button press speaks', walk['sfw-live'].textContent, spoken(fx.views.false[String(fx.openingPct)], fx.readouts[String(fx.openingPct)][0]));

// tapping the plot picks the nearest mark, at the plot's own size and at another size and place on the page
fx.isolated.forEach((i) => {
  [{ left: 0, top: 0, width: 300, height: 214 }, { left: 20, top: 10, width: 600, height: 428 }].forEach((rect) => {
    const page = open(), k = rect.width / 300;
    page['sfw-plot'].rect = rect;
    fire(page['sfw-plot'], 'click', { clientX: rect.left + fx.xs[i] * k, clientY: rect.top + fx.ys[i] * k });
    look(page, fx.openingPct, false, i, 'tap on message ' + i + ' at width ' + rect.width);
  });
});
const off = open();
fire(off['sfw-plot'], 'click', { clientX: fx.offTap[0], clientY: fx.offTap[1] });
look(off, fx.openingPct, false, fx.offTap[2], 'a tap between the rows picks the nearest mark');

// the host page theme: only the parent window may set it, and only to light or dark
const themed = open();
const send = (data, source) => themed.messages.forEach((f) => f({ data, source }));
send({ type: 'spicyshark:theme', theme: 'dark' }, { other: false });
send({ type: 'spicyshark:theme', theme: 'purple' }, themed.parent);
send({ type: 'other', theme: 'dark' }, themed.parent);
send(null, themed.parent);
want('ignored messages', themed.root.attrs, {});
send({ type: 'spicyshark:theme', theme: 'dark' }, themed.parent);
want('theme from the parent', themed.root.attrs, { 'data-theme': 'dark' });
send({ type: 'spicyshark:theme', theme: 'light' }, themed.parent);
want('theme back to light', themed.root.attrs, { 'data-theme': 'light' });

console.log(JSON.stringify({ updates: 198, bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""


def xy(m):
    """Where the widget draws a message, in drawing units."""
    return GEO["left"] + m["s"] * GEO["span"], GEO["lanes"][0 if m["h"] else 1] + m["y"]


def nearest(x, y):
    return min(range(len(MSGS)), key=lambda i: (xy(MSGS[i])[0] - x) ** 2 + (xy(MSGS[i])[1] - y) ** 2)


def isolated():
    """Messages at least 7 units from every other mark: a tap on one of them can only mean it."""
    pts = [xy(m) for m in MSGS]
    return [i for i, (x, y) in enumerate(pts) if all(((x - u) ** 2 + (y - v) ** 2) ** 0.5 >= 7 for j, (u, v) in enumerate(pts) if j != i)]


def views():
    return {str(rare).lower(): {str(p): sf.view(LABELS, SCORES, p, rare) for p in THRESHOLDS} for rare in (False, True)}


def readouts():
    return {str(p): [sf.readout(m["t"], m["h"], m["s"], p) for m in MSGS] for p in THRESHOLDS}


def run_node(tmp_path, script, fixture, args):
    (tmp_path / "fixture.json").write_text(json.dumps(fixture), encoding="utf-8")
    (tmp_path / "check.mjs").write_text(script, encoding="utf-8")
    run = subprocess.run(["node", str(tmp_path / "check.mjs"), *args(tmp_path)], capture_output=True, text=True, timeout=120)
    assert run.returncode == 0, run.stdout + run.stderr
    return json.loads(run.stdout)


def test_widget_data_block_matches_the_python_model():
    """Guards a change to the data or the filter that was not copied into the widget (run embed_widget.py)."""
    assert embed(HTML) == HTML


def test_embed_rewrites_a_widget_whose_data_block_drifted():
    """Guards embed() skipping the block: a hand-edited score or opening threshold must be put back."""
    drifted = HTML.replace('"pct": 50', '"pct": 30', 1).replace(f'"s": {MSGS[0]["s"]}', '"s": 0.999', 1)
    assert drifted != HTML and embed(drifted) == HTML
    assert json.loads(CONFIG_BLOCK.search(HTML).group(2)) == json.loads(json.dumps(CONFIG))


def test_every_element_the_script_looks_up_exists_inside_the_root():
    """Guards a widget that never starts: the parity run covers only the counting, so a misspelt id passed there and threw in the browser."""
    script = HTML[HTML.index("<script>"):]
    lookups = set(re.findall(r"\$\('([\w-]+)'\)", script))
    assert "sfw-th" in lookups and "sfw-marks" in lookups
    missing = sorted(i for i in lookups if i != "sfw-config" and f'id="{i}"' not in ROOT)
    assert not missing, missing
    assert 'id="sfw-config"' in HTML


def test_widget_is_small_and_asks_for_nothing_online():
    assert len(HTML.splitlines()) <= 300 and len(HTML.encode("utf-8")) < 40 * 1024
    page = CONFIG_BLOCK.sub("", HTML)
    for banned in ("http://", "https://", "fetch(", "XMLHttpRequest", "WebSocket", "sendBeacon", "import(", "createElementNS"):
        assert banned not in page, banned
    assert " src=" not in page and "@import" not in page


def test_widget_has_no_form_element():
    """Guards a dead button: the lesson page loads widget.html in a sandboxed frame, where a form cannot submit."""
    assert "<form" not in HTML and 'type="submit"' not in HTML


def test_widget_shows_at_most_five_controls_and_hides_the_rest():
    """Guards the first screen at 390 px: the slider and two buttons show, and the checkbox stays in a closed details element."""
    outside = re.sub(r"<details>.*?</details>", "", ROOT, flags=re.S)
    assert len(re.findall(r"<(?:select|button|input|textarea)\b", outside)) <= 5
    assert "<details><summary>More settings</summary>" in ROOT and "<details open" not in ROOT
    assert re.search(r"<details><summary>More settings</summary>.*?type=\"checkbox\"", ROOT, re.S)


def test_widget_keeps_the_site_theme_listener_and_palette():
    assert "d.type !== 'spicyshark:theme'" in HTML and "e.source !== parent" in HTML and "parent === window" in HTML
    for pair in ("--bg: light-dark(#ffffff, #0a0f17)", "--panel: light-dark(#ebf0f6, #131b28)", "--ink: light-dark(#0a1626, #f4f7fb)",
                 "--mute: light-dark(#44566b, #a3b1c4)", "--ctl: light-dark(#66768a, #7a8a9f)", "--ink-on: light-dark(#f6f8fb, #05070c)",
                 "--green: light-dark(#00855F, #009E73)", "--orange: light-dark(#b36200, #E69F00)", ":root[data-theme=light]", ":root[data-theme=dark]"):
        assert pair in HTML, pair
    for banned in ("#0072B2", "#56B4E9", "#D55E00"):
        assert banned.lower() not in HTML.lower()
    assert not set(re.findall(r"#[0-9a-fA-F]{3,8}\b", HTML)) - {"#ffffff", "#0a0f17", "#ebf0f6", "#131b28", "#0a1626", "#f4f7fb", "#44566b", "#a3b1c4",
                                                               "#66768a", "#7a8a9f", "#f6f8fb", "#05070c", "#00855F", "#009E73", "#b36200", "#E69F00"}


def test_widget_scores_are_the_filters_scores_and_the_marks_do_not_hide_a_row():
    """Guards the plot: every test message is in the data once, scores are what safety_filter.py computed, and no mark leaves its row."""
    r = sf.build()
    assert sorted(zip(SCORES, LABELS)) == sorted(zip(r["scores"], r["labels"]))
    assert len(MSGS) == 120 and sum(LABELS) == 40 and SCORES == sorted(SCORES)
    assert all(abs(m["y"]) <= GEO["half"] for m in MSGS) and 0 <= min(SCORES) and max(SCORES) <= 1
    assert len(isolated()) >= 8                                           # enough separate marks to test tapping


def test_widget_text_keeps_the_caveat_and_the_numbers_it_names():
    """Guards the words that keep a toy from reading as a measurement: they are visible without opening anything, and the
    More settings note names the traffic the code computes."""
    outside = re.sub(r"<details>.*?</details>", "", ROOT, flags=re.S)
    assert "A toy: made-up messages that ask for or share contact details. Real filters score many kinds of harm, each against its own threshold." in outside
    assert "Square = harmful, circle = harmless, ring = a mistake, bold outline = the one you picked." in outside
    assert 'id="sfw-pickl">Read another message:</p>' in outside and 'role="group" aria-labelledby="sfw-pickl"' in outside      # the two buttons are named on screen
    assert 'class="sfw-sr" id="sfw-live" role="status" aria-live="polite" aria-atomic="true"' in ROOT      # what a screen reader hears
    assert ".sfw-root .sfw-btns button { flex: 1 1 auto; white-space: nowrap; }" in HTML      # a button wraps to the next row, never its label
    assert "paint-order: stroke" in HTML and HTML.count('class="sfw-tag"') == 2               # the row labels keep a halo, so the threshold line never cuts them
    more = re.search(r"<details><summary>More settings</summary>(.*?)</details>", ROOT, re.S).group(1)
    assert f"{sf.TRAFFIC:,} messages, {sf.TRAFFIC_HARMFUL} of them harmful" in more and "1 in 100 messages is harmful" in more
    assert "They are expected counts, not measured ones." in more and f"{LABELS.count(0)} harmless messages blocked is not a rate of 0 in real traffic" in more
    assert sf.TRAFFIC == 100 * sf.TRAFFIC_HARMFUL


def test_widget_opens_on_a_mistake_the_lesson_names():
    """Guards the first screen: at the opening threshold the chosen message is the harmless one the filter blocks, and the
    sentence already shows the lesson's opening counts."""
    opening = MSGS[OPENING]
    assert CONFIG["opening"]["pct"] == sf.OPENING_PCT == 50 and opening["t"].endswith(sf.FALSE_ALARM_EXAMPLE) and opening["h"] == 0
    assert sf.readout(opening["t"], opening["h"], opening["s"], 50).endswith("so it is blocked: a false alarm.")
    assert sf.view(LABELS, SCORES, 50)["says"] == "At 0.50 the filter misses 5 harmful messages and blocks 1 harmless message."
    rare = sf.view(LABELS, SCORES, 50, True)                                  # expected counts, so the sentences say "about"
    assert rare["says"] == "Out of 10,000 messages, 100 harmful: at 0.50 the filter would miss about 12 and block about 124 harmless messages."
    assert rare["precision"] == "Of the 212 or so messages it would block, about 88 are harmful (42%)."


def test_widget_counting_gives_the_numbers_and_words_of_the_python_model(tmp_path):
    """Guards the widget's JavaScript drifting from safety_filter.py: the lesson prints Python's numbers and the reader sees the
    widget's, so every sentence, count, percentage and readout must agree at all 99 thresholds, in both traffic mixes."""
    fixture = {"views": views(), "readouts": readouts(), "commas": [[n, f"{n:,}"] for n in COMMAS], "shares": [[p, w, sf.share(p, w)] for p, w in SHARES]}
    result = run_node(tmp_path, PARITY, fixture, lambda d: [str(d / "fixture.json"), str(WIDGET)])
    assert result["bad"] == [] and result["checks"] == 2 * 99 + 99 * 120 + len(COMMAS) + len(SHARES)
    exact = [i for i, s in enumerate(SCORES) if round(s * 100) == s * 100 and 1 <= round(s * 100) <= 99]
    assert exact                                                          # a score equal to a threshold is blocked, in both languages


def test_widget_page_shows_what_the_lesson_names(tmp_path):
    """Guards the part a reader sees: the opening state, every threshold with the box off and on, what is spoken on release, the
    two buttons and their wrap-around, a tap on a mark, and the theme message."""
    ids = sorted(set(re.findall(r'id="(sfw-[\w-]+)"', HTML)))
    taps = isolated()
    x, y = 150.0, 104.0
    fixture = {"ids": ids, "messages": MSGS, "views": views(), "readouts": readouts(), "openingPct": sf.OPENING_PCT, "openingPick": OPENING,
               "texts": {str(p): f"0.{p:02d}" for p in THRESHOLDS}, "harmful": sum(LABELS), "harmless": len(LABELS) - sum(LABELS),
               "mistakes": {str(p): sum(sf.counts(LABELS, SCORES, p)[1:3]) for p in THRESHOLDS},
               "xs": [xy(m)[0] for m in MSGS], "ys": [xy(m)[1] for m in MSGS], "isolated": taps, "offTap": [x, y, nearest(x, y)]}
    result = run_node(tmp_path, PAGE, fixture, lambda d: [str(WIDGET), str(d / "fixture.json")])
    assert result == {"updates": 198, "bad": []}
