"""Guards for ../widget.html: its data block, that its server gives the same numbers and words as tiny_server.py and show.py, and what a reader sees and can press."""
import json
import re
import subprocess

import show
from embed_widget import CONFIG_BLOCK, WIDGET, config, embed

HTML = WIDGET.read_text(encoding="utf-8")
# Every setting the three sliders offer, in the opening order of the chats; every other order at the opening, at the settings the lesson walks through and at the two light
# settings that test the wall floor (10 a second: 240 pages and 8 chats name no wall, 120 pages and 16 chats name the cache).
GRID = [(r, p, l, 0) for r in show.RATES for p in show.PAGE_STOPS for l in show.LIMIT_STOPS]
GRID += [(show.OPENING["rate"], p, l, k) for p, l in [(show.OPENING["pages"], show.OPENING["limit"]), *show.WALL_MOVES] for k in range(1, len(show.REROLLS))]
GRID += [(show.RATES[0], p, l, k) for p, l in [(240, 8), (120, 16)] for k in range(1, len(show.REROLLS))]

# Reads the model out of the widget between its marker comments, runs every setting of the grid with Node and compares everything the page shows with what Python wrote
# into the fixture: the words, the table, the chart columns and the numbers behind them, exactly.
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="ts-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 {describe};')(cfg);
const bad = [];
fx.cases.forEach((c) => {
  const got = api.describe(...c.args);
  if (JSON.stringify(got) !== JSON.stringify(c.want)) bad.push([c.args, JSON.stringify(got).slice(0, 300), JSON.stringify(c.want).slice(0, 300)]);
});
console.log(JSON.stringify({ checks: fx.cases.length, bad: bad.slice(0, 3) }));
process.exit(bad.length ? 1 : 0);
"""

# Runs the page script against a small stand-in for the DOM, moves the sliders, shuffles the chats 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="ts-config">([\s\S]*?)<\/script>/)[1];
const code = html.match(/<script>([\s\S]*?)<\/script>/)[1];
function el(tag) {
  const e = { tag, children: [], listeners: {}, value: '', style: {}, attrs: {}, textContent: '', innerHTML: '' };
  e.setAttribute = (k, v) => { e.attrs[k] = v; };
  e.getAttribute = (k) => e.attrs[k];
  e.addEventListener = (type, f) => (e.listeners[type] = e.listeners[type] || []).push(f);
  return e;
}
const page = { root: el('html'), messages: [], parent: { other: true } };
[...html.matchAll(/id="(ts-[^"]+)"/g)].forEach((m) => { page[m[1]] = el('x'); });
page['ts-config'].textContent = config;
const doc = { getElementById: (id) => page[id], documentElement: page.root, createElement: el };
const win = { addEventListener: (type, f) => page.messages.push(f) };
new Function('document', 'window', 'parent', code)(doc, win, page.parent);
const fire = (e, type) => (e.listeners[type] || []).forEach((f) => f({ target: e }));
const bad = [];
const want = (what, got, expected) => { if (JSON.stringify(got) !== JSON.stringify(expected)) bad.push([what, JSON.stringify(got).slice(0, 300), JSON.stringify(expected).slice(0, 300)]); };
const shown = () => ({ line: page['ts-line'].textContent, table: [0, 1].map((k) => [page['ts-a' + k].textContent, page['ts-b' + k].textContent]),
  lines: [page['ts-q'].textContent, page['ts-p'].textContent, page['ts-w'].textContent] });
const cfg = JSON.parse(config);
want('opening controls', [page['ts-rate'].value, page['ts-pages'].value, page['ts-limit'].value],
  [String(cfg.rates.indexOf(cfg.opening.rate)), String(cfg.pageStops.indexOf(cfg.opening.pages)), String(cfg.limitStops.indexOf(cfg.opening.limit))]);
want('opening state', shown(), fx.opening);
want('nothing spoken yet', page['ts-live'].textContent, '');
if (!page['ts-chart'].innerHTML.includes('cache full') || !page['ts-chart'].innerHTML.includes('Cache pages promised, ceiling ' + cfg.opening.pages)) bad.push(['opening chart', page['ts-chart'].innerHTML.slice(0, 200), 'cache full']);
// The picture itself, read back from the SVG text: the tallest promised bar touches the ceiling line, the queue axis ends at the longest queue and its tallest bar fills it,
// the "cache full" label sits on its green line and clear of the chart title, and nothing is drawn past the right or bottom edge of the viewBox.
const svg = page['ts-chart'].innerHTML;
const rects = [...svg.matchAll(/<rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)" fill="var\(--(ink|mute)\)"\/>/g)].map((m) => ({ x: +m[1], y: +m[2], w: +m[3], h: +m[4], kind: m[5] }));
const grab = (re) => { const m = svg.match(re); return m ? m.slice(1).map(Number) : [NaN, NaN, NaN]; };
const [, ceilingY, ceilingEnd] = grab(/<path d="M([\d.]+) ([\d.]+)H([\d.]+)" stroke="var\(--orange\)"/);
const [markX] = grab(/<path d="M([\d.]+) [\d.]+V[\d.]+" stroke="var\(--green\)"/);
const [labelX, labelY] = grab(/<text class="ts-t" x="([\d.]+)" y="([\d.]+)">cache full<\/text>/);
const [, titleY] = grab(/<text class="ts-t" x="(2)" y="([\d.]+)">Cache pages promised/);
const [, viewWidth, viewHeight] = grab(/viewBox="(0) 0 (\d+) (\d+)"/);
const lowest = Math.max(...rects.map((r) => r.y + r.h), ...[...svg.matchAll(/<text [^>]*? y="([\d.]+)"/g)].map((m) => +m[1]));
const axis = [...svg.matchAll(/<text class="ts-k" x="38" y="([\d.]+)" text-anchor="end">(\d+)<\/text>/g)].map((m) => [+m[1], m[2]]);
const topOf = (kind) => Math.min(...rects.filter((r) => r.kind === kind).map((r) => r.y));
const queueTop = axis.length === 4 ? axis[2][0] - 4 : NaN;
want('chart', { found: [ceilingY, ceilingEnd, markX, labelX, titleY, viewWidth, viewHeight, queueTop].every(Number.isFinite), ceiling: ceilingY, promisedTop: topOf('ink'), pagesLabel: axis[0] && axis[0][1], queueTop: topOf('mute'), queueAxis: [queueTop, axis[2] && axis[2][1]],
  label: [labelX, labelY], clear: labelY - titleY >= 20, inside: Math.max(...rects.map((r) => r.x + r.w)) <= Math.min(ceilingEnd, viewWidth) && lowest <= viewHeight },
{ found: true, ceiling: ceilingY, promisedTop: ceilingY, pagesLabel: String(cfg.opening.pages), queueTop: queueTop, queueAxis: [queueTop, String(Math.max(10, fx.chart.queueMax))],
  label: [markX + 4, ceilingY - 6], clear: true, inside: true });
page['ts-rate'].value = '0';
fire(page['ts-rate'], 'input');
want('light rate, dragging', shown(), fx.light);
want('dragging stays silent', page['ts-live'].textContent, '');
if (page['ts-chart'].innerHTML.includes('cache full')) bad.push(['light chart', 'has a cache full mark', 'none']);
fire(page['ts-rate'], 'change');
want('release speaks', page['ts-live'].textContent, fx.light.line + ' ' + fx.light.lines[2]);
want('words on the controls', [page['ts-rate-out'].textContent, page['ts-pages-out'].textContent, page['ts-limit-out'].textContent].map(String).concat([page['ts-rate'].attrs['aria-valuetext'],
  page['ts-pages'].attrs['aria-valuetext'], page['ts-limit'].attrs['aria-valuetext']]), ['10', '240', '16', '10 chats a second', '240 cache pages', '16 chats at most']);
fx.others.forEach((c) => {
  page['ts-rate'].value = String(cfg.rates.indexOf(c.args[0])); page['ts-pages'].value = String(cfg.pageStops.indexOf(c.args[1])); page['ts-limit'].value = String(cfg.limitStops.indexOf(c.args[2]));
  fire(page['ts-limit'], 'input');
  want('state ' + c.args.join('/'), shown(), c.want);
});
page['ts-rate'].value = String(cfg.rates.indexOf(cfg.opening.rate)); page['ts-pages'].value = String(cfg.pageStops.indexOf(cfg.opening.pages)); page['ts-limit'].value = String(cfg.limitStops.indexOf(cfg.opening.limit));
fire(page['ts-shuffle'], 'click');
want('shuffle shows the second order', [shown(), page['ts-order'].textContent.slice(0, 30)], [fx.shuffled, 'Same 400 chats in order 2 of ' + cfg.rerolls.length]);
want('shuffle speaks', page['ts-live'].textContent, fx.shuffled.line + ' ' + fx.shuffled.lines[2]);
const theme = (source, name) => page.messages.forEach((f) => f({ data: { type: 'spicyshark:theme', theme: name }, source }));
theme({ other: 'frame' }, 'dark');
want('theme from another sender is ignored', page.root.attrs['data-theme'], undefined);
theme(page.parent, 'dark');
want('theme from the host page', page.root.attrs['data-theme'], 'dark');
console.log(JSON.stringify({ bad: bad.slice(0, 3) }));
process.exit(bad.length ? 1 : 0);
"""


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


