"""Guards for ../widget.html: its data block, what it may show, and that its rules give the same answers as sim.py."""
import json
import re
import subprocess

from embed_widget import WIDGET, config, embed
from sim import CLIENTS, LIMIT, RULES, SEED, TRIES, random_numbers, simulate

HTML = WIDGET.read_text(encoding="utf-8")
NAMES = dict(RULES)

# Reads the rules out of the widget between its marker comments, runs them, and compares with the Python answers.
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="rl-config">([\s\S]*?)<\/script>/)[1]);
const js = html.slice(html.indexOf("/* ---------- the toy's rules"), html.indexOf('/* ---------- the page'));
const api = new Function('cfg', "'use strict';" + js + '; return {randomNumbers, waitAfter, simulate, summary};')(cfg);
const bad = [];
fx.numbers.forEach((n) => {
  const next = api.randomNumbers(n.seed);
  n.values.forEach((v, i) => { if (next() !== v) bad.push(['number', n.seed, i]); });
});
fx.runs.forEach((r) => {
  const got = api.simulate(r.rule, r.seed, r.clients, r.limit, r.tries);
  if (JSON.stringify([got.served, got.refused, got.gaveUp]) !== JSON.stringify([r.served, r.refused, r.gaveUp])) bad.push(['run', r.rule, r.seed, r.clients, r.limit, r.tries]);
  if (JSON.stringify(api.summary(got)) !== JSON.stringify(r.summary)) bad.push(['summary', r.rule, r.seed, r.clients, r.limit, r.tries]);
});
fx.waits.forEach((w) => { if (api.waitAfter(w.rule, w.refusals, () => w.draw) !== w.wait) bad.push(['wait', w.rule, w.refusals, w.draw]); });
console.log(JSON.stringify({ numbers: fx.numbers.length, runs: fx.runs.length, waits: fx.waits.length, bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""


# Runs the page script (everything after the rules) against a small stand-in for the DOM, and reads back what a reader would see.
PAGE_TEST = r"""
import fs from 'node:fs';
const [widget, fixture] = process.argv.slice(2);
const html = fs.readFileSync(widget, 'utf8');
const states = JSON.parse(fs.readFileSync(fixture, 'utf8')).states;
const cfgText = html.match(/<script type="application\/json" id="rl-config">([\s\S]*?)<\/script>/)[1];
const code = html.match(/<script>([\s\S]*?)<\/script>/)[1];

function el(tag) {
  const e = { tag, children: [], listeners: {}, className: '', checked: false, value: '', style: {}, attributes: {}, _text: '' };
  e.addEventListener = (type, f) => (e.listeners[type] = e.listeners[type] || []).push(f);
  e.appendChild = (c) => (e.children.push(c), c);
  e.setAttribute = (k, v) => { e.attributes[k] = v; };
  Object.defineProperty(e, 'textContent', { get: () => e._text, set: (v) => { e._text = String(v); e.children = []; } });
  return e;
}
function open() {
  const page = {};
  ['rl-config', 'rl-title', 'rl-cap', 'rl-live', 'rl-picks', 'rl-what', 'rl-chart', 'rl-end', 'rl-top', 'rl-big', 'rl-sub', 'rl-again', 'rl-note', 'rl-rows',
   'rl-clients', 'rl-limit', 'rl-tries', 'rl-clients-v', 'rl-limit-v', 'rl-tries-v'].forEach((id) => { page[id] = el('x'); });
  page['rl-config']._text = cfgText;
  const doc = { getElementById: (id) => page[id], createElement: el, createTextNode: (text) => ({ text }), documentElement: { setAttribute() {} } };
  new Function('document', 'window', 'parent', code)(doc, { addEventListener() {} }, {});
  return page;
}
const fire = (e, type) => (e.listeners[type] || []).forEach((f) => f());
const radios = (page) => page['rl-picks'].children.map((label) => label.children[0]);
const labelText = (page, i) => page['rl-picks'].children[i].children[1].text;
const cells = (tr) => tr.children.map((c) => c._text);

const bad = [];
const want = (what, got, expected) => { if (JSON.stringify(got) !== JSON.stringify(expected)) bad.push([what, got, expected]); };
states.forEach((st) => {
  const page = open();
  ['clients', 'limit', 'tries'].forEach((key) => { if (st[key] !== undefined) { page['rl-' + key].value = String(st[key]); fire(page['rl-' + key], 'input'); } });
  const picks = radios(page);
  picks.forEach((r, i) => { r.checked = i === st.pick; });
  if (st.pick > 0) fire(picks[st.pick], 'change');
  for (let i = 0; i < st.again; i++) fire(page['rl-again'], 'click');
  want(st.name + ': title', page['rl-title']._text, st.title);
  want(st.name + ': caption', page['rl-cap']._text, st.cap);
  want(st.name + ': rule description', page['rl-what']._text, st.what);
  want(st.name + ': headline', page['rl-big']._text, st.big);
  want(st.name + ': second line', page['rl-sub']._text, st.sub);
  want(st.name + ': note beside the button', page['rl-note']._text, st.note);
  want(st.name + ': table', page['rl-rows'].children.map(cells), st.rows);
  want(st.name + ': only the picked rule is marked', page['rl-rows'].children.map((tr) => tr.className), st.marks);
  want(st.name + ': slider read-outs', [page['rl-clients-v']._text, page['rl-limit-v']._text, page['rl-tries-v']._text], st.sliders);
  want(st.name + ': last tick label', page['rl-end']._text, 'tick ' + (st.columns - 1));
  want(st.name + ': full-height label', page['rl-top']._text, 'full height = ' + st.sliders[0] + ' calls');
  const cols = page['rl-chart'].children;
  want(st.name + ': one column per tick, at least 12', cols.length, st.columns);
  want(st.name + ': every bar is answered (green) under refused (orange), as the legend says', [...new Set(cols.map((c) => c.children.map((b) => b.className).join(' ')))], ['rl-ok rl-no']);
  const heights = cols.map((c) => [parseFloat(c.children[0].style.height), parseFloat(c.children[1].style.height)]);
  heights.forEach((h, t) => {
    const e = st.heights[t] || [0, 0];
    if (!(Math.abs(h[0] - e[0]) <= 0.05 && Math.abs(h[1] - e[1]) <= 0.05)) bad.push([st.name + ': bar heights at tick ' + t, h, e]);
  });
  const touched = st.pick > 0 || st.again > 0 || ['clients', 'limit', 'tries'].some((key) => st[key] !== undefined);
  want(st.name + ': screen reader line', touched ? page['rl-live']._text === st.rule + ': ' + st.big + ' ' + st.sub : page['rl-live']._text === '', true);
});
const headers = [...html.match(/<thead>[\s\S]*?<\/thead>/)[0].matchAll(/<th scope="col">([^<]*)<\/th>/g)].map((m) => m[1]);
want('the results table headers, in the order of the cells the script writes', headers, ['Rule', 'Refused', 'Gave up', 'Done at', 'Retry peak']);
const legend = [...html.matchAll(/<i class="rl-sw (rl-\w+)"><\/i>([^<]*)<\/span>/g)].map((m) => [m[1], m[2]]);
want('the legend names each bar color', legend, [['rl-ok', 'answered'], ['rl-no', 'refused (429)']]);
const liveTag = html.match(/<p[^>]*id="rl-live"[^>]*>/)[0];
want('the live region is a polite status read whole', ['role="status"', 'aria-live="polite"', 'aria-atomic="true"'].map((a) => liveTag.includes(a)), [true, true, true]);
const start = radios(open());
want('the four radios share one group name', new Set(start.map((r) => r.name)).size === 1 && !!start[0].name, true);
want('the first rule is checked at the start', start.map((r) => r.checked), [true, false, false, false]);
want('the radio labels are the rule names', [0, 1, 2, 3].map((i) => labelText(open(), i)), JSON.parse(cfgText).rules.map((r) => r.name));
console.log(JSON.stringify({ states: states.length, bad }));
process.exit(bad.length ? 1 : 0);
"""


def expected_page(pick, again=0, clients=CLIENTS, limit=LIMIT, tries=TRIES):
    """What the page should show after these choices, worked out from sim.py."""
    seed = SEED + again
    runs = {rule: simulate(rule, seed, clients, limit, tries) for rule, _ in RULES}
    rule = RULES[pick][0]
    run = runs[rule]
    ticks = max(len(run.served), 12)  # the chart is at least 12 ticks wide
    summary = lambda r: [r.refused_calls, r.gave_up, r.finished, r.busiest_retry_tick]
    waits = {"once": "Call again on the very next tick.", "fixed": "Wait 4 ticks, every time.",
             "backoff": "Wait 4 ticks, then twice as long each time, never more than 16.",
             "jitter": "Pick a random wait from 1 tick up to the backoff wait: up to 4, then 8, never more than 16."}
    return {
        "rule": NAMES[rule], "pick": pick, "again": again, "columns": ticks,
        "title": f"What happens when {clients} clients are told “too many requests”?",
        "cap": f"A toy server, not a real one. It answers {limit} calls per tick (think of a tick as one second). All {clients} clients call at tick 0; a refused client tries again by the rule you pick.",
        "what": waits[rule],
        "big": f"{run.refused_calls} calls refused. " + (f"{run.gave_up} of {clients} clients gave up." if run.gave_up else "Every client was served."),
        "sub": f"Last call at tick {run.finished}. Most calls in one retry tick: {run.busiest_retry_tick}.",
        "note": f"Seed {seed}. A new run draws new random waits." if rule == "jitter" else "No random part, so every run is the same.",
        "rows": [[name] + [str(v) for v in summary(runs[r])] for r, name in RULES],
        "marks": ["rl-sel" if r == rule else "" for r, _ in RULES],
        "sliders": [str(clients), str(limit), str(tries)],
        "heights": [[100 * s / clients, 100 * r / clients] for s, r in zip(run.served, run.refused)],
    }


PAGE_CHOICES = [
    ("opens on retry at once", dict(pick=0)),
    ("backoff with jitter", dict(pick=3)),
    ("fixed wait", dict(pick=1)),
    ("backoff", dict(pick=2)),
    ("jitter, two more random runs", dict(pick=3, again=2)),
    ("retry at once, a new run changes nothing", dict(pick=0, again=1)),
    ("jitter, 40 clients, 5 per tick", dict(pick=3, clients=40, limit=5)),
    ("backoff, 5 clients, 2 tries", dict(pick=2, clients=5, tries=2)),
    ("jitter, nobody refused", dict(pick=3, clients=8, limit=10)),
    ("a slider alone, no rule picked", dict(pick=0, clients=8)),
]


def test_widget_data_block_matches_sim():
    """Guards a sim.py edit that was not copied into the widget (run embed_widget.py)."""
    assert embed(HTML) == HTML


def test_widget_sliders_hold_every_value_the_data_block_sets():
    """Guards a setting changed in sim.py past the end of its slider: the browser would clamp it and the page would show another run."""
    cfg = config()
    for key in ("clients", "limit", "tries"):
        low, high = (int(v) for v in re.search(rf'id="rl-{key}" min="(\d+)" max="(\d+)"', HTML).groups())
        assert low <= cfg[key] <= high, key


def test_every_element_the_script_looks_up_exists_inside_the_root():
    """Guards a widget that never starts: a lookup of an id that is not in the page throws in the browser."""
    root = HTML[HTML.index('<div class="rl-root">'):HTML.index("<script")]
    script = HTML[HTML.index("<script>"):]
    lookups = set(re.findall(r"\$\('([\w-]+)'\)", script))
    assert "['clients', 'limit', 'tries']" in script, "the slider key list has moved: update this test"
    lookups |= {f"rl-{key}{suffix}" for key in ("clients", "limit", "tries") for suffix in ("", "-v")}
    created = {"rl-pick-" + r["id"] for r in config()["rules"]}  # made by the script, not in the page
    missing = sorted(i for i in lookups if f'id="{i}"' not in root and i not in created)
    assert lookups and not missing, missing


def test_widget_is_small_and_asks_for_nothing_online():
    assert len(HTML.splitlines()) <= 300 and len(HTML.encode("utf-8")) < 40 * 1024
    page = re.sub(r'<script type="application/json" id="rl-config">.*?</script>', "", HTML, flags=re.S)
    for banned in ("http://", "https://", "fetch(", "XMLHttpRequest", "WebSocket", "sendBeacon", "import(", "Math.random"):
        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_keeps_the_site_theme_listener_and_palette():
    assert "d.type !== 'spicyshark:theme'" in HTML and "e.source !== parent" in HTML
    for pair in ("light-dark(#ffffff, #0a0f17)", "light-dark(#ebf0f6, #131b28)", "light-dark(#0a1626, #f4f7fb)",
                 "light-dark(#44566b, #a3b1c4)", "light-dark(#d2dae4, #1c2635)", "light-dark(#66768a, #7a8a9f)",
                 "--green: light-dark(#00855F, #009E73)", "--orange: light-dark(#b36200, #E69F00)",
                 ".rl-root .rl-ok { background: var(--green)", ".rl-root .rl-no { background: var(--orange)"):
        assert pair in HTML, pair
    assert not re.search(r"0072B2|56B4E9|D55E00", HTML, re.I)


def test_widget_keeps_extra_settings_in_a_closed_more_settings_block():
    """The lesson's brief: the point shows with four radios and one button; the sliders wait behind "More settings"."""
    assert re.search(r"<details>\s*<summary>More settings</summary>", HTML) and "<details open" not in HTML
    visible = HTML[:HTML.index("<details>")]
    assert len(re.findall(r"<button\b", visible)) == 1 and 'type="range"' not in visible


def test_widget_rules_match_the_python_sim(tmp_path):
    """Guards the widget's JavaScript drifting from sim.py: the same numbers, runs and waits for every rule, seed and setting."""
    numbers = []
    for seed in (0, 1, 2, 3, 12345, 2 ** 31, 2 ** 32 - 1):
        gen = random_numbers(seed)
        numbers.append({"seed": seed, "values": [next(gen) for _ in range(200)]})
    runs = []
    for clients, limit, tries in ((CLIENTS, LIMIT, TRIES), (5, 1, 2), (40, 10, 10), (12, 2, 4), (30, 5, 3), (8, 10, 6), (40, 1, 10)):
        for seed in range(1, 31):
            for rule, _ in RULES:
                run = simulate(rule, seed, clients, limit, tries)
                runs.append({"rule": rule, "seed": seed, "clients": clients, "limit": limit, "tries": tries, "served": list(run.served),
                             "refused": list(run.refused), "gaveUp": run.gave_up,
                             "summary": {"refused": run.refused_calls, "gaveUp": run.gave_up, "finished": run.finished, "peak": run.busiest_retry_tick}})
    from sim import wait_after
    waits = [{"rule": rule, "refusals": k, "draw": draw, "wait": wait_after(rule, k, lambda: draw)}
             for rule, _ in RULES for k in range(1, 7) for draw in (0.0, 0.2, 0.5, 0.999999)]
    (tmp_path / "fixture.json").write_text(json.dumps({"numbers": numbers, "runs": runs, "waits": waits}), encoding="utf-8")
    (tmp_path / "parity.mjs").write_text(PARITY, encoding="utf-8")
    result = subprocess.run(["node", str(tmp_path / "parity.mjs"), str(tmp_path / "fixture.json"), str(WIDGET)], capture_output=True, text=True, timeout=120)
    assert result.returncode == 0, result.stdout + result.stderr
    assert json.loads(result.stdout) == {"numbers": 7, "runs": 7 * 30 * 4, "waits": 4 * 6 * 4, "bad": []}


def test_widget_page_shows_what_the_lesson_names(tmp_path):
    """Guards the part a reader sees: headline, second line, comparison table, the bars, the note beside the button and the sliders' wiring.

    The lesson names these runs: retry at once (57 refused, 2 gave up), the fixed wait, backoff, and backoff with jitter on seed 1 (25 refused)."""
    states = [{"name": name, **choice, **expected_page(**choice)} for name, choice in PAGE_CHOICES]
    (tmp_path / "states.json").write_text(json.dumps({"states": states}), encoding="utf-8")
    (tmp_path / "page.mjs").write_text(PAGE_TEST, encoding="utf-8")
    result = subprocess.run(["node", str(tmp_path / "page.mjs"), str(WIDGET), str(tmp_path / "states.json")], capture_output=True, text=True, timeout=60)
    assert result.returncode == 0, result.stdout + result.stderr
    assert json.loads(result.stdout) == {"states": len(PAGE_CHOICES), "bad": []}
    first = states[0]
    assert (first["big"], first["rows"][0]) == ("57 calls refused. 2 of 20 clients gave up.", ["Retry at once", "57", "2", "5", "17"])
    assert states[1]["rows"][3] == ["Backoff with jitter", "25", "0", "14", "7"]
