"""Guards for ../widget.html: its data block, its size and controls, that its JavaScript gives the same times and words as tail_sim.py for every setting,
and what a reader sees."""
import json
import re
import subprocess
from functools import lru_cache

import tail_sim as sim
from embed_widget import CONFIG_BLOCK, WIDGET, config, embed

HTML = WIDGET.read_text(encoding="utf-8")
ROOT = HTML[HTML.index('<div class="tl-root">'):HTML.index("<script")]
LOADS = sim.RATES
MEASURES = ("total", "wait")
# every load, tester and freeze on the first arrival order, then every arrival order at the opening load and at the hardest setting
SETTINGS = [(rate, waits, freeze, 0) for rate in LOADS for waits in (False, True) for freeze in (False, True)]
SETTINGS += [(sim.OPENING_RATE, False, False, k) for k in range(1, len(sim.REROLLS))] + [(LOADS[-1], True, True, k) for k in range(1, len(sim.REROLLS))]

# Reads the model out of the widget between its marker comments, runs it with Node, and compares every time, summary and line of words with the ones Python
# wrote into the fixture, bit for bit (the seconds are compared as exact numbers).
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="tl-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 {serve, shuffled, times, summarize, secondsText, headline, keyRows, stepSeconds};')(cfg);
const bad = [];
let checks = 0;
const same = (what, got, want) => { checks++; if (JSON.stringify(got) !== JSON.stringify(want)) bad.push([what, JSON.stringify(got).slice(0, 160), JSON.stringify(want).slice(0, 160)]); };
fx.cases.forEach((c) => {
  const what = [c.rate, c.waits ? 'waits' : 'schedule', c.freeze ? 'freeze' : 'calm', 'order ' + c.reroll].join('/');
  const [a, b] = cfg.rerolls[c.reroll];
  const run = api.serve(api.shuffled(a, b), c.rate, c.waits ? cfg.connections : 0, c.freeze ? cfg.pause : 0);
  same(what + ' intended', run.intended, c.intended);
  same(what + ' sent', run.sent, c.sent);
  same(what + ' first words', run.first, c.first);
  same(what + ' last words', run.done, c.done);
  ['total', 'wait'].forEach((m) => {
    const st = api.summarize(api.times(run, m));
    same(what + ' ' + m + ' summary', [st.average, st.p50, st.p95, st.p99, st.max], c.stats[m]);
    same(what + ' ' + m + ' headline', api.headline(st, m), c.headline[m]);
    same(what + ' ' + m + ' key', api.keyRows(st), c.key[m]);
  });
});
fx.words.forEach((w) => same('seconds ' + w[0], api.secondsText(w[0]), w[1]));
fx.ranks.forEach((r) => { const st = api.summarize(Array.from({ length: r.n }, (_, i) => r.n - i)); same('p50, p95, p99 and slowest of 1 to ' + r.n, [st.p50, st.p95, st.p99, st.max], r.want); });
same('cost of one step', [api.stepSeconds(0, 0, 100), api.stepSeconds(1, 1000, 0), api.stepSeconds(64, 64000, 0)], fx.steps);
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, changes the settings 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="tl-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.getAttribute = (k) => e.attrs[k];
  e.addEventListener = (type, f) => (e.listeners[type] = e.listeners[type] || []).push(f);
  return e;
}
function open() {
  const page = { root: el('html'), messages: [], parent: { other: true } };
  fx.ids.forEach((id) => { page[id] = el('x'); });
  page['tl-config'].textContent = config;
  page['tl-tester'].value = 'schedule';
  page['tl-load'].value = '0';
  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) => (e.listeners[type || 'click'] || []).forEach((f) => f({ target: e }));