def seen(rate, pages, limit, reroll=0):
    """What the page shows for one setting, in the form the PAGE script reads back."""
    d = show.describe(rate, pages, limit, reroll)
    return {"line": d["head"], "table": [row[1:] for row in d["table"]], "lines": d["lines"]}


def test_widget_data_block_matches_the_python_module():
    assert embed(HTML) == HTML
    assert json.loads(CONFIG_BLOCK.search(HTML).group(2)) == json.loads(json.dumps(config()))
    for name, stops in (("rate", show.RATES), ("pages", show.PAGE_STOPS), ("limit", show.LIMIT_STOPS)):     # each slider walks the stops the grid below covers
        assert re.search(r'id="ts-%s" min="0" max="%d" step="1"' % (name, len(stops) - 1), HTML), name


def test_widget_gives_the_same_numbers_and_words_as_python(tmp_path):
    cases = [{"args": list(g), "want": show.describe(*g)} for g in GRID]
    heads = " ".join(c["want"]["head"] for c in cases)
    walls = {c["want"]["lines"][2] for c in cases}
    assert {"Wall: none", "Wall: the cache", "Wall: the batch limit", "Wall: both"} <= walls and "no wall binds" in heads      # the grid reaches every kind of sentence
    wall_at = {tuple(c["args"]): c["want"]["lines"][2] for c in cases}                                                          # the floor: a handful of chats queued names no wall
    assert all(wall_at[(10, 240, 8, k)] == "Wall: none" and wall_at[(10, 120, 16, k)] == "Wall: the cache" for k in range(len(show.REROLLS)))
    assert all(wall_at[(10, p, 8, 0)] == "Wall: none" for p in (240, 480, 960))
    (tmp_path / "fx.json").write_text(json.dumps({"cases": cases}), encoding="utf-8")
    assert run_node(tmp_path, PARITY, str(tmp_path / "fx.json"), str(WIDGET))["checks"] == len(GRID)


