"""Guards for ../widget.html: its table of numbers, that its words and chart equal show.py's for every setting, that the states the lesson names are reachable and right
against pd_sim.py itself, and what a reader sees and can press. Run from this folder: python3 -B -m pytest -q -p no:cacheprovider"""
import json
import re
import subprocess
from itertools import product

import pd_sim as sim
import show
from embed_widget import CONFIG_BLOCK, WIDGET, embed

HTML = WIDGET.read_text(encoding="utf-8")
NAMES = ("rps", "prompt", "link", "reply", "first", "gap")
STOPS = {"rps": show.RPS, "prompt": show.PROMPTS, "link": show.LINKS, "reply": show.REPLIES, "first": show.FIRST_TARGETS, "gap": show.GAP_TARGETS}
# Every setting the six sliders offer, as the stop each is on.
GRID = list(product(*(range(len(STOPS[name])) for name in NAMES)))

# Reads the model and the picture out of the widget between its marker comments, runs every setting with Node and compares the words and counts with show.py, exactly. For
# each setting it also reads the chart back: the key sits above everything in the plot, nothing leaves the viewBox, and the marks for the link in use sit on the lines.
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="pd-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, chart};')(cfg);
const bad = [];
const note = (args, what, got, want) => { if (bad.length < 3) bad.push([args.join('/'), what, JSON.stringify(got).slice(0, 300), JSON.stringify(want).slice(0, 300)]); };
const pairs = (d) => [...d.matchAll(/[ML]([\d.]+) ([\d.]+)/g)].map((m) => [+m[1], +m[2]]);
// The box a group of marks covers, in SVG units. Text is a guess: 15 units tall, 8.25 wide a character, placed by its anchor.
function box(group) {
  const b = { top: 1e9, bottom: -1e9, left: 1e9, right: -1e9 };
  const grow = (x0, y0, x1, y1) => { b.left = Math.min(b.left, x0); b.right = Math.max(b.right, x1); b.top = Math.min(b.top, y0); b.bottom = Math.max(b.bottom, y1); };
  for (const m of group.matchAll(/<text class="[^"]*" x="([\d.]+)" y="([\d.]+)"(?: text-anchor="(\w+)")?>([^<]*)<\/text>/g)) {
    const w = 8.25 * m[4].length, x = +m[1];
    const [x0, x1] = m[3] === 'middle' ? [x - w / 2, x + w / 2] : m[3] === 'end' ? [x - w, x] : [x, x + w];
    grow(x0, +m[2] - 11, x1, +m[2] + 4);
  }
  for (const m of group.matchAll(/<circle cx="([\d.]+)" cy="([\d.]+)" r="([\d.]+)"/g)) grow(+m[1] - +m[3], +m[2] - +m[3], +m[1] + +m[3], +m[2] + +m[3]);
  for (const m of group.matchAll(/<rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)) grow(+m[1], +m[2], +m[1] + +m[3], +m[2] + +m[4]);
  for (const m of group.matchAll(/<path d="([^"]+)"/g)) pairs(m[1]).forEach(([x, y]) => grow(x, y, x, y));
  return b;
}
const near = (p, q) => Math.abs(p[0] - q[0]) < 0.15 && Math.abs(p[1] - q[1]) < 0.15;
fx.cases.forEach((c) => {
  const got = api.describe(...c.args);
  if (JSON.stringify(got) !== JSON.stringify(c.want)) return note(c.args, 'words', got, c.want);
  const svg = api.chart(got, c.args[2]);
  const [w, h] = svg.match(/viewBox="0 0 (\d+) (\d+)"/).slice(1).map(Number);
  const key = box(svg.match(/<g class="pd-key">(.*?)<\/g>/)[1]), plot = svg.match(/<g class="pd-plot">(.*?)<\/g><\/svg>/)[1], p = box(plot);
  if (!(key.bottom + 4 < p.top)) note(c.args, 'the key reaches the plot', key, p);
  if (Math.min(key.left, p.left, key.top, p.top) < 0 || Math.max(key.right, p.right) > w || Math.max(key.bottom, p.bottom) > h) note(c.args, 'outside the viewBox', [w, h], [key, p]);
  const line = (color) => plot.match(new RegExp('<path d="M([^"]+)" fill="none" stroke="var\\(--' + color + '\\)"'))[1].split('L').map((q) => q.split(' ').map(Number));
  const green = line('green'), orange = line('orange'), n = cfg.link.length;
  const square = plot.match(/<rect x="([\d.]+)" y="([\d.]+)" width="11.0"/), dot = plot.match(/<circle cx="([\d.]+)" cy="([\d.]+)" r="6"/);
  const k = c.args[2], ok = green.length === n && orange.length === n && orange.every((q) => q[1] === orange[0][1]) && square && dot
    && near([+square[1] + 5.5, +square[2] + 5.5], green[k]) && near([+dot[1], +dot[2]], orange[k])
    && (plot.match(/width="6.0"/g) || []).length === n && (plot.match(/r="3.5"/g) || []).length === n;
  if (!ok) note(c.args, 'marks are off the lines', green, orange);
});
console.log(JSON.stringify({ checks: fx.cases.length, bad }));
process.exit(bad.length ? 1 : 0);
"""

# Runs the page script against a small stand-in for the DOM, moves the sliders as a reader would and reads back what the page shows.
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="pd-config">([\s\S]*?)<\/script>/)[1];
const code = html.match(/<script>([\s\S]*?)<\/script>/)[1];
function el(tag) {
  const e = { tag, listeners: {}, value: '', attrs: {}, textContent: '', innerHTML: '' };
  e.setAttribute = (k, v) => { e.attrs[k] = v; };
  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="(pd-[^"]+)"/g)].forEach((m) => { page[m[1]] = el('x'); });
page['pd-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);
const names = ['rps', 'prompt', 'link', 'reply', 'first', 'gap'];
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 = () => [page['pd-line'].textContent, page['pd-rule'].textContent, page['pd-tails'].textContent];
const set = (stops) => names.forEach((n, i) => { page['pd-' + n].value = String(stops[i]); });
want('opening controls', names.map((n) => page['pd-' + n].value), fx.opening.controls);
want('opening state', shown(), fx.opening.shown);
want('nothing spoken yet', page['pd-live'].textContent, '');
want('opening chart', page['pd-chart'].innerHTML.match(/<path d="M306\.0 82L/) !== null && /viewBox="0 0 320 244"/.test(page['pd-chart'].innerHTML), true);
fx.states.forEach((s) => {   // the states the lesson tells the reader to reach, worked out from pd_sim.py itself
  set(s.controls);
  fire(page['pd-' + s.moved], 'input');
  want('state ' + s.moved, page['pd-line'].textContent, s.head);
});
set(fx.dragged.controls);
fire(page['pd-rps'], 'input');
want('dragging shows the new state', shown(), fx.dragged.shown);
want('dragging stays silent', page['pd-live'].textContent, '');
fire(page['pd-rps'], 'change');
want('release speaks', page['pd-live'].textContent, fx.dragged.shown[0]);
want('words on the controls', names.map((n) => page['pd-' + n + '-out'].textContent).concat(names.map((n) => page['pd-' + n].attrs['aria-valuetext'])), fx.dragged.words);
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 sentence(rps, prompt, link, reply=200):
    """The top line the page must show, worked out from pd_sim.py without show.py's table."""
    got = sim.compare(rps, prompt, reply, link)
    a, b = f"{got['shared']['goodput']:.1%}", f"{got['split']['goodput']:.1%}"
    return f"Both layouts get {a} of requests right." if a == b else f"Shared GPUs get {a} of requests right and the split gets {b}."


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(show.widget_config()))
    for name in NAMES:                                                                                  # each slider walks the stops the table covers and starts on the opening one
        assert re.search(r'id="pd-%s" min="0" max="%d" step="1" value="%d"' % (name, len(STOPS[name]) - 1, show.OPENING[name]), HTML), name
    assert [STOPS[name][show.OPENING[name]] for name in NAMES] == [4, 2000, 25, 200, sim.FIRST_WORD_TARGET_S, sim.WORD_GAP_TARGET_S]      # the opening is the toy's default
    requests = sim.make_requests(4, 2000, 200)                                                          # show.py counts good requests itself: it must agree with pd_sim.goodput
    for run, summary in ((sim.shared(requests), show.table()[1][2][1]["shared"]), (sim.split(requests, 0.25), show.table()[1][2][1]["split"][0])):
        assert summary["good"] == [round(sim.goodput(requests, run, f, g) * sim.N_REQUESTS) for f in show.FIRST_TARGETS for g in show.GAP_TARGETS]
        assert (summary["first"], summary["gap"]) == tuple(round(sim.measure(requests, run)[key] * 1000, 1) for key in ("first_p99", "gap_p99"))


