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

from embed_widget import WIDGET, embed
from toy import FIELDS, MINE, WORDINGS, Defences, all_settings, notes_leave, prompt, reading, run, send_result

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

# 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="pi-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 {prompt, reading, sendResult, run, leaves};')(cfg);
const bad = [];
fx.runs.forEach((r) => {
  const d = { line: r.s[0], markers: r.s[1], noTool: r.s[2], allowList: r.s[3], ask: r.s[4], reads: r.s[5] };
  if (api.prompt(r.text, d) !== r.prompt) bad.push(['prompt', r.s, r.id]);
  if (api.reading(r.text, d) !== r.reading) bad.push(['reading', r.s, r.id]);
  if (JSON.stringify(api.run(r.text, d)) !== JSON.stringify(r.run)) bad.push(['run', r.s, r.id]);
  if (api.leaves(api.run(r.text, d)) !== r.leaves) bad.push(['leaves', r.s, r.id]);
  if (api.sendResult(cfg.outside, d) !== r.outside || api.sendResult(cfg.mine, d) !== r.mine) bad.push(['send', r.s, r.id]);
});
console.log(JSON.stringify({ runs: fx.runs.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="pi-config">([\s\S]*?)<\/script>/)[1];
const cfg = JSON.parse(cfgText);
const code = html.match(/<script>([\s\S]*?)<\/script>/)[1];

function el(tag) {
  const e = { tag, children: [], listeners: {}, className: '', checked: false, classes: new Set(), _text: '' };
  e.classList = { toggle: (c, on) => (on ? e.classes.add(c) : e.classes.delete(c)) };
  e.addEventListener = (type, f) => (e.listeners[type] = e.listeners[type] || []).push(f);
  e.appendChild = (c) => (e.children.push(c), c);
  e.querySelector = (sel) => {
    if (sel !== '.pi-res span') throw new Error('stand-in DOM has no selector ' + sel);
    return e.children.find((c) => c.className === 'pi-res').children.find((c) => c.tag === 'span');
  };
  Object.defineProperty(e, 'textContent', { get: () => e._text, set: (v) => { e._text = v; e.children = []; } });
  return e;
}
function open() {
  const page = {};
  ['pi-config', 'pi-line', 'pi-markers', 'pi-notool', 'pi-list', 'pi-ask', 'pi-blind', 'pi-live', 'pi-big', 'pi-cells', 'pi-mine', 'pi-prompt', 'pi-steps']
    .forEach((id) => { page[id] = el('x'); });
  page['pi-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) => (e.listeners.change || []).forEach((f) => f());
const turnOn = (page, ids) => ids.forEach((id) => { page[id].checked = true; fire(page[id]); });
const shown = (pre) => pre.children.map((c) => ('text' in c ? c.text : c._text)).join('');
const radios = (page) => page['pi-cells'].children.map((cell) => cell.children[0]);

const bad = [];
const want = (what, got, expected) => { if (got !== expected) bad.push([what, got, expected]); };
states.forEach((st) => {
  const page = open();
  turnOn(page, st.ids);
  want(st.name + ': headline', page['pi-big']._text, st.n + ' of 3 wordings get your notes out');
  page['pi-cells'].children.forEach((cell, i) => {
    want(st.name + ': cell ' + i + ' is marked sent', cell.classes.has('sent'), st.sent[i]);
    want(st.name + ': cell ' + i + ' label', cell.querySelector('.pi-res span')._text, st.sent[i] ? 'Notes sent' : 'Stopped');
  });
  const line = page['pi-mine']._text;
  want(st.name + ': own request', line.startsWith('Your own request') && line.endsWith(st.mine), true);
  want(st.name + ': prompt panel, wording 0', shown(page['pi-prompt']), st.prompts[0]);
  radios(page).forEach((radio, i) => { if (i > 0) { fire(radio); want(st.name + ': prompt panel, wording ' + i, shown(page['pi-prompt']), st.prompts[i]); } });
});
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 first wording is checked at the start', start.map((r) => r.checked).join(), 'true,false,false');
const live = open();
turnOn(live, ['pi-ask', 'pi-blind']);
want('screen reader line', live['pi-live']._text.startsWith('3 of 3 wordings get your notes out. Plain: notes sent'), true);
const pick = open();
fire(radios(pick)[2]);
const mark = pick['pi-prompt'].children.find((c) => c.tag === 'mark');
want('picked wording is highlighted', mark && mark._text, cfg.wordings[2].text);
console.log(JSON.stringify({ states: states.length, bad }));
process.exit(bad.length ? 1 : 0);
"""


# What the page should show for each box ticked, worked out from toy.py. The tails are the widget's own-request wording per result.
PAGE_STATES = [
    ("nothing on", [], Defences()),
    ("Instruction line", ["pi-line"], Defences(line=True)),
    ("Markers", ["pi-markers"], Defences(markers=True)),
    ("line and markers", ["pi-line", "pi-markers"], Defences(line=True, markers=True)),
    ("allow-list", ["pi-list"], Defences(allow_list=True)),
    ("no send tool", ["pi-notool"], Defences(no_tool=True)),
    ("Ask first", ["pi-ask"], Defences(ask=True)),
    ("Ask first and the blind click", ["pi-ask", "pi-blind"], Defences(ask=True, reads=False)),
]
OWN_REQUEST_ENDS = {"sent": "Sent.", "approved": "Sent after you say Yes (one question).", "no tool": "Blocked: no send tool."}


def test_widget_data_block_matches_toy():
    """Guards a toy.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="pi-root">'):HTML.index("<script")]
    lookups = set(re.findall(r"\$\('([\w-]+)'\)", HTML[HTML.index("<script>"):]))
    handlers = re.search(r"^\s*\['pi-line'.*$", HTML, re.M)
    assert handlers, "the handler list that starts with 'pi-line' has moved: update this test"
    lookups |= set(re.findall(r"'(pi-[\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="pi-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 Send 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)", ".pi-cell.sent .pi-res i { background: var(--orange);"):
        assert pair in HTML, pair


def test_widget_rules_match_the_python_toy(tmp_path):
    """Guards the widget's JavaScript drifting from toy.py: for all 64 settings and 3 wordings, same prompt and same answers."""
    runs = []
    for d in all_settings():
        flags = [getattr(d, field) for field in FIELDS]
        for wid, _, text in WORDINGS:
            runs.append({"s": flags, "id": wid, "text": text, "prompt": prompt(text, d), "reading": reading(text, d),
                         "run": list(run(text, d)), "leaves": notes_leave(run(text, d)), "outside": send_result("files@outside.example.invalid", d),
                         "mine": send_result("me@example.invalid", d)})
    (tmp_path / "fixture.json").write_text(json.dumps({"runs": runs}), 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=60)
    assert result.returncode == 0, result.stdout + result.stderr
    assert json.loads(result.stdout) == {"runs": 192, "bad": []}


def test_widget_page_shows_what_the_lesson_names(tmp_path):
    """Guards the part a reader sees: the "N of 3" headline, the Notes sent / Stopped marks, the own-request line, the prompt panel, and the wiring of each box.

    The lesson names these runs: nothing on (3 of 3), Ask first (0 of 3), Ask first and the blind click (3 of 3), no send tool (0 of 3)."""
    states = []
    for name, ids, d in PAGE_STATES:
        leaked = [notes_leave(run(text, d)) for _, _, text in WORDINGS]
        states.append({"name": name, "ids": ids, "n": sum(leaked), "sent": leaked, "mine": OWN_REQUEST_ENDS[send_result(MINE, d)],
                       "prompts": [prompt(text, d) for _, _, text in WORDINGS]})
    (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_STATES), "bad": []}
