"""Guards for ../widget.html: its table of numbers, that its words and numbers equal show.py's for every setting, and what a reader sees and can press."""
import json
import re
import subprocess

import show
from embed_widget import CONFIG_BLOCK, WIDGET, embed
from refusal_toy import LAYERS

HTML = WIDGET.read_text(encoding="utf-8")
# Every setting the four sliders offer: share taken out x layer read x second route x request noise.
GRID = [(share, layer, second, noise) for share in show.SHARES for layer in range(LAYERS + 1) for second in range(len(show.SECOND)) for noise in range(len(show.NOISE))]

# Reads the model out of the widget between its marker comments, runs every setting with Node and compares what the page would show with what show.py gives, 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="rf-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 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="rf-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="(rf-[^"]+)"/g)].forEach((m) => { page[m[1]] = el('x'); });
page['rf-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 = () => [page['rf-line'].textContent, page['rf-angle'].textContent];
const set = (share, layer, second, noise) => { page['rf-share'].value = String(share); page['rf-layer'].value = String(layer); page['rf-second'].value = String(second); page['rf-noise'].value = String(noise); };
const cfg = JSON.parse(config);
want('opening controls', [page['rf-share'].value, page['rf-layer'].value, page['rf-second'].value, page['rf-noise'].value], fx.opening.controls);
want('opening state', shown(), fx.opening.shown);
want('nothing spoken yet', page['rf-live'].textContent, '');
// The picture, read back from the SVG text: the current-share mark sits on the right edge of the plot at the opening, the big dot and square sit on the data point the
// line passes through, there is one small mark per share on each line, and nothing is drawn past the edge of the viewBox.
const picture = () => {
  const svg = page['rf-chart'].innerHTML;
  const [, w, h] = svg.match(/viewBox="0 0 (\d+) (\d+)"/).map(Number);
  const line = (color) => svg.match(new RegExp('<path d="M([^"]+)" fill="none" stroke="var\\(--' + color + '\\)"'))[1].split('L').map((p) => p.split(' ').map(Number));
  const mark = svg.match(/<path d="M([\d.]+) 80V190" stroke="var\(--ink\)"/);
  const big = svg.match(/<circle cx="([\d.]+)" cy="([\d.]+)" r="6"/);
  const bigSquare = svg.match(/<rect x="([\d.]+)" y="([\d.]+)" width="11.0"/);
  const coords = [...svg.matchAll(/(?:cx|x)="([\d.]+)"[^>]*?(?:cy|y)="([\d.]+)"/g)].map((m) => [+m[1], +m[2]]);
  return { w, h, orange: line('orange'), green: line('green'), markX: mark && +mark[1], big: big && [+big[1], +big[2]], bigSquare: bigSquare && [Math.round((+bigSquare[1] + 5.5) * 10) / 10, Math.round((+bigSquare[2] + 5.5) * 10) / 10],
    smallDots: (svg.match(/r="3.5"/g) || []).length - 1, smallSquares: (svg.match(/width="6.0"/g) || []).length - 1, inside: coords.every(([x, y]) => x <= w && y <= h) };
};
const seen = (k) => { // the k-th share's point on both lines is where the big marks are
  const p = picture();
  return { size: [p.w, p.h], points: [p.orange.length, p.green.length], marksOnTheLines: JSON.stringify([p.big, p.bigSquare]) === JSON.stringify([p.orange[k], p.green[k]]) && p.markX === p.orange[k][0],
    dots: [p.smallDots, p.smallSquares], inside: p.inside };
};
const all = cfg.shares.length;
want('opening chart', seen(all - 1), { size: [320, 242], points: [all, all], marksOnTheLines: true, dots: [all, all], inside: true });
set(20, 5, 0, 1);
fire(page['rf-share'], 'input');
want('dragging shows the new state', shown(), fx.dragged.shown);
want('chart follows the share', seen(4), { size: [320, 242], points: [all, all], marksOnTheLines: true, dots: [all, all], inside: true });
want('dragging stays silent', page['rf-live'].textContent, '');
fire(page['rf-share'], 'change');
want('release speaks', page['rf-live'].textContent, fx.dragged.shown[0]);
want('words on the controls', [page['rf-share-out'].textContent, page['rf-layer-out'].textContent, page['rf-second-out'].textContent, page['rf-noise-out'].textContent].map(String).concat([page['rf-share'].attrs['aria-valuetext'], page['rf-layer'].attrs['aria-valuetext'], page['rf-second'].attrs['aria-valuetext'], page['rf-noise'].attrs['aria-valuetext']]),
  ['20%', '5', '0%', 'normal', '20 percent of the direction taken out', 'read after layer 5', '0 percent of flagged requests use a second route', 'request noise normal']);
fx.others.forEach((c) => {
  set(...c.args);
  fire(page['rf-noise'], 'input');
  want('state ' + c.args.join('/'), shown(), c.shown);
});
set(0, 5, 0, 1);
fire(page['rf-share'], 'input');
want('nothing taken out', shown()[0].startsWith('Nothing taken out: 100 of 100'), true);
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 shown(*setting):
    """What the page shows for one setting, in the form the PAGE script reads back."""
    d = show.describe(*setting)
    return [d["head"], d["angle"]]


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, count in (("share", len(show.SHARES)), ("layer", LAYERS + 1), ("second", len(show.SECOND)), ("noise", len(show.NOISE))):    # each slider walks the stops the grid covers
        top = {"share": show.SHARES[-1], "layer": LAYERS}.get(name, count - 1)
        step = 5 if name == "share" else 1
        assert re.search(r'id="rf-%s" min="0" max="%d" step="%d"' % (name, top, step), 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)
    assert "Nothing taken out" in heads and "still refused" in heads and ", down from " 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_follows_the_controls(tmp_path):
    opening = (show.OPENING["share"], show.OPENING["layer"], show.OPENING["second"], show.OPENING["noise"])
    head = show.describe(*opening)["head"]
    assert "0 of 100 flagged requests are refused, down from 100" in head and "Plain requests whose answer changes: 1 of 100" in head      # the first screen makes the point
    others = [(50, 5, 0, 1), (100, 0, 0, 1), (100, 5, 2, 1), (100, 5, 0, 2), (100, 5, 0, 0), (100, 6, 1, 1)]
    fixture = {"opening": {"controls": [str(v) for v in opening], "shown": shown(*opening)}, "dragged": {"shown": shown(20, 5, 0, 1)},
               "others": [{"args": list(o), "shown": shown(*o)} for o in others]}
    (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="rf-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 network and invented requests" in visible   # at most five controls on show, caveat always on show
    longest = {"share": f"{show.SHARES[-1]}%", "layer": str(LAYERS), "second": f"{max(show.SECOND)}%", "noise": max(show.NOISE_WORDS, key=len)}
    labels = re.findall(r'<label class="rf-lab" for="rf-(\w+)">(.*?)</label>', body)
    assert [name for name, _ in labels] == list(longest)
    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)
    legend_y = [int(y) for y in re.findall(r'<text class="rf-t" x="36" y="(\d+)">', HTML)]
    assert len(legend_y) == 2 and max(legend_y) < 80                                                   # the legend sits above the plot top (80), off the curves (round 1 review)
    sizes = [int(px) for px in re.findall(r"\.rf-[kt] \{ font: (?:600 )?(\d+)px", HTML)]
    assert len(sizes) == 2 and min(sizes) >= 15                                                         # chart text 15 units or more, about 12.5 px in the 290 px frame at 340