def test_widget_gives_the_same_words_and_a_clean_chart_as_python_for_every_setting(tmp_path):
    cases = [{"args": list(g), "want": show.describe(*g)} for g in GRID]
    heads = " ".join(c["want"]["head"] for c in cases)
    assert "Both layouts get" in heads and "Shared GPUs get" in heads and "the split gets 0.0%" in heads    # the grid reaches every kind of sentence
    (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_opens_on_the_point_and_reaches_the_states_the_lesson_names(tmp_path):
    opening = [show.OPENING[name] for name in NAMES]
    head = show.describe(*opening)["head"]
    assert head == "Shared GPUs get 13.5% of requests right and the split gets 98.7%." == sentence(4, 2000, 25)      # the first screen makes the point
    states = [{"moved": "rps", "controls": [2, 2, 5, 1, 1, 1], "head": sentence(10, 2000, 25)},              # Requests a second 10
              {"moved": "link", "controls": [1, 2, 0, 1, 1, 1], "head": sentence(4, 2000, 0.25)},            # Link speed 0.25 GB/s
              {"moved": "prompt", "controls": [1, 0, 5, 1, 1, 1], "head": sentence(4, 200, 25)}]             # Prompt tokens 200
    assert "1.7% of requests right and the split gets 32.2%" in states[0]["head"] and states[1]["head"].endswith("the split gets 0.0%.") and states[2]["head"] == "Both layouts get 100.0% of requests right."
    dragged = (2, 1, 3, 2, 0, 2)
    words = ["10", "1000", "2.5 GB/s", "400", "0.25 s", "0.2 s", "10 requests a second", "1000 tokens in a prompt", "link speed 2.5 gigabytes a second", "replies of 400 words",
             "first word within 0.25 seconds", "no gap between words over 0.2 seconds"]
    fixture = {"opening": {"controls": [str(v) for v in opening], "shown": [show.describe(*opening)[key] for key in ("head", "rule", "tails")]}, "states": states,
               "dragged": {"controls": list(dragged), "shown": [show.describe(*dragged)[key] for key in ("head", "rule", "tails")], "words": words}}
    (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="pd-root">'):HTML.index("<script")]
    visible = re.sub(r"<details.*?</details>", "", body, flags=re.S)
    assert len(re.findall(r"<input|<select|<button", visible)) == 3 and "More settings" in body and "Toy values and 600 invented requests" in visible   # three controls on show, caveat always on show
    longest = {"rps": "10", "prompt": "2000", "link": "12.5 GB/s", "reply": "400", "first": "0.25 s", "gap": "0.05 s"}
    labels = re.findall(r'<label class="pd-lab" for="pd-(\w+)">(.*?)</label>', body)
    assert [name for name, _ in labels] == list(NAMES) and [re.sub(r"<[^>]+>", "", text).split(":")[0] for name, text in labels[:3]] == ["Requests a second", "Prompt tokens", "Link speed"]
    assert all(f'for="pd-{name}"' in visible for name in NAMES[:3]) and all(f'for="pd-{name}"' in body[body.index("<details"):] for name in NAMES[3:])     # the first three are on show, the rest are in More settings
    for name, label in labels:                                                                          # a label and its longest value fit one line at 340 px
        text = re.sub(r"<[^>]+>", "", label) + longest[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 "forced-colors: active" in HTML and 'role="status" aria-live="polite"' in HTML
    assert not re.search(r"0072B2|56B4E9|D55E00|ht" r"tps?://|<img|@import", HTML, re.I)
    sizes = [int(px) for px in re.findall(r"\.pd-[kt] \{ font: (?:600 )?(\d+)px", HTML)]
    assert len(sizes) == 2 and min(sizes) >= 15 and 'viewBox="0 0 320 ' in HTML                         # chart text 15 units or more of a 320-unit chart: about 12.5 px in the 290 px frame at 340