const bad = [];
let checks = 0;
const want = (what, got, expected) => { checks++; if (JSON.stringify(got) !== JSON.stringify(expected)) bad.push([what, JSON.stringify(got).slice(0, 160), JSON.stringify(expected).slice(0, 160)]); };
function look(page, key, what) {                       // compares the page with one expected view
  const v = fx.views[key];
  want(what + ': load words', page['tl-rate'].textContent, v.rate);
  want(what + ': headline', page['tl-head'].textContent, v.head);
  want(what + ': key', page['tl-key'].innerHTML, v.key);
  want(what + ': pressed buttons', [page['tl-total'].attrs['aria-pressed'], page['tl-wait'].attrs['aria-pressed']], v.pressed);
  want(what + ': chart dots', (page['tl-chart'].innerHTML.match(/<circle class="tl-dot" cx="[\d.]+" cy="[\d.]+" r="6"\/>/g) || []), v.dots);
  want(what + ': average line', (page['tl-chart'].innerHTML.match(/<line class="tl-avg"[^>]*>/g) || []), v.avg);
  want(what + ': top label', (page['tl-chart'].innerHTML.match(/>([\d.]+ s, the slowest)</) || [])[1], v.top);
  want(what + ': 7 labels, clear of each other and inside the chart', labelProblems(page['tl-chart'].innerHTML), [7, []]);
}
const spoken = (page) => page['tl-live'].textContent;

// Where the chart's labels sit. There is no layout engine here, so each label's box is estimated from its text: the width is 0.65 of the font size for every
// character (the real glyphs average about 0.58, so the box is wider than the text), the height runs from one font size above the baseline to 0.3 below it.
// No two boxes may overlap, and every box must lie inside the view box.
const FONT = Number(html.match(/\.tl-root \.tl-t \{ font: \d+ (\d+)px/)[1]);
function labelProblems(markup) {
  const [W, H] = markup.match(/viewBox="0 0 (\d+) (\d+)"/).slice(1).map(Number);
  const boxes = [...markup.matchAll(/<text class="tl-t[^"]*"( text-anchor="end")? x="(-?[\d.]+)" y="(-?[\d.]+)">([^<]*)<\/text>/g)].map((m) => {
    const x = Number(m[2]), y = Number(m[3]), w = 0.65 * FONT * m[4].length;
    return { name: m[4], left: m[1] ? x - w : x, right: m[1] ? x : x + w, top: y - FONT, bottom: y + 0.3 * FONT };
  });
  const problems = [];
  boxes.forEach((a, i) => {
    if (a.left < 0 || a.right > W || a.top < 0 || a.bottom > H) problems.push(a.name + ' leaves the chart');
    boxes.slice(i + 1).forEach((b) => {
      if (Math.min(a.right, b.right) > Math.max(a.left, b.left) && Math.min(a.bottom, b.bottom) > Math.max(a.top, b.top)) problems.push(a.name + ' overlaps ' + b.name);
    });
  });
  return [boxes.length, problems];
}

const first = open();
look(first, fx.opening, 'opening state');
want('opening controls', [first['tl-load'].value, first['tl-load'].max, first['tl-tester'].value, first['tl-freeze'].checked], [String(fx.rates.indexOf(fx.openingRate)), String(fx.rates.length - 1), 'schedule', false]);
want('nothing spoken yet', spoken(first), '');
want('capacity line', first['tl-cap'].textContent, fx.capacityLine);
want('freeze label', first['tl-freeze-text'].textContent, fx.freezeLabel);
want('slider reads out', first['tl-load'].attrs['aria-valuetext'], fx.openingRate + ' requests a second');
const path = first['tl-chart'].innerHTML.match(/<path class="tl-area" d="([^"]*)"/)[1];
want('curve: 1 start, 200 steps, 2 closing lines, closed', [path.startsWith('M6.0 '), (path.match(/L/g) || []).length, path.endsWith('Z')], [true, 202, true]);
fx.openingDots.forEach((d) => want('dot ' + d + ' sits on the curve', path.includes('L' + d), true));

// every load stop, by dragging (input) and by releasing (change)
const slider = open();
fx.rates.forEach((rate, i) => {
  slider['tl-load'].value = String(i);
  const heard = spoken(slider);
  fire(slider['tl-load'], 'input');
  look(slider, 'load ' + rate, 'load ' + rate);
  want('dragging stays silent at ' + rate, spoken(slider), heard);
  fire(slider['tl-load'], 'change');
  want('release speaks at ' + rate, spoken(slider), fx.said['load ' + rate]);
});

