"""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 budget import dollars
from embed_widget import WIDGET, config, embed
from sim import BUDGET, RPM, RULES, SEED, make_day, random_numbers, simulate_day

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="qb-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, makeDay, simulateDay, summary, dollars};')(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.days.forEach((d) => { if (JSON.stringify(api.makeDay(d.seed)) !== JSON.stringify(d.calls)) bad.push(['day', d.seed]); });
fx.runs.forEach((r) => {
  const got = api.simulateDay(r.rule, r.seed, r.rpm, r.budget);
  if (JSON.stringify([got.served, got.refused, got.spent]) !== JSON.stringify([r.served, r.refused, r.spent])) bad.push(['run', r.rule, r.seed, r.rpm, r.budget]);
  if (JSON.stringify(api.summary(got)) !== JSON.stringify(r.summary)) bad.push(['summary', r.rule, r.seed, r.rpm, r.budget]);
});
fx.money.forEach((m) => { if (api.dollars(m.micro) !== m.text) bad.push(['dollars', m.micro]); });
console.log(JSON.stringify({ numbers: fx.numbers.length, days: fx.days.length, runs: fx.runs.length, money: fx.money.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="qb-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 = {};
  ['qb-config', 'qb-live', 'qb-picks', 'qb-what', 'qb-big', 'qb-sub', 'qb-bars', 'qb-again', 'qb-note', 'qb-rows',
   'qb-rpm', 'qb-budget', 'qb-rpm-v', 'qb-budget-v'].forEach((id) => { page[id] = el('x'); });
  page['qb-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['qb-picks'].children.map((label) => label.children[0]);
const labelText = (page, i) => page['qb-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();
  [['rpm', st.rpm], ['budget', st.cents]].forEach(([key, v]) => { if (v !== undefined) { page['qb-' + key].value = String(v); fire(page['qb-' + key], 'input'); } });
  const picks = radios(page);
  picks.forEach((r, i) => { r.checked = i === st.pick; });
  if (st.pick !== 1) fire(picks[st.pick], 'change');
  for (let i = 0; i < st.again; i++) fire(page['qb-again'], 'click');
  want(st.name + ': rule description', page['qb-what']._text, st.what);
  want(st.name + ': headline', page['qb-big']._text, st.big);
  want(st.name + ': second line', page['qb-sub']._text, st.sub);
  want(st.name + ': note beside the button', page['qb-note']._text, st.note);
  want(st.name + ': table', page['qb-rows'].children.map(cells), st.rows);
  want(st.name + ': only the picked rule is marked', page['qb-rows'].children.map((tr) => tr.className), st.marks);
  want(st.name + ': slider read-outs', [page['qb-rpm-v']._text, page['qb-budget-v']._text], st.sliders);
  const rows = page['qb-bars'].children;
  want(st.name + ': one row for each user', rows.length, 10);
  want(st.name + ': row text', rows.map((r) => [r.children[0]._text, r.children[2]._text, r.children[3]._text]), st.bars);
  want(st.name + ': the heavy bar is orange, the others green', rows.map((r) => r.children[1].children[0].className.split(' ')[1]), st.colors);
  rows.forEach((r, u) => {
    const w = parseFloat(r.children[1].children[0].style.width);
    if (!(Math.abs(w - st.widths[u]) <= 0.05)) bad.push([st.name + ': bar width for user ' + u, w, st.widths[u]]);
  });
  want(st.name + ': bar label for screen readers', page['qb-bars'].attributes['aria-label'], st.label);
  const touched = st.pick !== 1 || st.again > 0 || st.rpm !== undefined || st.cents !== undefined;
  want(st.name + ': screen reader line', touched ? page['qb-live']._text === st.rule + ': ' + st.big + ' ' + st.sub : page['qb-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', 'Heavy share', 'Bill', 'Ordinary refused']);
const legend = [...html.matchAll(/<i class="qb-sw (qb-\w+)"><\/i>([^<]*)<\/span>/g)].map((m) => [m[1], m[2]]);
want('the key names each bar color', legend, [['qb-hv', 'heavy user'], ['qb-ok', 'ordinary user']]);
const liveTag = html.match(/<p[^>]*id="qb-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 three radios share one group name', new Set(start.map((r) => r.name)).size === 1 && !!start[0].name, true);
want('the widget opens on requests per minute', start.map((r) => r.checked), [false, true, false]);
want('the radio labels are the rule names', [0, 1, 2].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, rpm=RPM, cents=BUDGET // 10_000):
    """What the page should show after these choices, worked out from sim.py."""
    seed, budget = SEED + again, cents * 10_000
    days = {rule: simulate_day(rule, seed, rpm, budget) for rule, _ in RULES}
    rule = RULES[pick][0]
    day = days[rule]
    what = {"none": "The server makes every call.",
            "rpm": f"Each user may make {rpm} call{'' if rpm == 1 else 's'} a minute (a bucket of tickets for each user, as in lesson 9).",
            "budget": f"Each user may spend {dollars(budget)} a day. Before a call the server sets aside the most it can cost, refuses if that does not fit, then charges what it really cost."}[rule]
    big = f"Heavy user: {day.percent(0)}% of a {dollars(day.bill)} bill."
    sub = (f"Ordinary users refused: {day.ordinary_refused} of {day.ordinary_asked} calls. "
           f"Heavy user refused: {day.refused[0]} of {day.served[0] + day.refused[0]} calls.")
    names = ["Heavy"] + [f"User {u}" for u in range(1, 10)]
    return {
        "rule": NAMES[rule], "pick": pick, "again": again, "what": what, "big": big, "sub": sub,
        "note": f"Seed {seed}. Another day draws new calls.",
        "rows": [[name, f"{days[r].percent(0)}%", dollars(days[r].bill), f"{days[r].ordinary_refused} of {days[r].ordinary_asked}"] for r, name in RULES],
        "marks": ["qb-sel" if r == rule else "" for r, _ in RULES],
        "sliders": [str(rpm), dollars(budget)],
        "bars": [[names[u], f"{day.percent(u)}%", str(day.refused[u])] for u in range(10)],
        "colors": ["qb-hv"] + ["qb-ok"] * 9,
        "widths": [100 * s / day.bill for s in day.spent],
        "label": f"{NAMES[rule]}: " + "; ".join(f"{names[u]} {day.percent(u)}% of the bill, {day.refused[u]} refused" for u in range(10)),
    }


PAGE_CHOICES = [
    ("opens on requests per minute", dict(pick=1)),
    ("no limit", dict(pick=0)),
    ("daily cost budget", dict(pick=2)),
    ("budget, another day", dict(pick=2, again=1)),
    ("rpm, two other days", dict(pick=1, again=2)),
    ("rpm at 1 a minute", dict(pick=1, rpm=1)),
    ("budget at 5 cents", dict(pick=2, cents=5)),
    ("budget at 50 cents", dict(pick=2, cents=50)),
    ("a slider alone, no other change", dict(pick=1, rpm=10)),
]


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 day."""
    cfg = config()
    for key, value in (("rpm", cfg["rpm"]), ("budget", cfg["budget"] // 10_000)):
        low, high = (int(v) for v in re.search(rf'id="qb-{key}" min="(\d+)" max="(\d+)"', HTML).groups())
        assert low <= value <= high, key
    assert cfg["budget"] % 10_000 == 0 and re.search(r'id="qb-budget" min="5" max="50" step="5"', HTML)


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="qb-root">'):HTML.index("<script")]
    script = HTML[HTML.index("<script>"):]
    lookups = set(re.findall(r"\$\('([\w-]+)'\)", script))
    assert "['qb-rpm', 'qb-budget'].forEach" in script, "the slider id list has moved: update this test"
    lookups |= {"qb-rpm", "qb-budget"}
    created = {"qb-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="qb-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 and "setAttribute('data-theme', d.theme)" 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)",
                 ".qb-root .qb-ok { background: var(--green)", ".qb-root .qb-hv { 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 three 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, days and results for every rule, seed and setting."""
    numbers = []
    for seed in (0, 1, 2, 3, 7, 12345, 2 ** 31, 2 ** 32 - 1):
        gen = random_numbers(seed)
        numbers.append({"seed": seed, "values": [next(gen) for _ in range(300)]})
    days = [{"seed": seed, "calls": [list(c) for c in make_day(seed)]} for seed in range(0, 21)]
    runs = []
    for rpm, budget in ((RPM, BUDGET), (1, 50_000), (2, 100_000), (10, 500_000), (3, 150_000)):
        for seed in range(1, 21):
            for rule, _ in RULES:
                day = simulate_day(rule, seed, rpm, budget)
                runs.append({"rule": rule, "seed": seed, "rpm": rpm, "budget": budget, "served": list(day.served), "refused": list(day.refused),
                             "spent": list(day.spent),
                             "summary": {"bill": day.bill, "pct": [day.percent(u) for u in range(10)], "ordinaryRefused": day.ordinary_refused,
                                         "ordinaryAsked": day.ordinary_asked, "heavyAsked": day.served[0] + day.refused[0]}})
    money = [{"micro": m, "text": dollars(m)} for m in (0, 4_999, 5_000, 14_999, 15_000, 250_000, 995_000, 9_925_000, 9_934_567, 12_345_678)]
    (tmp_path / "fixture.json").write_text(json.dumps({"numbers": numbers, "days": days, "runs": runs, "money": money}), 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": 8, "days": 21, "runs": 5 * 20 * 3, "money": 10, "bad": []}


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

    The lesson names these runs: requests per minute leaves the heavy user at 94% of a $9.93 bill, the budget cuts it to 28% of $0.85."""
    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"] == "Heavy user: 94% of a $9.93 bill."
    assert first["rows"] == [["No limit", "94%", "$9.93", "0 of 204"], ["Requests per minute", "94%", "$9.93", "0 of 204"], ["Daily cost budget", "28%", "$0.85", "0 of 204"]]
    assert states[2]["big"] == "Heavy user: 28% of a $0.85 bill." and states[2]["sub"].startswith("Ordinary users refused: 0 of 204 calls.")
