"""Guards for ../widget.html: its data block, its size, that its tokenizer, match and sums give the same tokens, numbers and words as prefix_cache.py, and what a reader sees."""
import json
import re
import subprocess

import pytest

import prefix_cache as pc
from embed_widget import CONFIG_BLOCK, WIDGET, config, embed

HTML = WIDGET.read_text(encoding="utf-8")
ROOT = HTML[HTML.index('<div class="pcw-root">'):HTML.index("<script")]
DEFAULTS = (0, pc.DOC_LENGTHS.index(pc.DEFAULT_DOC), pc.AGES.index(pc.DEFAULT_AGE), 1)       # rule, document, age (indexes), mark ticked (1)
CUSTOM = [pc.EARLIER, pc.EARLIER[:-8], "", "café 你好 😀 [document] x", "[document]\n[document]", "\x01 tab\t and 1234567 !?", pc.PRESETS[0]["text"] + "\n\n",
          "first line\nsecond line\nlast line", pc.EARLIER.replace("two short", "three short"), pc.EARLIER.rsplit(" ", 1)[0]]
TEXTS = [p["text"] for p in pc.PRESETS] + CUSTOM
ENCODE_TEXTS = [pc.EARLIER, "", " ", "  two  spaces ", "line\nbreak\r\nand\ttab", "café 你好 😀 naïve", "1234567 and 89", "end!?.,;:", "\x00\x01\x7f", "퟿\U0010ffff", "x" * 40]

