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

from embed_widget import CONFIG_BLOCK, CORPUS_BLOCK, WIDGET, embed
from prompts import PROMPTS
from toy_gen import END, generate, lines_of, load_corpus, pick, reply_of, seed_state, seed_step, tokenize, train

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

# Reads the rules out of the widget between its marker comments, runs them with Node, and compares every pass with the
# ones Python wrote into the fixture: the same text before, the same chances in the same order, the same token.
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="wbw-config">([\s\S]*?)<\/script>/)[1]);
const js = html.slice(html.indexOf('/* ---------- the toy ('), html.indexOf('/* ---------- state'));
const api = new Function('cfg', "'use strict';" + js + '; return {tokenize, linesOf, train, chances, seedState, seedStep, pick, generate, textOf};')(cfg);
const bad = [];
let checks = 0;
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
fx.tokens.forEach((t) => { checks++; if (!same(api.tokenize(t[0]), t[1])) bad.push(['tokenize', t[0]]); });
fx.seeds.forEach((s) => { checks++; if (api.seedState(s.seed) !== s.state) bad.push(['seedState', s.seed]); });
fx.steps.forEach((s) => { checks++; if (!same(api.seedStep(s.state), { state: s.next, u: s.u })) bad.push(['seedStep', s.state]); });
fx.picks.forEach((p) => { checks++; if (api.pick(p.ranked, p.total, p.u) !== p.token) bad.push(['pick', p.u]); });
fx.corpora.forEach((c) => {
  const model = api.train(api.linesOf(c.text));
  checks++;
  if (!same(model.map((m) => m.size), c.sizes)) bad.push(['table sizes', c.name, model.map((m) => m.size), c.sizes]);
  c.runs.forEach((r) => {
    const got = api.generate(model, r.prompt, r.cap, r.seed);
    checks += 2;
    if (got.length !== r.passes.length) { bad.push(['pass count', c.name, r.prompt, r.seed, r.cap]); return; }
    if (api.textOf(got.map((p) => p.token)) !== r.reply) bad.push(['reply', c.name, r.prompt, r.seed, r.cap]);
    r.passes.forEach((want, i) => {
      const p = got[i];
      checks += 5;
      if (!same(p.before, want.before)) bad.push(['before', c.name, r.prompt, r.seed, i]);
      if (!same(p.looked, want.looked)) bad.push(['looked', c.name, r.prompt, r.seed, i]);
      if (!same(p.ranked, want.ranked) || p.total !== want.total) bad.push(['chances', c.name, r.prompt, r.seed, i]);
      if (p.token !== want.token) bad.push(['token', c.name, r.prompt, r.seed, i, p.token, want.token]);
      if (!same(api.chances(model, want.before).ranked, want.ranked)) bad.push(['chances()', c.name, r.prompt, i]);
    });
  });
});
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 and a fake clock, presses the buttons, 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: '', checked: false, hidden: false, disabled: 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 = { timers: [], root: el('html'), messages: [], parent: { other: true } };
  fx.ids.forEach((id) => { page[id] = el('x'); });
  page['wbw-config']._text = block('wbw-config');
  page['wbw-corpus']._text = block('wbw-corpus');
  const doc = { getElementById: (id) => page[id], createElement: el, createTextNode: (text) => ({ text }), documentElement: page.root };
  const win = { addEventListener: (type, f) => page.messages.push(f) };
  const clock = { set: (f, ms) => { page.timers.push({ f, ms }); return page.timers.length; }, clear: () => { page.timers.length = 0; } };
  new Function('document', 'window', 'parent', 'setInterval', 'clearInterval', code)(doc, win, page.parent, clock.set, clock.clear);
  return page;
}
const fire = (e, type) => (e.listeners[type || 'click'] || []).forEach((f) => f({ target: e }));
const tick = (page) => page.timers[0].f();

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 + ': reply', shown(page['wbw-reply']), v.reply);
  want(what + ': count', page['wbw-count']._text, v.count);
  want(what + ': next button', page['wbw-next'].disabled, 'closing' in v);
  want(what + ': closing hidden', page['wbw-end'].hidden, !('closing' in v));
  want(what + ': chances hidden', page['wbw-next-box'].hidden, 'closing' in v);
  if ('closing' in v) {
    want(what + ': closing', page['wbw-end']._text, v.closing);
    want(what + ': closing kind', page['wbw-end'].attrs['data-v'], v.kind);
    return;
  }
  want(what + ': looked', page['wbw-looked']._text, v.looked);
  const rows = page['wbw-list'].children;
  want(what + ': row count', rows.length, v.rows.length);
  rows.forEach((li, i) => {
    const r = v.rows[i];
    want(what + ': row ' + i + ' token', shown(li.children[0]), r[0] + (i === 0 && !v.chance ? ' next' : ''));
    want(what + ': row ' + i + ' share', li.children[2]._text, r[1]);
    if (!(Math.abs(parseFloat(li.children[1].style.width) - r[2]) <= 0.051)) bad.push([what + ': row ' + i + ' bar', li.children[1].style.width, r[2]]);
    want(what + ': row ' + i + ' top mark', li.attrs['data-top'] === 'true', i === 0 && !v.chance);
  });
}
function settle(page, s) {                          // question, then settings, as a reader would
  page['wbw-q'].value = String(s.q); fire(page['wbw-q'], 'change');
  page['wbw-chance'].checked = s.chance; page['wbw-seed'].value = String(s.seed); page['wbw-cap'].value = String(s.cap);
  ['wbw-chance', 'wbw-seed', 'wbw-cap'].forEach((id) => fire(page[id], 'change'));
}