// the measure buttons, at the opening load and at the highest load
const measure = open();
fire(measure['tl-wait']);
look(measure, 'wait', 'wait for the first word');
want('measure speaks', spoken(measure), fx.said.wait);
fire(measure['tl-total']);
look(measure, fx.opening, 'back to total time');
want('total speaks', spoken(measure), fx.said.total);

// the three settings under More settings, then all of them together
const more = open();
more['tl-tester'].value = 'waits'; fire(more['tl-tester'], 'change');
look(more, 'waits', 'tester waits'); want('tester speaks', spoken(more), fx.said.waits);
more['tl-tester'].value = 'schedule'; fire(more['tl-tester'], 'change');
look(more, fx.opening, 'tester back on schedule');
more['tl-freeze'].checked = true; fire(more['tl-freeze'], 'change');
look(more, 'freeze', 'freeze'); want('freeze speaks', spoken(more), fx.said.freeze);
more['tl-freeze'].checked = false; fire(more['tl-freeze'], 'change');
want('freeze off speaks', spoken(more), fx.said.calm);
fx.rerolls.forEach((r, k) => {
  fire(more['tl-shuffle']);
  look(more, 'order ' + (k + 1), 'shuffle ' + (k + 1));
  want('shuffle speaks ' + (k + 1), spoken(more), r);
});
// the crowded chart: wait for the first word, the tester that waits, the server frozen. p95 and p99 sit on one line here, at every load and in every arrival order
const crowded = open();
fire(crowded['tl-wait']);
crowded['tl-tester'].value = 'waits'; fire(crowded['tl-tester'], 'change');
crowded['tl-freeze'].checked = true; fire(crowded['tl-freeze'], 'change');
for (let k = 0; k < fx.rerolls.length; k++) {
  fx.rates.forEach((rate, i) => {
    crowded['tl-load'].value = String(i); fire(crowded['tl-load'], 'input');
    want('crowded chart, load ' + rate + ', order ' + (k + 1) + ': 7 labels, clear of each other and inside the chart', labelProblems(crowded['tl-chart'].innerHTML), [7, []]);
  });
  fire(crowded['tl-shuffle']);
}
const all = open();
all['tl-load'].value = String(fx.rates.length - 1); fire(all['tl-load'], 'input');
all['tl-tester'].value = 'waits'; fire(all['tl-tester'], 'change');
all['tl-freeze'].checked = true; fire(all['tl-freeze'], 'change');
fire(all['tl-wait']); fire(all['tl-shuffle']); fire(all['tl-shuffle']); fire(all['tl-shuffle']);
look(all, 'everything', 'every setting changed');

// 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({ checks, bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""

X0, X1, Y0, Y1 = 6, 314, 30, 166              # the chart's drawing box, as in widget.html


@lru_cache(maxsize=None)
def served(rate, waits, freeze, reroll):
    a, b = sim.REROLLS[reroll]
    return sim.serve(sim.shuffled(sim.WORKLOAD, a, b), rate, sim.CONNECTIONS if waits else 0, sim.PAUSE if freeze else 0.0)


def stats(rate, waits=False, freeze=False, reroll=0, measure="total"):
    return sim.summarize(sim.MEASURES[measure](served(rate, waits, freeze, reroll)))


def x(share):
    return f"{X0 + (X1 - X0) * share:.1f}"


def y(value, top):
    return f"{Y1 - (Y1 - Y0) * value / top:.1f}"