# Reads the tokenizer, the match and the sums out of the widget between its marker comments, runs them with Node, and compares every token,
# number and line of words with the ones Python wrote into the fixture.
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="pcw-config">([\s\S]*?)<\/script>/)[1]);
const js = html.slice(html.indexOf('/* ---------- the tokenizer'), html.indexOf('/* ---------- state'));
const api = new Function('cfg', "'use strict';" + js + '; return {encode, tokens, sharedStart, markTokens, view, money, percentLess, commas};')(cfg);
const bad = [];
let checks = 0;
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
fx.encodes.forEach(([text, ids]) => { checks++; if (!same(api.encode(text), ids)) bad.push(['encode', text, api.encode(text), ids]); });
fx.shared.forEach(([a, b, n]) => { checks++; if (api.sharedStart(a, b) !== n) bad.push(['sharedStart', a, b, n]); });
fx.marks.forEach(([text, doc, n]) => { checks++; if (api.markTokens(text, doc) !== n) bad.push(['markTokens', text, doc, n, api.markTokens(text, doc)]); });
fx.money.forEach(([u, text]) => { checks++; if (api.money(u) !== text) bad.push(['money', u, api.money(u), text]); });
fx.percent.forEach(([base, cost, n]) => { checks++; if (api.percentLess(base, cost) !== n) bad.push(['percentLess', base, cost, n, api.percentLess(base, cost)]); });
fx.commas.forEach(([n, text]) => { checks++; if (api.commas(n) !== text) bad.push(['commas', n, text]); });
fx.cases.forEach((c) => {
  checks++;
  const got = api.view(fx.earlier, fx.texts[c.t], cfg.rules[c.r], cfg.docs[c.d], cfg.ages[c.a], c.m === 1);
  if (!same(got, c.view)) bad.push(['view', c.t, c.r, c.d, c.a, c.m, got, c.view]);
});
console.log(JSON.stringify({ checks, bad: bad.slice(0, 3) }));
process.exit(bad.length ? 1 : 0);
"""

# Runs the page script against a small stand-in for the DOM, presses the buttons, types, moves the three settings, the mark box and the theme message,
# 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 config = html.match(/<script type="application\/json" id="pcw-config">([\s\S]*?)<\/script>/)[1];
const code = html.match(/<script>([\s\S]*?)<\/script>/)[1];

function el(tag) {
  const e = { tag, children: [], listeners: {}, value: '', checked: false, style: {}, attrs: {}, text: '' };
  Object.defineProperty(e, 'textContent', { get: () => e.text, set: (t) => { e.text = t; e.children = []; } });
  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);
  return e;
}
function open() {
  const page = { root: el('html'), messages: [], parent: { other: true } };
  fx.ids.forEach((id) => { page[id] = el('x'); });
  page['pcw-config'].text = config;
  page['pcw-mark'].checked = fx.markDefault;                 // the page's own HTML decides whether the box starts ticked
  const doc = { getElementById: (id) => page[id], createElement: el, 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]); };
const row = (box) => box.children.map((c) => [c.textContent, c.attrs['data-k'], c.attrs['data-doc'] ? 1 : 0]);
const selectNames = ['rule', 'doc', 'age'];
function look(page, t, s, what) {                  // compares the page with the view Python wrote for text t and settings s (indexes)
  const v = fx.views[[t, ...s].join('/')];
  if (!v) { bad.push([what, 'no fixture for', t, s]); return; }
  want(what + ': earlier row', row(page['pcw-old']), v.old);
  want(what + ': new row', row(page['pcw-new']), v.new);
  ['match', 'billed', 'cost', 'why'].forEach((k) => want(what + ': ' + k, page['pcw-' + k].textContent, v[k]));
}
function set(page, name, k) { page['pcw-' + name].value = String(k); fire(page['pcw-' + name], 'change'); }

const first = open();
look(first, 0, fx.defaults, 'opening state');
want('opening text', first['pcw-text'].value, fx.texts[0]);
want('nothing spoken yet', first['pcw-live'].textContent, '');
want('select values', selectNames.map((n) => first['pcw-' + n].value), fx.defaults.slice(0, 3).map(String));
want('mark box starts ticked', first['pcw-mark'].checked, true);
want('option words', selectNames.map((n) => first['pcw-' + n].children.map((o) => o.textContent)), fx.optionWords);
want('preset labels', first['pcw-btns'].children.map((b) => b.textContent), fx.presetLabels);
want('preset pressed at the opening', first['pcw-btns'].children.map((b) => b.attrs['aria-pressed']), ['true', 'false', 'false']);

// the three presets put their text in the box, mark themselves pressed, and speak
const press = open();
[0, 1, 2].forEach((k) => {
  fire(press['pcw-btns'].children[k]);
  look(press, k, fx.defaults, 'preset ' + k);
  want('preset ' + k + ' text', press['pcw-text'].value, fx.texts[k]);
  want('preset ' + k + ' pressed', press['pcw-btns'].children.map((b) => b.attrs['aria-pressed']), [0, 1, 2].map((j) => String(j === k)));
  const v = fx.views[[k, ...fx.defaults].join('/')];
  want('preset ' + k + ' spoken', press['pcw-live'].textContent, v.match + ' ' + v.cost);
});

// typing stays silent while it goes on and speaks when the box is left; no preset is pressed for a text of the reader's own
const typed = open();
typed['pcw-live'].textContent = '';
fx.typing.forEach((t) => {
  typed['pcw-text'].value = fx.texts[t];
  typed['pcw-live'].textContent = '';
  fire(typed['pcw-text'], 'input');
  look(typed, t, fx.defaults, 'typed text ' + t);
  want('typing stays silent at ' + t, typed['pcw-live'].textContent, '');
  want('no preset pressed at ' + t, typed['pcw-btns'].children.map((b) => b.attrs['aria-pressed']), ['false', 'false', 'false']);
  fire(typed['pcw-text'], 'change');
  const v = fx.views[[t, ...fx.defaults].join('/')];
  want('leaving the box speaks at ' + t, typed['pcw-live'].textContent, v.match + ' ' + v.cost);
});

// each of the three settings and the mark box, one option at a time, on the first preset; then all of them together
selectNames.forEach((name, n) => {
  const page = open();
  page['pcw-live'].textContent = '';
  fx.optionWords[n].forEach((w, k) => {
    const s = fx.defaults.slice(); s[n] = k;
    set(page, name, k);
    look(page, 0, s, name + ' = ' + w);
    const v = fx.views[[0, ...s].join('/')];
    want(name + ' = ' + w + ' spoken', page['pcw-live'].textContent, v.match + ' ' + v.cost);
  });
});
const unticked = open();
unticked['pcw-live'].textContent = '';
unticked['pcw-mark'].checked = false;
fire(unticked['pcw-mark'], 'change');
look(unticked, 0, [...fx.defaults.slice(0, 3), 0], 'box unticked');
const noMark = fx.views[[0, ...fx.defaults.slice(0, 3), 0].join('/')];
want('box unticked spoken', unticked['pcw-live'].textContent, noMark.match + ' ' + noMark.cost);
const mixed = open();
fx.mixed.forEach(([t, s]) => {
  mixed['pcw-text'].value = fx.texts[t];
  selectNames.forEach((name, n) => { mixed['pcw-' + name].value = String(s[n]); });
  mixed['pcw-mark'].checked = s[3] === 1;
  fire(mixed['pcw-mark'], 'change');
  look(mixed, t, s, 'mixed ' + t + ' ' + s);
});

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

console.log(JSON.stringify({ views: Object.keys(fx.views).length, bad: bad.slice(0, 4) }));
process.exit(bad.length ? 1 : 0);
"""