def test_widget_page_shows_the_opening_state_and_follows_the_controls(tmp_path):
    opening = seen(**dict(zip(("rate", "pages", "limit"), (show.OPENING[k] for k in ("rate", "pages", "limit")))))
    assert opening["line"] == "At 40\u00a0chats a second the cache fills at 0.39\u00a0s and new chats wait. 1\u00a0chat in 100 waits more than 3.49\u00a0s for its first word."
    others = [(30, 120, 8), (20, 480, 24), (40, 960, 48), (10, 960, 16), (10, 240, 8), (10, 120, 16)]
    fixture = {"opening": opening, "light": seen(10, 240, 16), "shuffled": seen(40, 240, 16, 1), "others": [{"args": list(o), "want": seen(*o)} for o in others],
               "chart": {"queueMax": max(show.describe(*(show.OPENING[k] for k in ("rate", "pages", "limit")))["chart"]["queue"])}}
    (tmp_path / "fx.json").write_text(json.dumps(fixture), encoding="utf-8")
    run_node(tmp_path, PAGE, str(WIDGET), str(tmp_path / "fx.json"))


def test_widget_is_small_themed_and_uses_only_the_site_palette():
    body = HTML[HTML.index('<div class="ts-root">'):HTML.index("<script")]
    visible = re.sub(r"<details.*?</details>", "", body, flags=re.S)
    assert len(re.findall(r"<input|<select|<button", visible)) <= 5 and "More settings" in body and "A toy server and 400 invented chats" in visible   # at most five controls on show, and the caveat is always on show
    stops = {"rate": show.RATES, "pages": show.PAGE_STOPS, "limit": show.LIMIT_STOPS}
    labels = re.findall(r'<label class="ts-lab" for="ts-(\w+)">(.*?)</label>', visible)
    assert [name for name, _ in labels] == list(stops)
    for name, label in labels:                                                                          # a label and its longest value fit one line at 340 px
        text = re.sub(r"<[^>]+>", "", label) + str(max(stops[name]))
        assert len(text) <= 22, text
    assert len(HTML.splitlines()) < 300 and "light-dark(#ffffff, #0a0f17)" in HTML and "spicyshark:theme" in HTML and "prefers-reduced-motion" in HTML
    assert not re.search(r"0072B2|56B4E9|D55E00|ht" r"tps?://|<img|@import", HTML, re.I)
