"""Guards for ../widget.html: its data block, its size, that its rules give the same chances and draws as sampler.py, and what a reader sees."""
import json
import re
import subprocess

import sampler as s
from embed_widget import CONFIG_BLOCK, WIDGET, embed

HTML = WIDGET.read_text(encoding="utf-8")
TOY = s.toy
MODEL = TOY.train(TOY.lines_of(TOY.load_corpus()))

# Reads the rules out of the widget between its marker comments, runs them with Node, and compares them with what
# Python wrote into the fixture: generator states, chances after temperature and top-p, and the picks drawn.
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="wsd-config">([\s\S]*?)<\/script>/)[1]);
const js = html.slice(html.indexOf('/* ---------- the rules ('), html.indexOf('/* ---------- state'));
const api = new Function('cfg', "'use strict';" + js + '; return {seedState, seedStep, startState, asChances, reshape, withTemperature, topP, choose, draw};')(cfg);
const bad = [];
let checks = 0;
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const close = (a, b) => a.length === b.length && a.every((e, i) => e[0] === b[i][0] && Math.abs(e[1] - b[i][1]) < 1e-12);
fx.seeds.forEach((e) => { checks += 2; if (api.seedState(e.seed) !== e.state) bad.push(['seedState', e.seed]); if (api.startState(e.seed) !== e.start) bad.push(['startState', e.seed]); });
fx.steps.forEach((e) => { checks++; if (!same(api.seedStep(e.state), { state: e.next, u: e.u })) bad.push(['seedStep', e.state]); });
fx.chances.forEach((e) => {
  const base = api.asChances({ ranked: e.ranked, total: e.total });
  checks += 3;
  if (!close(base, e.base)) bad.push(['asChances', e.row]);
  if (!close(api.withTemperature(base, e.t), e.hot)) bad.push(['withTemperature', e.row, e.t]);
  if (!close(api.reshape(base, e.t, e.p), e.shaped)) bad.push(['reshape', e.row, e.t, e.p, api.reshape(base, e.t, e.p), e.shaped]);
});
fx.chooses.forEach((e) => { checks++; if (api.choose(e.chances, e.u) !== e.token) bad.push(['choose', e.u]); });
fx.draws.forEach((e) => {
  const base = api.asChances({ ranked: e.ranked, total: e.total });
  const got = api.draw(api.reshape(base, e.t, e.p), e.state, e.n);
  checks += 2;
  if (!same(got.picks, e.picks)) bad.push(['picks', e.row, e.t, e.p, e.seed]);
  if (got.state !== e.after) bad.push(['state after', e.row, e.t, e.p, e.seed]);
});
console.log(JSON.stringify({ checks, bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""

# Runs the page script against a small stand-in for the DOM, moves the controls, 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 block = (id) => html.match(new RegExp('<script type="[a-z/]+" id="' + id + '">([\\s\\S]*?)</script>'))[1];
const code = html.match(/<script>([\s\S]*?)<\/script>/)[1];

function el(tag) {
  const e = { tag, children: [], listeners: {}, className: '', hidden: false, value: '', style: {}, attrs: {}, _text: '' };
  e.setAttribute = (k, v) => { e.attrs[k] = v; };
  e.addEventListener = (type, f) => (e.listeners[type] = e.listeners[type] || []).push(f);
  e.appendChild = (c) => (e.children.push(c), c);
  Object.defineProperty(e, 'textContent', { get: () => e._text, set: (v) => { e._text = v; e.children = []; } });
  return e;
}
const shown = (e) => e._text + e.children.map((c) => ('text' in c ? c.text : shown(c))).join('');
function open() {
  const page = { root: el('html'), messages: [], parent: { other: true } };
  fx.ids.forEach((id) => { page[id] = el('x'); });
  page['wsd-config']._text = block('wsd-config');
  const doc = { getElementById: (id) => page[id], createElement: el, createTextNode: (text) => ({ text }), 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 = [];
const want = (what, got, expected) => { if (JSON.stringify(got) !== JSON.stringify(expected)) bad.push([what, got, expected]); };
function look(page, v, what) {                      // compares the page with one expected view
  want(what + ': temperature shown', page['wsd-temp-v']._text, v.t);
  want(what + ': top-p shown', page['wsd-p-v']._text, v.p);
  want(what + ': lead', page['wsd-lead']._text, v.lead);
  want(what + ': note', page['wsd-note']._text, v.note);
  want(what + ': picks', shown(page['wsd-picks']), v.picks);
  want(what + ': marked picks', page['wsd-picks'].children.filter((c) => c.tag === 'mark').map(shown), v.marked);
  const rows = page['wsd-list'].children;
  want(what + ': row count', rows.length, v.rows.length);
  rows.forEach((li, i) => {
    const r = v.rows[i], fill = li.children[1].children[0], tick = li.children[1].children[1];
    want(what + ': row ' + i + ' token', shown(li.children[0]), r.token);
    want(what + ': row ' + i + ' chance', shown(li.children[2]), r.chance);
    want(what + ': row ' + i + ' drawn', shown(li.children[3]), r.drawn);
    want(what + ': row ' + i + ' top mark', li.attrs['data-top'] === 'true', i === 0);
    want(what + ': row ' + i + ' cut mark', li.attrs['data-cut'] === 'true', r.cut);
    want(what + ': row ' + i + ' label', li.attrs['aria-label'], r.label);
    if (!(Math.abs(parseFloat(fill.style.width) - r.width) <= 0.051)) bad.push([what + ': row ' + i + ' bar', fill.style.width, r.width]);
    if (!(Math.abs(parseFloat(tick.style.left) - r.tick) <= 0.051)) bad.push([what + ': row ' + i + ' tick', tick.style.left, r.tick]);
  });
}
function settle(page, s) {                          // row, sliders and seed, as a reader would move them
  page['wsd-row'].value = String(s.row); fire(page['wsd-row'], 'change');
  page['wsd-temp'].value = s.t; fire(page['wsd-temp'], 'input');
  page['wsd-p'].value = s.p; fire(page['wsd-p'], 'input');
  page['wsd-seed'].value = String(s.seed); fire(page['wsd-seed'], 'change');
}

fx.scenarios.forEach((s, n) => {
  const page = open();
  settle(page, s);
  const name = 'scenario ' + n + ' (row ' + s.row + ', t ' + s.t + ', p ' + s.p + ', seed ' + s.seed + ')';
  s.views.forEach((v, k) => {
    if (k > 0) {
      fire(page['wsd-draw']);
      want(name + ': spoken after press ' + (k + 1), page['wsd-live']._text, v.spoken);
    }
    look(page, v, name + ' press ' + (k + 1));
  });
  fire(page['wsd-restart']);
  look(page, s.views[0], name + ' after Start over');
});

// the opening state is the one the lesson shows first; nothing is spoken until a press
const first = open();
look(first, fx.opening, 'opening state');
want('options', first['wsd-row'].children.map((o) => [o.value, o._text]), fx.options);
want('nothing spoken yet', first['wsd-live']._text, '');
want('seed written back', first['wsd-seed'].value, '1');

// choosing another row alone, with no slider or seed touched, shows that row from press 1
const swapped = open();
swapped['wsd-row'].value = '2'; fire(swapped['wsd-row'], 'change');
look(swapped, fx.swapped, 'row changed alone');

// a slider or the row moved after some presses starts that setting over at press 1
const moved = open();
fire(moved['wsd-draw']); fire(moved['wsd-draw']);
want('press count', moved['wsd-note']._text.slice(0, 8), 'Press 3:');
moved['wsd-temp'].value = '0.5'; fire(moved['wsd-temp'], 'input');
want('slider restarts', moved['wsd-note']._text.slice(0, 8), 'Press 1:');

// settings are kept inside their range and written back
const odd = open();
[['wsd-seed', '-5', '0'], ['wsd-seed', '1e12', '999999999'], ['wsd-seed', '', '1'], ['wsd-seed', '12.7', '12'], ['wsd-seed', 'x', '1']]
  .forEach(([id, typed, kept]) => { odd[id].value = typed; fire(odd[id], 'change'); want(id + ' typed ' + JSON.stringify(typed), odd[id].value, kept); });
[['wsd-temp', '0', '0.05'], ['wsd-temp', '9', '2'], ['wsd-temp', '', '1'], ['wsd-p', '0', '0.1'], ['wsd-p', '7', '1'], ['wsd-p', '', '1']]
  .forEach(([id, typed, kept]) => { odd[id].value = typed; fire(odd[id], 'input'); want(id + ' typed ' + JSON.stringify(typed), odd[id + '-v']._text, kept); });

// 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('light again', themed.root.attrs, { 'data-theme': 'light' });

console.log(JSON.stringify({ scenarios: fx.scenarios.length, bad: bad.slice(0, 6) }));
process.exit(bad.length ? 1 : 0);
"""

ROW_INDEX = {row["id"]: i for i, row in enumerate(s.ROWS)}
TEMPS = [float(f"{0.05 * k:.2f}") for k in range(1, 41)]            # the slider's steps: 0.05 to 2
PS = [float(f"{0.05 * k:.2f}") for k in range(2, 21)]               # 0.1 to 1
OPTIONS = [[str(i), row["name"]] for i, row in enumerate(s.ROWS)]


def plural(n, one, many):
    return f"{n} {one if n == 1 else many}"


def ranked_of(row):
    ranked, total = s.row_of(MODEL, row["context"])
    return ranked, total


def percent(chance):
    tenths = int(chance * 1000 + 0.5)
    return "<0.1%" if tenths < 1 else f"{tenths / 10:.1f}%"


def view(row_i, t, p, seed, presses):
    """What the page shows after `presses` presses (the first one is the setting itself)."""
    ranked, total = ranked_of(s.ROWS[row_i])
    base = s.as_chances(ranked, total)
    shaped = s.reshape(base, t, p)
    kept = dict(shaped)
    state, picks = s.start_state(seed), []
    for _ in range(presses):
        picks, state = s.draw(shaped, state)
    top = ranked[0][0]
    rows = []
    for token, chance in base:
        drawn = picks.count(token)
        c = kept.get(token)
        rows.append({"token": token, "chance": "cut" if c is None else percent(c), "drawn": str(drawn), "cut": c is None,
                     "width": 100 * (c or 0), "tick": 100 * chance,
                     "label": f"{token}: {'cut' if c is None else 'chance ' + percent(c)}, drawn {drawn}"})
    where = f"all {len(base)} tokens" if len(kept) == len(base) else f"the {plural(len(kept), 'token', 'tokens')} kept (of {len(base)})"
    note = f"Press {presses}: {s.DRAWS} picks from {where}."
    counts = ", ".join(f"{r['token']} {r['drawn']}" for r in rows if r["drawn"] != "0")
    return {"t": f"{t:g}", "p": f"{p:g}", "lead": f"Counted in the toy’s lines: {total} tokens followed this text.",
            "note": note, "picks": " ".join(picks), "marked": [x for x in picks if x == top], "rows": rows, "spoken": f"{note} {counts}."}


def scenario(row_i, t, p, seed, presses=3):
    return {"row": row_i, "t": f"{t:g}", "p": f"{p:g}", "seed": seed, "views": [view(row_i, t, p, seed, k) for k in range(1, presses + 1)]}


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


def test_widget_data_block_matches_the_toy_and_sampler():
    """Guards a row or 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 count or constant must be put back."""
    drifted = HTML.replace('["three", 4]', '["three", 5]', 1).replace('"warmup": 4', '"warmup": 0', 1)
    assert drifted != HTML and embed(drifted) == HTML


def test_every_element_the_script_looks_up_exists_inside_the_root():
    """Guards a widget that never starts: the parity run covers only the rules, so a misspelt id passed there and threw in the browser."""
    root = HTML[HTML.index('<div class="wsd-root">'):HTML.index("<script")]
    script = HTML[HTML.index("<script>"):]
    classes = set(re.findall(r"\.(wsd-[\w-]+)", HTML[:HTML.index("<script")]))       # style classes the script gives to new elements
    ids = set(re.findall(r"'(wsd-[\w-]+)'", script)) - {"wsd-config"} - classes
    assert ids and all(f'id="{i}"' in root for i in ids), sorted(i for i in ids if f'id="{i}"' not in root)
    assert 'id="wsd-config"' in HTML and 'id="wsd-live"' in root


def test_config_is_safe_to_embed_and_names_the_rows_the_lesson_uses():
    block = CONFIG_BLOCK.search(HTML).group(2)
    assert "<" not in block and "</script" not in block.lower()
    config = json.loads(block)
    assert [r["id"] for r in config["rows"]] == ["wait", "the", "pass1", "pass11"]
    assert config["rows"][0]["ranked"][0] == ["three", 4] and config["rows"][0]["total"] == 13
    assert (config["draws"], config["warmup"]) == (20, 4)


def test_button_label_names_the_number_of_draws():
    """The label is plain text; it must not drift from the constant the draws use."""
    assert f">Draw {s.DRAWS} times</button>" in HTML


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("):
        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_shows_at_most_five_controls_and_hides_the_rest():
    """Guards the first screen at 390 px: more than five controls push the bars off it. Extra settings stay in a closed details element."""
    root = HTML[HTML.index('<div class="wsd-root">'):HTML.index("<script")]
    outside = re.sub(r"<details>.*?</details>", "", root, flags=re.S)
    assert len(re.findall(r"<(?:select|button|input|textarea)\b", outside)) <= 5
    assert "<details><summary>More settings</summary>" in root and "<details open" not in root
    assert 'id="wsd-seed"' in re.search(r"<details><summary>More settings</summary>.*?</details>", root, re.S).group(0)


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 ("light-dark(#ffffff, #0a0f17)", "light-dark(#ebf0f6, #131b28)", "light-dark(#0a1626, #f4f7fb)",
                 "light-dark(#44566b, #a3b1c4)", "light-dark(#d2dae4, #1c2635)", "light-dark(#66768a, #7a8a9f)",
                 "light-dark(#f6f8fb, #05070c)", "--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()


def test_widget_rules_give_the_chances_and_draws_of_the_python_sampler(tmp_path):
    """Guards the widget's JavaScript drifting from sampler.py: the lesson prints Python's numbers and the reader sees the
    widget's, so they must agree on the generator, on every chance the sliders can reach, on the cut, and on every pick."""
    seeds = [0, 1, 2, 3, 7, 12345, 999999999]
    steps = []
    for state in [s.start_state(x) for x in seeds] + [1, 2, 2147483646, 399268537]:
        nxt, u = TOY.seed_step(state)
        steps.append({"state": state, "next": nxt, "u": u})
    chances = []
    for i, row in enumerate(s.ROWS):
        ranked, total = ranked_of(row)
        base = s.as_chances(ranked, total)
        for t in TEMPS:
            for p in PS:
                chances.append({"row": i, "ranked": ranked, "total": total, "t": t, "p": p, "base": base,
                                "hot": s.with_temperature(base, t), "shaped": s.reshape(base, t, p)})
    chooses = [{"chances": [("a", 0.5), ("b", 0.25), ("c", 0.25)], "u": u, "token": s.choose([("a", 0.5), ("b", 0.25), ("c", 0.25)], u)}
               for u in (0.0, 0.49, 0.5, 0.74, 0.75, 0.9999999)]
    chooses.append({"chances": [("a", 0.3), ("b", 0.3)], "u": 0.7, "token": "b"})
    draws = []
    for i, row in enumerate(s.ROWS):
        ranked, total = ranked_of(row)
        base = s.as_chances(ranked, total)
        for t, p in ((1.0, 1.0), (0.2, 1.0), (2.0, 1.0), (1.0, 0.7), (0.05, 0.1), (0.35, 0.65)):
            for seed in (1, 2, 999999999):
                state = s.start_state(seed)
                picks, after = s.draw(s.reshape(base, t, p), state, 1000)
                draws.append({"row": i, "ranked": ranked, "total": total, "t": t, "p": p, "seed": seed, "state": state, "n": 1000, "picks": picks, "after": after})
    fixture = {"seeds": [{"seed": x, "state": TOY.seed_state(x), "start": s.start_state(x)} for x in seeds], "steps": steps,
               "chances": chances, "chooses": chooses, "draws": draws}
    result = run_node(tmp_path, PARITY, fixture, lambda d: [str(d / "fixture.json"), str(WIDGET)])
    assert result["bad"] == [] and result["checks"] > 9000
    assert any(len(set(d["picks"])) > 1 for d in draws)


def test_widget_page_shows_what_the_lesson_names(tmp_path):
    """Guards the part a reader sees: the opening state (Wait row, seed 1, 7 4 3 2 1 3), a new 20 picks per press, bars
    and counts for each setting, the cut tokens, the spoken summary, Start over, the settings' ranges and the theme."""
    scenarios = [scenario(0, 1.0, 1.0, 1), scenario(0, 0.2, 1.0, 1), scenario(0, 2.0, 1.0, 1), scenario(0, 1.0, 0.7, 1),
                 scenario(0, 0.1, 0.1, 1), scenario(0, 0.05, 1.0, 2), scenario(0, 2.0, 0.1, 999999999)]
    scenarios += [scenario(i, t, p, seed) for i in (1, 2, 3) for t, p, seed in ((1.0, 1.0, 1), (0.5, 0.8, 7), (1.5, 0.35, 0))]
    opening = view(0, 1.0, 1.0, 1, 1)
    assert [r["drawn"] for r in opening["rows"]] == ["7", "4", "3", "2", "1", "3"]
    assert [r["chance"] for r in opening["rows"]] == ["30.8%", "23.1%", "23.1%", "7.7%", "7.7%", "7.7%"]
    assert opening["note"] == "Press 1: 20 picks from all 6 tokens." and opening["picks"].split()[:3] == ["six", "ten", "three"]
    again = view(0, 1.0, 1.0, 1, 2)
    assert [r["drawn"] for r in again["rows"]] == ["6", "2", "5", "4", "3", "0"] and again["picks"] != opening["picks"]
    cut = view(0, 1.0, 0.7, 1, 1)
    assert [r["chance"] for r in cut["rows"]] == ["40.0%", "30.0%", "30.0%", "cut", "cut", "cut"]
    assert cut["note"] == "Press 1: 20 picks from the 3 tokens kept (of 6)."
    assert view(0, 0.1, 0.1, 1, 1)["note"] == "Press 1: 20 picks from the 1 token kept (of 6)."
    assert any(r["chance"] == "<0.1%" for r in view(0, 0.05, 1.0, 1, 1)["rows"])
    defaults = {i: re.search(rf'id="{i}"[^>]*? value="([^"]*)"', HTML).group(1) for i in ("wsd-temp", "wsd-p", "wsd-seed")}
    assert defaults == {"wsd-temp": "1", "wsd-p": "1", "wsd-seed": "1"}      # the page's own starting values, which the opening state assumes
    ids = sorted(set(re.findall(r'id="(wsd-[\w-]+)"', HTML)))
    fixture = {"ids": ids, "scenarios": scenarios, "opening": opening, "swapped": view(2, 1.0, 1.0, 1, 1), "options": OPTIONS}
    result = run_node(tmp_path, PAGE, fixture, lambda d: [str(WIDGET), str(d / "fixture.json")])
    assert result == {"scenarios": len(scenarios), "bad": []}