def view(rate, waits=False, freeze=False, reroll=0, measure="total"):
    """What the page should show after it has settled on these settings."""
    st = stats(rate, waits, freeze, reroll, measure)
    dots = [f'<circle class="tl-dot" cx="{x(s)}" cy="{y(st[k], st["max"])}" r="6"/>' for k, s in (("p50", 0.5), ("p95", 0.95), ("p99", 0.99))]
    line = y(st["average"], st["max"])
    key = "".join(f'<li><i{" class=" + chr(34) + "tl-sw-avg" + chr(34) if i == 0 else ""}></i><span>{name} {value}</span></li>' for i, (name, value) in enumerate(sim.key_rows(st)))
    return {"rate": str(rate), "head": sim.headline(st, measure), "key": key, "pressed": ["true", "false"] if measure == "total" else ["false", "true"], "dots": dots,
            "avg": [f'<line class="tl-avg" x1="{X0}" x2="{X1}" y1="{line}" y2="{line}"/>'], "top": f"{sim.seconds_text(st['max'])} s, the slowest"}


def spoken_line(rate, measure, head):
    return f"{rate} requests a second. {'Total time' if measure == 'total' else 'Wait for the first word'}. {head}"


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")
    result = subprocess.run(["node", str(tmp_path / "check.mjs"), *args(tmp_path)], capture_output=True, text=True, timeout=300)
    assert result.returncode == 0, result.stdout + result.stderr
    return json.loads(result.stdout)


def test_widget_data_block_matches_the_python_constants():
    """Guards a constant edit 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 parameter count or a changed freeze length must be put back."""
    drifted = HTML.replace('"params": 8030261248', '"params": 7000000000', 1).replace('"pause": 5.0', '"pause": 50.0', 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 model, so a misspelt id would pass there and throw in the browser."""
    script = HTML[HTML.index("<script>"):]
    lookups = set(re.findall(r"\$\('([\w-]+)'\)", script))
    missing = sorted(i for i in lookups if i != "tl-config" and f'id="{i}"' not in ROOT)
    assert not missing, missing
    assert 'id="tl-config"' in HTML and len(lookups) >= 12


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


def test_widget_shows_at_most_five_controls_and_hides_the_rest():
    """Guards the first screen at 390 px: the slider and the two measure buttons stay in view, and the other three controls wait 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)) == 3
    assert "<details><summary>More settings</summary>" in ROOT and "<details open" not in ROOT
    assert re.search(r'<details><summary>More settings</summary>.*?<select id="tl-tester">.*?<input type="checkbox" id="tl-freeze">.*?<button type="button" id="tl-shuffle">', 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)",
                 "--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", "#00855F", "#009E73", "#b36200", "#E69F00"}


def test_widget_model_gives_the_times_and_words_of_the_python_model(tmp_path):
    """Guards the widget's JavaScript drifting from tail_sim.py: the lesson prints Python's numbers and the reader sees the widget's, so the send, first-word and
    last-word time of all 2,000 requests, the five numbers and the lines of words must agree to the last bit at every load, tester and freeze, and in every arrival order."""
    cases = []
    for rate, waits, freeze, reroll in SETTINGS:
        run = served(rate, waits, freeze, reroll)
        st = {m: stats(rate, waits, freeze, reroll, m) for m in MEASURES}
        cases.append({"rate": rate, "waits": waits, "freeze": freeze, "reroll": reroll, "intended": run.intended, "sent": run.sent, "first": run.first, "done": run.done,
                      "stats": {m: [st[m][k] for k in ("average", "p50", "p95", "p99", "max")] for m in MEASURES},
                      "headline": {m: sim.headline(st[m], m) for m in MEASURES}, "key": {m: [list(r) for r in sim.key_rows(st[m])] for m in MEASURES}})
    b = sim.batching
    words = [[s, sim.seconds_text(s)] for s in (0.0, 0.0124, 0.0999, 0.1, 0.7406, 9.994, 10.0, 12.345, 123.456)]
    steps = [b.batch_step([], 100).seconds, b.batch_step([1000]).seconds, b.batch_step([1000] * 64).seconds]
    ranks = [{"n": n, "want": [sim.percentile(list(range(1, n + 1)), p) for p in (50, 95, 99)] + [n]} for n in (7, 50, 99, 100, 101, 150, 2000)]    # counts where p x n / 100 is not a whole number
    result = run_node(tmp_path, PARITY, {"cases": cases, "words": words, "steps": steps, "ranks": ranks}, lambda d: [str(d / "fixture.json"), str(WIDGET)])
    assert result["bad"] == [] and result["checks"] >= len(cases) * 10


