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

from embed_widget import WIDGET, embed
from evalset import NUMBER, QUESTIONS, WOBBLE_CHANCE, compare, describe, has, passes, results, wilson, wobble
from toy import FIELDS, Instructions, all_settings, answer

HTML = WIDGET.read_text(encoding="utf-8")
LESSON = WIDGET.parent
OFF = Instructions()
RUNS = [None, 0, 1, 2, 3, 4]

# Reads the rules out of the widget between its marker comments, runs them, and compares with the Python results.
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="ev-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 {answer, passes, results, compare, wilson, numbers, has, OFF};')(cfg);
const bad = [];
let checked = 0;
const same = (what, got, want) => { checked++; if (JSON.stringify(got) !== JSON.stringify(want)) bad.push([what, got, want]); };
fx.settings.forEach((s) => {
  same('answers ' + s.name, cfg.questions.map((q) => api.answer(q.q, s.d)), s.answers);
  fx.runs.forEach((run, k) => {
    same('results ' + s.name + ' run ' + run, api.results(s.d, run), s.results[k]);
    same('compare ' + s.name + ' run ' + run, api.compare(api.results(api.OFF, run), api.results(s.d, run)), s.compare[k]);
  });
});
fx.numbers.forEach(([text, want]) => same('numbers ' + text, api.numbers(text), want));
fx.has.forEach(([text, phrase, want]) => same('has ' + text + ' / ' + phrase, api.has(text, phrase), want));
fx.grades.forEach(([i, text, want]) => same('passes Q' + (i + 1) + ' ' + text, api.passes(cfg.questions[i], text), want));
fx.wilson.forEach(([k, lo, hi]) => {
  const w = api.wilson(k, 20);
  checked++;
  if (Math.abs(w[0] - lo) > 1e-12 || Math.abs(w[1] - hi) > 1e-12) bad.push(['wilson', k, w, lo, hi]);
});
console.log(JSON.stringify({ checked, 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, opening } = JSON.parse(fs.readFileSync(fixture, 'utf8'));
const cfgText = html.match(/<script type="application\/json" id="ev-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, disabled: false, value: '0', attrs: {}, _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.attrs[k] = v);
  Object.defineProperty(e, 'textContent', { get: () => e._text, set: (v) => { e._text = String(v); e.children = []; } });
  return e;
}
const controls = ['ev-page', 'ev-dk', 'ev-one', 'ev-wob', 'ev-run'];
const tagOf = (id) => html.match(new RegExp('<[a-z]+ [^>]*id="' + id + '"[^>]*>'))[0];
const value = (page, id) => (id === 'ev-run' ? page[id].value : page[id].checked);
function open() {
  const page = {};
  ['ev-live', 'ev-big', 'ev-cells', 'ev-detail', 'ev-rates', 'ev-runs', ...controls].forEach((id) => { page[id] = el('x'); });
  page['ev-config'] = el('script');
  page['ev-config']._text = cfgText;
  controls.forEach((id) => { page[id].checked = / checked[ >]/.test(tagOf(id)); page[id].disabled = / disabled[ >]/.test(tagOf(id)); });
  const doc = { getElementById: (id) => page[id], createElement: el, documentElement: { setAttribute() {} } };
  new Function('document', 'window', 'parent', code)(doc, { addEventListener() {} }, {});
  return page;
}
const fire = (e, type = 'change') => (e.listeners[type] || []).forEach((f) => f());
const cells = (page) => page['ev-cells'].children;
const detail = (page) => page['ev-detail'].children.map((p) => p._text);

const bad = [];
const want = (what, got, expected) => { if (JSON.stringify(got) !== JSON.stringify(expected)) bad.push([what, got, expected]); };

const start = open();
want('opening: headline', start['ev-big']._text, opening);
want('opening: 20 squares', cells(start).length, 20);
want('opening: don\'t-know switch is on, the other two off', [start['ev-page'].checked, start['ev-dk'].checked, start['ev-one'].checked], [false, true, false]);
want('opening: variation controls are off', [start['ev-wob'].checked, start['ev-run'].disabled, start['ev-runs']._text], [false, true, '']);
want('opening: first picked square is 13', cells(start).map((c) => c.attrs['aria-pressed']).indexOf('true'), 12);

states.forEach((st) => {
  const page = open(), was = controls.map((id) => value(page, id));
  page['ev-page'].checked = st.d[0]; page['ev-dk'].checked = st.d[1]; page['ev-one'].checked = st.d[2];
  page['ev-wob'].checked = st.run !== null;
  if (st.run !== null) page['ev-run'].value = String(st.run);
  (st.only ? [st.only] : controls.filter((id, k) => value(page, id) !== was[k])).forEach((id) => fire(page[id]));
  if (st.pick !== null) fire(cells(page)[st.pick], 'click');
  want(st.name + ': headline', page['ev-big']._text, st.headline);
  want(st.name + ': square words', cells(page).map((c) => c.children[1]._text), st.words);
  want(st.name + ': square classes', cells(page).map((c) => c.className), st.classes);
  want(st.name + ': detail', detail(page), st.detail);
  want(st.name + ': rates', page['ev-rates']._text, st.rates);
  want(st.name + ': runs line', page['ev-runs']._text, st.runs);
  want(st.name + ': run menu enabled only with variation on', page['ev-run'].disabled, st.run === null);
  want(st.name + ': screen reader line', page['ev-live']._text, st.live);
});
console.log(JSON.stringify({ states: states.length, bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""


def flags(d):
    return [getattr(d, f) for f in FIELDS]


def js_settings(d):
    return {"pageOnly": d.page_only, "dontKnow": d.dont_know, "oneSentence": d.one_sentence}


def pct(x):
    return f"{round(100 * x)}%"


def span(k):
    lo, hi = wilson(k, 20)
    return f"{pct(lo)} to {pct(hi)}"


def expected_page(name, d, run, pick):
    """What the page should show, worked out from toy.py and evalset.py with none of the widget's code."""
    before, after = results(OFF, run), results(d, run)
    c = compare(before, after)
    kb, ka, fixed, broke = sum(before), sum(after), c.count("fixed"), c.count("broke")
    i = 12 if pick is None else pick
    q = QUESTIONS[i]
    flip = run is not None and wobble()[run][i]
    check = describe(q)
    gap = "" if kb == ka else " The ranges overlap, so a gap this size could come from which questions you picked." if wilson(kb, 20)[1] >= wilson(ka, 20)[0] and wilson(ka, 20)[1] >= wilson(kb, 20)[0] else " The ranges do not overlap."
    runs = [(sum(results(OFF, r)), sum(results(d, r))) for r in range(5)]
    low = lambda j: min(x[j] for x in runs)
    high = lambda j: max(x[j] for x in runs)
    headline = f"Nothing changed: still {pct(ka / 20)}." if fixed + broke == 0 else \
        f"This change fixed {fixed} question{'' if fixed == 1 else 's'} and broke {broke}: {pct(kb / 20)} to {pct(ka / 20)}."
    live = lambda v: ", ".join(f"Q{n + 1}" for n, x in enumerate(c) if x == v) or "none"
    return {"name": name, "d": flags(d), "run": run, "pick": pick, "headline": headline, "words": c,
            "classes": [f"ev-cell {x}" + (" sel" if n == i else "") for n, x in enumerate(c)],
            "detail": [f"Q{i + 1}. {q[0]}", f"Right answer: {q[1]}",
                       f"Starting instructions said: “{answer(q[0], OFF)}” ({'pass' if before[i] else 'fail'})",
                       f"This setting says: “{answer(q[0], d)}” ({'pass' if after[i] else 'fail'})",
                       "Check: " + check[0].lower() + check[1:] + "." + (" This run, both results came out the other way." if flip else "")],
            "rates": f"Starting instructions: {kb} of 20 ({span(kb)}, 95% range). This setting: {ka} of 20 ({span(ka)})." + gap,
            "runs": f"Across all 5 runs the starting instructions score {low(0)} to {high(0)} of 20; this setting scores {low(1)} to {high(1)}." if run is not None else "",
            "live": f"{headline} Fixed: {live('fixed')}. Broke: {live('broke')}."}


def run_node(tmp_path, script, fixture, args):
    (tmp_path / "script.mjs").write_text(script, encoding="utf-8")
    (tmp_path / "fixture.json").write_text(json.dumps(fixture), encoding="utf-8")
    result = subprocess.run(["node", str(tmp_path / "script.mjs"), *args(tmp_path / "fixture.json")], capture_output=True, text=True, timeout=60)
    assert result.returncode == 0, result.stdout + result.stderr
    return json.loads(result.stdout)


def test_widget_data_block_matches_toy():
    """Guards a toy.py or evalset.py edit that was not copied into the widget (run embed_widget.py)."""
    assert embed(HTML) == 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="ev-root">'):HTML.index("<script")]
    script = HTML[HTML.index("<script>"):]
    lookups = set(re.findall(r"\$\('([\w-]+)'\)", script))
    handlers = re.search(r"^\s*\['ev-page'.*$", script, re.M)
    assert handlers, "the handler list that starts with 'ev-page' has moved: update this test"
    lookups |= set(re.findall(r"'(ev-[\w-]+)'", handlers.group(0)))
    assert lookups and all(f'id="{i}"' in root for i in lookups), sorted(i for i in lookups if f'id="{i}"' not in root)


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="ev-config">.*?</script>', "", HTML, flags=re.S)
    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_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)"):
        assert pair in HTML, pair
    assert "1 in 10" in HTML and WOBBLE_CHANCE == 0.1  # the words in the widget say what evalset.py does


def test_no_blue_or_vermillion_anywhere_in_the_lesson():
    """Guards the site palette: ink, grey, orange and green only (no blue, no vermillion), in the page, the figures and the text."""
    files = [LESSON / "lesson.md", LESSON / "widget.html", *sorted((LESSON / "figs").glob("*.svg"))]
    found = {f.name: m for f in files if f.exists() for m in ("0072B2", "56B4E9", "D55E00") if m.lower() in f.read_text(encoding="utf-8").lower()}
    assert found == {}


def test_widget_rules_match_the_python_toy(tmp_path):
    """Guards the widget's JavaScript drifting from toy.py and evalset.py: same answers, marks, fixed/broke lists and ranges."""
    settings = []
    for d in all_settings():
        settings.append({"name": "+".join(f for f, on in zip(FIELDS, flags(d)) if on) or "off", "d": js_settings(d),
                         "answers": [answer(q[0], d) for q in QUESTIONS],
                         "results": [results(d, r) for r in RUNS], "compare": [compare(results(OFF, r), results(d, r)) for r in RUNS]})

    def nums(text):
        values = sorted({float(x) for x in NUMBER.findall(text)})
        return ",".join(str(int(v)) if v == int(v) else repr(v) for v in values)

    fixture = {"runs": RUNS, "settings": settings, "wilson": [[k, *wilson(k, 20)] for k in range(21)],
               "numbers": [[t, nums(t)] for t in ("$60.", "9am to 6pm", "a 190 day warranty, 2 days", "3.5 and 3.50 and 7", "$100 and $1000", "none", "1 2 2 1", "v2 5x 7")],
               "has": [[t, p, has(t, p)] for t, p in (("I do not know", "no"), ("No, only within 30 days", "no"), ("$160 a week", "$60 a week"),
                                                     ("Unused parts", "used parts"), ("Used parts.", "used parts"), ("Opening: 9AM", "9am"))],
               "grades": [[i, text, passes(QUESTIONS[i], text)] for i, text in
                          ((2, "2 days. 5 days."), (6, "a 190 day warranty"), (8, "Opening hours on Sunday: closed."), (18, "I do not know."), (18, "No, 30 days."),
                           (12, "Yes, we do."), (12, "The help page does not say."), (1, "10am to 4pm and 9am"))]}
    out = run_node(tmp_path, PARITY, fixture, lambda f: [str(f), str(WIDGET)])
    assert out["bad"] == [] and out["checked"] == 8 * (1 + 2 * len(RUNS)) + 8 + 6 + 8 + 21


def test_widget_page_shows_what_the_lesson_names(tmp_path):
    """Guards the part a reader sees: the headline sentence, the word in every square, the detail lines, the ranges, the switches the page opens with and the wiring of each control.

    The lesson names these states: the opening one (13 of 20: fixed 3, broke 2), one sentence (fixed 2, broke 2, same 60%), and run 5 of the variation (the new
    instructions score below the starting ones). The opening switches are read from widget.html's own attributes. Each control is also changed and fired on its
    own, so a control whose change handler is missing leaves the page unchanged and fails."""
    on = lambda *names: Instructions(**{n: True for n in names})
    states = [{**expected_page("opening", on("dont_know"), None, None), "live": ""},  # nothing is announced until a control changes
              expected_page("all off", OFF, None, 3),
              expected_page("one sentence", on("one_sentence"), None, 9),
              expected_page("page only", on("page_only"), None, 11),
              expected_page("all three", on("page_only", "dont_know", "one_sentence"), None, 15)]
    states += [expected_page(f"variation run {r + 1}", on("dont_know"), r, 17 if r == 2 else None) for r in range(5)]
    alone = [("ev-page", on("page_only", "dont_know"), None), ("ev-dk", OFF, None), ("ev-one", on("dont_know", "one_sentence"), None),
             ("ev-wob", on("dont_know"), 0), ("ev-run", on("dont_know"), 4)]
    states += [{**expected_page(f"{id} alone", d, run, None), "only": id} for id, d, run in alone]
    out = run_node(tmp_path, PAGE_TEST, {"states": states, "opening": states[0]["headline"]}, lambda f: [str(WIDGET), str(f)])
    assert out == {"states": len(states), "bad": []}
    assert states[0]["headline"] == "This change fixed 3 questions and broke 2: 60% to 65%."
    assert states[9]["headline"] == "This change fixed 2 questions and broke 3: 70% to 65%."  # run 5: the better change looks worse
    assert "<b>Vary the results</b>" in WIDGET.read_text()  # it flips results, not answers