def outside_details(html):
    return re.sub(r"<details>.*?</details>", "", html, flags=re.S)


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 py_view(t, r, d, a, m):
    return pc.view(pc.EARLIER, TEXTS[t], pc.RULES[r], pc.DOC_LENGTHS[d], pc.AGES[a], bool(m))


def fixture_cases():
    """Every setting of the first three texts (the presets), and the reader's own texts at each rule, two documents, two ages and both mark states."""
    cases = {}
    for t in range(len(pc.PRESETS)):
        for r in range(len(pc.RULES)):
            for d in range(len(pc.DOC_LENGTHS)):
                for a in range(len(pc.AGES)):
                    for m in (0, 1):
                        cases[(t, r, d, a, m)] = None
    for t in range(len(pc.PRESETS), len(TEXTS)):
        for r in range(len(pc.RULES)):
            for d in (DEFAULTS[1], 1):
                for a in (DEFAULTS[2], 2):
                    for m in (0, 1):
                        cases[(t, r, d, a, m)] = None
    return {k: py_view(*k) for k in cases}


def test_widget_data_block_matches_the_python_constants():
    """Guards a 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 minimum or an example text must be put back."""
    drifted = HTML.replace('"minimum": 512', '"minimum": 256', 1).replace("Do you open on Sundays?", "Do you open on Mondays?", 1)
    assert drifted != HTML and embed(drifted) == HTML
    assert json.loads(CONFIG_BLOCK.search(HTML).group(2)) == json.loads(json.dumps(config()))


def test_every_element_the_script_looks_up_exists_inside_the_root():
    """Guards a widget that never starts: the parity run covers only the sums, so a misspelt id passed there and threw in the browser."""
    script = HTML[HTML.index("<script>"):]
    lookups = set(re.findall(r"\$\('([\w-]+)'\)", script)) | {f"pcw-{k}" for k in ("match", "billed", "cost", "why")}
    assert "$('pcw-' + k)" in script                      # the four text lines are looked up by suffix; they are added above
    missing = sorted(i for i in lookups if i != "pcw-config" and f'id="{i}"' not in ROOT)
    assert not missing, missing
    assert 'id="pcw-config"' in HTML