def test_widget_page_shows_what_the_lesson_names(tmp_path):
    """Guards the part a reader sees: the opening state (17 a second, total time), every load stop, both measures, the three settings under More settings, the
    chart's dots and average line, what is spoken, the theme message, and the words that keep a toy from reading as a measurement."""
    outside = re.sub(r"<details>.*?</details>", "", ROOT, flags=re.S)
    assert "A toy server and 2,000 invented requests, not a measurement of a real service." in outside and sim.N_REQUESTS == 2000
    assert "p50, p95 and p99 are the times that 50, 95 and 99 of every 100 requests beat." in outside
    assert 'class="tl-sr" id="tl-live" role="status" aria-live="polite" aria-atomic="true"' in ROOT      # what a screen reader hears
    for rule in (".tl-root button { font: inherit; font-weight: 600; font-size: 0.9rem; line-height: 1.2; min-height: 44px; min-width: 72px; padding: 2px 10px; white-space: nowrap;",
                 ".tl-root label.tl-lab { display: block; font-weight: 600; white-space: nowrap; }", ".tl-root input[type=range] { display: block; width: 100%; height: 44px;",
                 ".tl-root li { display: flex; align-items: center; gap: 8px; white-space: nowrap; }", "paint-order: stroke; stroke: var(--bg); stroke-width: 7px;"):      # one line at 290 px, 44 px taps, labels clear of the lines
        assert rule in HTML, rule
    assert 'aria-pressed="true">Total time</button>' in outside and 'aria-pressed="false">Wait for first word</button>' in outside
    assert '<option value="schedule">Sends on schedule</option><option value="waits">Waits for each reply before sending</option>' in ROOT
    views = {"opening": view(sim.OPENING_RATE), "wait": view(sim.OPENING_RATE, measure="wait"), "waits": view(sim.OPENING_RATE, waits=True),
             "freeze": view(sim.OPENING_RATE, freeze=True), "everything": view(LOADS[-1], waits=True, freeze=True, reroll=3, measure="wait")}
    said = {"wait": spoken_line(sim.OPENING_RATE, "wait", views["wait"]["head"]), "total": spoken_line(sim.OPENING_RATE, "total", views["opening"]["head"]),
            "waits": "The tester waits for each reply. " + views["waits"]["head"], "freeze": f"Server frozen for {sim.PAUSE:g} seconds. " + views["freeze"]["head"],
            "calm": "No freeze. " + views["opening"]["head"]}
    for rate in LOADS:
        views[f"load {rate}"] = view(rate)
        said[f"load {rate}"] = spoken_line(rate, "total", views[f"load {rate}"]["head"])
    rerolls = []
    for k in range(1, len(sim.REROLLS) + 1):
        views[f"order {k}"] = view(sim.OPENING_RATE, reroll=k % len(sim.REROLLS))
        rerolls.append(f"Requests shuffled, set {k % len(sim.REROLLS) + 1} of {len(sim.REROLLS)}. {views[f'order {k}']['head']}")
    opening_stats = stats(sim.OPENING_RATE)
    ids = sorted(set(re.findall(r'id="(tl-[\w-]+)"', HTML)))
    fixture = {"ids": ids, "views": views, "opening": "opening", "openingRate": sim.OPENING_RATE, "rates": LOADS, "said": said, "rerolls": rerolls,
               "capacityLine": f"This server tops out near {round(sim.capacity(), 1)} a second.", "freezeLabel": f"Freeze the server for {sim.PAUSE:g} seconds",
               "openingDots": [f"{x(s)} {y(opening_stats[k], opening_stats['max'])}" for k, s in (("p50", 0.5), ("p95", 0.95), ("p99", 0.99))]}
    result = run_node(tmp_path, PAGE, fixture, lambda d: [str(WIDGET), str(d / "fixture.json")])
    assert result["bad"] == [] and result["checks"] > 100