fx.scenarios.forEach((s, n) => {
  const page = open();
  settle(page, s);
  const name = 'scenario ' + n + ' (' + s.q + ', ' + (s.chance ? 'seed ' + s.seed : 'likeliest') + ', cap ' + s.cap + ')';
  s.views.forEach((v, k) => {
    if (k > 0) {
      fire(page['wbw-next']);
      const p = s.passes[k - 1];
      want(name + ': spoken after press ' + k, page['wbw-live']._text, p.spoken);
      if (p.token !== fx.end) {
        const marks = page['wbw-reply'].children.filter((c) => c.tag === 'mark');
        want(name + ': newest token marked', marks.map(shown), [p.token]);
      }
    }
    look(page, Object.assign({ chance: s.chance }, v), name + ' after ' + k + ' presses');
  });
});

// the opening state is the one the lesson shows first
const first = open();
look(first, Object.assign({ chance: false }, fx.scenarios[0].views[0]), 'opening state');
want('options', first['wbw-q'].children.map((o) => [o.value, o._text]), fx.options);
want('nothing spoken yet', first['wbw-live']._text, '');

// Play runs the loop on a clock and speaks only at the end; Pause stops it; Replay starts over
const play = open();
fire(play['wbw-play']);
want('timer started', [play.timers.length, play['wbw-play']._text], [1, 'Pause']);
want('play paces itself', play.timers[0].ms >= 100 && play.timers[0].ms <= 1000, true);
tick(play); tick(play); tick(play);
want('three ticks, three passes', play['wbw-count']._text, 'Passes so far: 3');
want('silent while playing', play['wbw-live']._text, '');
fire(play['wbw-play']);
want('paused', [play.timers.length, play['wbw-play']._text, play['wbw-count']._text], [0, 'Play', 'Passes so far: 3']);
fire(play['wbw-play']);
let guard = 0;
while (play.timers.length && guard++ < 100) tick(play);
look(play, Object.assign({ chance: false }, fx.scenarios[0].views[fx.scenarios[0].views.length - 1]), 'played to the end');
want('play ends', [play.timers.length, play['wbw-play']._text, play['wbw-live']._text], [0, 'Replay', fx.finished]);
fire(play['wbw-play']);
want('play again starts over', [play['wbw-count']._text, play.timers.length, shown(play['wbw-reply'])], ['Passes so far: 0', 1, 'Nothing yet.']);

// Next token stops a running play; Reset empties the reply; a new question starts over
const mix = open();
fire(mix['wbw-play']); tick(mix);
fire(mix['wbw-next']);
want('Next token stops play', [mix.timers.length, mix['wbw-count']._text, mix['wbw-play']._text], [0, 'Passes so far: 2', 'Play']);
fire(mix['wbw-reset']);
look(mix, Object.assign({ chance: false }, fx.scenarios[0].views[0]), 'after Reset');
fire(mix['wbw-next']);
mix['wbw-q'].value = '2'; fire(mix['wbw-q'], 'change');
look(mix, Object.assign({ chance: false }, fx.scenarios.find((s) => s.q === 2 && !s.chance && s.cap === 40).views[0]), 'new question');