def test_widget_is_small_and_asks_for_nothing_online():
    assert len(HTML.splitlines()) <= 300 and len(HTML.encode("utf-8")) < 40 * 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: the box, the mark checkbox and the preset buttons (built by the script) count, and the three menus stay in a closed details element."""
    outside = outside_details(ROOT)
    assert len(re.findall(r"<(?:select|button|input|textarea)\b", outside)) + len(pc.PRESETS) <= 5
    assert "<details><summary>More settings</summary>" in ROOT and "<details open" not in ROOT
    assert len(re.findall(r"<select\b", re.search(r"<details><summary>More settings</summary>.*?</details>", ROOT, re.S).group(0))) == 3


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 ("--bg: light-dark(#ffffff, #0a0f17)", "--panel: light-dark(#ebf0f6, #131b28)", "--ink: light-dark(#0a1626, #f4f7fb)",
                 "--mute: light-dark(#44566b, #a3b1c4)", "--ctl: light-dark(#66768a, #7a8a9f)", "--ink-on: 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()
    assert not set(re.findall(r"#[0-9a-fA-F]{3,8}\b", HTML)) - {"#ffffff", "#0a0f17", "#ebf0f6", "#131b28", "#0a1626", "#f4f7fb", "#44566b", "#a3b1c4",
                                                               "#66768a", "#7a8a9f", "#f6f8fb", "#05070c", "#00855F", "#009E73", "#b36200", "#E69F00"}


def test_widget_tokenizer_match_and_sums_give_the_tokens_numbers_and_words_of_python(tmp_path):
    """Guards the widget's JavaScript drifting from prefix_cache.py and bpe.py: the lesson prints Python's numbers and the reader sees the
    widget's, so the two must agree on every token, on the length of the shared start, on the rounding of money and percent, and on
    every row and line of words for every setting."""
    learned = pc.merges()
    ints = [([1, 2, 3], [1, 2, 3, 4]), ([1, 2, 3], [9, 2, 3]), ([], [1]), ([5, 6, 7, 8], [5, 6, 0, 8]), ([1], [1])]
    marks = [[text, doc, pc.mark_tokens(text, doc)] for text in TEXTS for doc in pc.DOC_LENGTHS]
    prices = [(3, 1), (200, 100), (1, 0), (2, 3), (7, 8), (1000, 995), (1000, 1005), (1000, 1000), (6, 5), (5, 6)]
    cases = fixture_cases()
    fixture = {"earlier": pc.EARLIER, "texts": TEXTS, "encodes": [[t, pc.bpe.encode(t, learned)] for t in ENCODE_TEXTS],
               "shared": [[a, b, pc.shared_start(a, b)] for a, b in ints], "marks": marks, "commas": [[n, pc.commas(n)] for n in (0, 7, 999, 1000, 12345, 1234567)],
               "money": [[u, pc.money(u)] for u in (0, 499, 500, 999, 1500, 2_508_000, 999_999_500, 1_234_567_890)],
               "percent": [[a, b, pc.percent_less(a, b)] for a, b in prices],
               "cases": [{"t": k[0], "r": k[1], "d": k[2], "a": k[3], "m": k[4], "view": v} for k, v in cases.items()]}
    result = run_node(tmp_path, PARITY, fixture, lambda d: [str(d / "fixture.json"), str(WIDGET)])
    assert result["bad"] == [] and result["checks"] == len(ENCODE_TEXTS) + len(ints) + len(marks) + 6 + 8 + len(prices) + len(cases)
    reasons = ("The prompt is shorter", "The earlier request stores", "The earlier request is older", "The new request begins", "The very first token",
               "The new request stops", "Nothing is reused")
    assert all(any(v["why"].startswith(r) for v in cases.values()) for r in reasons)      # the cases reach every kind of reason


def test_widget_page_shows_what_the_lesson_names(tmp_path):
    """Guards the part a reader sees: the opening state (53 tokens in the earlier row, 94% less, and 25% more with the box unticked), the
    three presets, typing, each of the three settings and the mark box, the theme message, and the words that keep a toy from reading as a measurement (the caveat line, the key, the
    closed panels, the live region)."""
    opening = py_view(0, *DEFAULTS)
    assert opening["match"].startswith("1,236 of 1,254 tokens are reused") and opening["cost"].endswith("94% less.") and opening["oldCount"] == 1253
    unticked = py_view(0, *DEFAULTS[:3], 0)                  # automatic mode: a new question after a shared document gets no hit and pays the write price
    assert unticked["match"].startswith("No token is reused: without a mark") and unticked["cost"].endswith("25% more.")
    outside = outside_details(ROOT)
    assert '<input type="checkbox" id="pcw-mark" checked>Mark the end of the fixed part</label>' in outside
    assert "A real model splits text differently." in outside                                       # visible without opening anything
    assert re.findall(r'<li><i data-k="(\w+)"></i><span>(.*?)</span></li>', ROOT) == [("match", "Stored start, reused"), ("rest", "Not reused")]
    leaves_out = re.search(r"<details><summary>What this toy leaves out</summary>(.*?)</details>", ROOT, re.S).group(1)
    for said in ("earlier than its lifetime", "two requests may not reach the same copy", "Images and tools follow their own rules", "Real prices change"):
        assert said in leaves_out, said
    assert 'class="pcw-sr" id="pcw-live" role="status" aria-live="polite" aria-atomic="true"' in ROOT      # what a screen reader hears
    assert ".pcw-root .pcw-btns button { flex: 1 1 auto; white-space: nowrap; }" in HTML      # a button wraps to the next row, never its label
    words = [[r["summary"] for r in config()["rules"]], ["none", "400 tokens", "1,200 tokens", "5,000 tokens"],
             ["1 minute ago", "4 minutes ago", "6 minutes ago", "40 minutes ago", "2 hours ago"]]
    assert words[0][0] == "A: write 1.25x, read 0.05x, 5 min"
    cases = fixture_cases()
    mixed = [[t, [3, 1, 3, 0]] for t in (0, 3, 4)] + [[1, [1, 3, 1, 1]], [2, [2, 0, 0, 0]]]
    fixture = {"ids": sorted(set(re.findall(r'id="(pcw-[\w-]+)"', HTML))), "texts": TEXTS, "defaults": list(DEFAULTS), "optionWords": words,
               "presetLabels": [p["name"] for p in pc.PRESETS], "typing": [3, 6, 5, 4], "mixed": mixed, "markDefault": 'id="pcw-mark" checked' in ROOT,
               "views": {"/".join(map(str, k)): v for k, v in cases.items()}}
    for t, s in mixed:                                                       # the mixed settings need a view written for them too
        fixture["views"].setdefault("/".join(map(str, (t, *s))), py_view(t, *s))
    result = run_node(tmp_path, PAGE, fixture, lambda d: [str(WIDGET), str(d / "fixture.json")])
    assert result["bad"] == [] and result["views"] >= len(cases)