// settings are kept inside their range and written back
const odd = open();
[['wbw-cap', '2', '4'], ['wbw-cap', '99', '40'], ['wbw-cap', '', '40'], ['wbw-cap', '12.7', '12'], ['wbw-seed', '-5', '0'], ['wbw-seed', '1e12', '999999999'], ['wbw-seed', '', '1']]
  .forEach(([id, typed, kept]) => { odd[id].value = typed; fire(odd[id], 'change'); want(id + ' typed ' + JSON.stringify(typed), odd[id].value, 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);
"""

# Corpora a reader might paste, and questions the corpus never saw: words that are also JavaScript object keys,
# non-ASCII text, an empty corpus. The page itself always uses the lesson corpus.
CORPORA = {
    "lesson": CORPUS,
    "odd words": "constructor is __proto__ .\nhasOwnProperty is toString !\nThe café is open, isn't it?\nNo. 2026 is 2026.\n\n\n",
    "one line": "How do I make tea? Boil some water.\n",
    "empty": "",
    "one word": "x\n",
}
QUESTIONS = [p["prompt"] for p in PROMPTS] + ["constructor", "", "is __proto__", "zzz qqq zzz", "How do I make", "Café is"]
SEEDS = [None, 0, 1, 2, 3, 7, 12345, 999999999, 2147483645, 2147483646, -5]
CAPS = [1, 4, 10, 40]
TOKEN_CASES = ["How do I make tea?", "", "  \t ", "a_b-c", "éè", "end.", "x" * 40, "A.B,C!D?E", "isn't it? 2026!"]
OPTIONS = [[str(i), p["prompt"]] for i, p in enumerate(PROMPTS)]


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


def passes_of(prompt, cap, seed):
    return generate(MODEL, prompt, cap, seed)


def view(passes, k):
    """What the page shows after k presses of Next token: the reply, the count, and then the chances for the next pass or the closing line."""
    shown = {"reply": reply_of(passes[:k]) or "Nothing yet.", "count": f"Passes so far: {k}"}
    if k < len(passes):
        p = passes[k]
        looked = f': "{' '.join(p['looked_at'])}"' if p["looked_at"] else ""
        shown["looked"] = f"Pass {k + 1} looks at the last {plural(len(p['looked_at']), 'token', 'tokens')}{looked}. Chances for the next token:"
        shown["rows"] = [["end mark" if token == END else token, f"{count} of {p['total']} · {int(100 * count / p['total'] + 0.5)}%", 100 * count / p["total"]]
                         for token, count in p["ranked"]]
    elif passes[-1]["token"] == END:
        shown["closing"] = f"The model chose the end mark, so the reply is finished: {plural(k - 1, 'token', 'tokens')} and the end mark took {plural(k, 'pass', 'passes')}."
        shown["kind"] = "end"
    else:
        shown["closing"] = f"Stopped at the length cap: {plural(k, 'token', 'tokens')} took {plural(k, 'pass', 'passes')}, and the reply is cut off."
        shown["kind"] = "cap"
    return shown


def spoken(pass_number, token):
    return f"Pass {pass_number}: added " + ("the end mark. The reply is finished." if token == END else f"{token}.")


def scenario(q, chance, seed, cap):
    passes = passes_of(PROMPTS[q]["prompt"], cap, seed if chance else None)
    return {"q": q, "chance": chance, "seed": seed, "cap": cap, "views": [view(passes, k) for k in range(len(passes) + 1)],
            "passes": [{"token": p["token"], "spoken": spoken(i + 1, p["token"])} for i, p in enumerate(passes)]}


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_blocks_match_corpus_and_settings():
    """Guards a corpus, question 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_blocks_drifted():
    """Guards embed() skipping a block: a hand-edited corpus line or question must be put back."""
    drifted = HTML.replace("Boil some water", "Boil some milk", 1).replace('"Egg"', '"Eggs"', 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="wbw-root">'):HTML.index("<script")]
    script = HTML[HTML.index("<script>"):]
    lookups = set(re.findall(r"\$\('([\w-]+)'\)", script))
    handlers = re.search(r"^\s*\['wbw-chance'.*$", script, re.M)
    assert handlers, "the handler list that starts with 'wbw-chance' has moved: update this test"
    lookups |= set(re.findall(r"'(wbw-[\w-]+)'", handlers.group(0)))
    inside = lookups - {"wbw-config", "wbw-corpus"}
    assert inside and all(f'id="{i}"' in root for i in inside), sorted(i for i in inside if f'id="{i}"' not in root)
    assert 'id="wbw-config"' in HTML and 'id="wbw-corpus"' in HTML


def test_corpus_is_safe_to_embed_in_a_script_element():
    assert "\r" not in CORPUS and "</script" not in CORPUS.lower() and "<!--" not in CORPUS
    assert "<" not in CONFIG_BLOCK.search(HTML).group(2)


def test_widget_is_small_and_asks_for_nothing_online():
    assert len(HTML.splitlines()) < 300 and len(HTML.encode("utf-8")) < 60 * 1024
    page = CORPUS_BLOCK.sub("", 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 chances off it. Extra settings stay in a closed details element."""
    root = HTML[HTML.index('<div class="wbw-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


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)", "--green: light-dark(#00855F, #009E73)", "--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_passes_of_the_python_toy(tmp_path):
    """Guards the widget's JavaScript drifting from toy_gen.py: the lesson prints Python's passes and the reader sees the
    widget's, so the two must agree on every pass (text before, chances, token) for odd corpora, unseen questions, every
    seed and every cap."""
    corpora = []
    for name, text in CORPORA.items():
        model = train(lines_of(text))
        runs = []
        for question in QUESTIONS:
            for seed in SEEDS:
                for cap in CAPS:
                    passes = generate(model, question, cap, seed)
                    runs.append({"prompt": question, "seed": seed, "cap": cap, "reply": reply_of(passes),
                                 "passes": [{"before": p["before"], "looked": p["looked_at"], "ranked": [list(r) for r in p["ranked"]],
                                             "total": p["total"], "token": p["token"]} for p in passes]})
        corpora.append({"name": name, "text": text, "sizes": [len(table) for table in model], "runs": runs})
    seed_values = SEEDS[1:] + [2147483647, 2147483648, 40, 4000000000]
    seeds = [{"seed": seed, "state": seed_state(seed)} for seed in seed_values]
    steps = []
    for state in [seed_state(seed) for seed in seed_values] + [1, 2, 2147483646, 399268537]:
        nxt, u = seed_step(state)
        steps.append({"state": state, "next": nxt, "u": u})
    ranked, total = [("a", 3), ("b", 2), ("c", 1)], 6
    picks = [{"ranked": ranked, "total": total, "u": u, "token": pick(ranked, total, u)} for u in (None, 0.0, 0.2, 0.5, 0.51, 0.83, 0.9999999)]
    fixture = {"tokens": [[t, tokenize(t)] for t in TOKEN_CASES], "seeds": seeds, "steps": steps, "picks": picks, "corpora": corpora}
    result = run_node(tmp_path, PARITY, fixture, lambda d: [str(d / "fixture.json"), str(WIDGET)])
    assert result["bad"] == [] and result["checks"] > 1000
    assert any(len(r["passes"]) == 31 for r in corpora[0]["runs"]) and any(r["reply"] == "" for r in corpora[3]["runs"])


def test_widget_page_shows_what_the_lesson_names(tmp_path):
    """Guards the part a reader sees: the opening state (Boil 4 of 5, Heat 1 of 5), one more token per press, the pass
    count, the marked newest token, the closing line for an end mark and for the cap, Play, Pause, Reset and the settings."""
    scenarios = [scenario(0, False, 1, 40)] + [scenario(q, False, 1, 40) for q in (1, 2, 3)]
    scenarios += [scenario(0, False, 1, 4), scenario(0, False, 1, 10)]
    scenarios += [scenario(q, True, seed, 40) for q in range(4) for seed in (1, 2, 7, 12345, 0)]
    scenarios += [scenario(0, True, 3, 6), scenario(3, True, 999999999, 40)]
    opening = scenarios[0]["views"][0]
    assert opening["rows"][0][:2] == ["Boil", "4 of 5 · 80%"] and opening["rows"][1][:2] == ["Heat", "1 of 5 · 20%"]
    assert opening["looked"] == 'Pass 1 looks at the last 3 tokens: "make tea ?". Chances for the next token:' and opening["reply"] == "Nothing yet."
    assert scenarios[0]["views"][-1]["closing"].endswith("30 tokens and the end mark took 31 passes.")
    assert max(len(v["rows"]) for s in scenarios for v in s["views"] if "rows" in v) >= 2
    ids = sorted(set(re.findall(r'id="(wbw-[\w-]+)"', HTML)))
    fixture = {"ids": ids, "end": END, "scenarios": scenarios, "options": OPTIONS, "finished": spoken(31, END),
               "text": reply_of(passes_of(PROMPTS[0]["prompt"], 40, None))}
    result = run_node(tmp_path, PAGE, fixture, lambda d: [str(WIDGET), str(d / "fixture.json")])
    assert result == {"scenarios": len(scenarios), "bad": []}
