"""Guards for ../widget.html: its data blocks, its size, its wording, and that its tokenizer is bpe.py's."""
import json
import random
import re
import shutil
import string
import subprocess

import pytest

import embed_widget
from embed_widget import WIDGET, embed
from letters import MAX_WORD, SEPARATOR, chunks, toy_merges

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

# Reads the tokenizer out of the widget between its marker comments (everything above "state" needs the page),
# runs it, and compares with the ids, spellings and start offsets 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 merges = JSON.parse(html.match(/id="ltr-merges">\s*([\s\S]*?)\s*<\/script>/)[1]);
const js = html.slice(html.indexOf('/* ---------- the tokenizer'), html.indexOf('/* ---------- state'));
const api = new Function("'use strict'; var merges = arguments[0];" + js + '; return {chunks};')(merges);
const bad = [];
for (const f of fx) {
  const got = api.chunks(f.text, f.k).map(c => [c.id, c.text, c.start]);
  if (JSON.stringify(got) !== JSON.stringify(f.want)) bad.push([f.text, f.k, got, f.want]);
}
console.log(JSON.stringify({ checks: fx.length, bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""

# Calls describe() the way paint() does and prints the sentences it builds.
SENTENCES = r"""
import fs from 'node:fs';
const html = fs.readFileSync(process.argv[2], 'utf8');
const merges = JSON.parse(html.match(/id="ltr-merges">\s*([\s\S]*?)\s*<\/script>/)[1]);
const js = html.slice(html.indexOf('/* ---------- the tokenizer'), html.indexOf('/* ---------- state'));
const api = new Function("'use strict'; var merges = arguments[0];" + js + '; return {chunks, describe};')(merges);
const out = [];
for (const [word, text, letter, k] of [['strawberry', 'strawberry', 'r', 512], ['strawberry', 'strawberry', 'e', 512],
    ['strawberry', 'strawberry', 'z', 512], ['strawberry', 'strawberry', 'r', 0], ['strawberry', 's-t-r-a-w-b-e-r-r-y', 'r', 512],
    ['a', 'a', 'a', 512], ['the', 'the', 'h', 512], ['Strawberry', 'Strawberry', 'S', 512]]) {
  const d = api.describe(word, text, letter, api.chunks(text, k));
  out.push([d.n, d.headline, d.cut, d.ids, d.verdict]);
}
console.log(JSON.stringify(out));
"""

SENTENCES_WANT = [
    [3, "The letter r is in “strawberry” 3 times.", "The toy cuts “strawberry” into 7 chunks: st | r | a | w | b | er | ry.",
     "The model is given these numbers: 292, 114, 97, 119, 98, 263, 587.",
     "The r hides inside 2 chunks that hold other letters too: er (263), ry (587). The numbers alone do not say which letters are inside."],
    [1, "The letter e is in “strawberry” once.", "The toy cuts “strawberry” into 7 chunks: st | r | a | w | b | er | ry.",
     "The model is given these numbers: 292, 114, 97, 119, 98, 263, 587.",
     "The e hides inside 1 chunk that holds other letters too: er (263). The number alone does not say which letters are inside."],
    [0, "The letter z is not in “strawberry”.", "The toy cuts “strawberry” into 7 chunks: st | r | a | w | b | er | ry.",
     "The model is given these numbers: 292, 114, 97, 119, 98, 263, 587.", "No chunk holds the letter z."],
    [3, "The letter r is in “strawberry” 3 times.", "The toy cuts “strawberry” into 10 chunks: s | t | r | a | w | b | e | r | r | y.",
     "The model is given these numbers: 115, 116, 114, 97, 119, 98, 101, 114, 114, 121.", "Every r is a chunk of its own, the easy case."],
    [3, "The letter r is in “strawberry” 3 times.",
     "The toy cuts “s-t-r-a-w-b-e-r-r-y” into 19 chunks: s | - | t | - | r | - | a | - | w | - | b | - | e | - | r | - | r | - | y.",
     "The model is given these numbers: 115, 45, 116, 45, 114, 45, 97, 45, 119, 45, 98, 45, 101, 45, 114, 45, 114, 45, 121.",
     "Every r is a chunk of its own, the easy case."],
    [1, "The letter a is in “a” once.", "The toy keeps “a” whole, as 1 chunk.", "The model is given this number: 97.",
     "Every a is a chunk of its own, the easy case."],
    [1, "The letter h is in “the” once.", "The toy cuts “the” into 2 chunks: t | he.", "The model is given these numbers: 116, 257.",
     "The h hides inside 1 chunk that holds other letters too: he (257). The number alone does not say which letters are inside."],
    [1, "The letter S is in “Strawberry” once.", "The toy cuts “Strawberry” into 8 chunks: S | t | r | a | w | b | er | ry.",
     "The model is given these numbers: 83, 116, 114, 97, 119, 98, 263, 587.", "Every S is a chunk of its own, the easy case."],
]


def with_offsets(pairs):
    """[id, spelling, start] for each (id, spelling): where the chunk begins in the text, as the widget's grid needs."""
    start, out = 0, []
    for i, s in pairs:
        out.append([i, s, start])
        start += len(s)
    return out


def run_node(tmp_path, name, script, *args):
    (tmp_path / name).write_text(script, encoding="utf-8")
    return subprocess.run(["node", str(tmp_path / name), *args], capture_output=True, text=True, timeout=120)


def test_widget_data_blocks_match_the_python_code():
    """Guards a change to the merges, the starting word or the examples in the code that was not copied into the
    widget (run embed_widget.py)."""
    assert embed(HTML) == HTML


def test_every_element_the_script_looks_up_exists_where_it_looks():
    """Guards a widget that never starts: a lookup of an id that is not in the markup throws in the browser, and
    two elements with one id (the range and the merges data block once shared ltr-merges) make a lookup return the wrong one."""
    root = HTML[HTML.index('<div class="ltr-root">'):HTML.index("<script")]
    script = HTML[HTML.index("<script>"):]
    listed = script[script.index("['ltr-word'"):script.index("].forEach")]
    ids = set(re.findall(r"\$\('([\w-]+)'\)", script)) | set(re.findall(r"'(ltr-[\w-]+)'", listed))
    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 "$('ltr-config')" not in script and "$('ltr-merges')" not in script
    every_id = re.findall(r'\sid="([\w-]+)"', HTML)
    assert len(every_id) == len(set(every_id)), sorted(i for i in set(every_id) if every_id.count(i) > 1)


def test_widget_is_small_and_asks_for_nothing_online():
    """The widget runs in a sandbox that cannot open links, and says it uses no network: it holds no address."""
    assert len(HTML.encode("utf-8")) < 40 * 1024
    page = embed_widget.MERGES_BLOCK.sub("", embed_widget.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():
    """The lesson page loads the widget in a sandbox where a form cannot submit; inputs are read on change."""
    assert "<form" not in HTML


def test_widget_shows_four_controls_and_keeps_the_rest_under_more_settings():
    """Guards the brief's limit on visible controls: the word, the letter, the example list and the spell-out box
    sit in the open, and the merge slider is inside a closed details element."""
    open_part, _, more = HTML[HTML.index('<div class="ltr-ctl">'):HTML.index("</details>")].partition('<details class="ltr-more">')
    assert len(re.findall(r"<(?:input|select)\b", open_part)) == 4
    assert "<summary>More settings</summary>" in more and 'type="range"' in more and "<details open" not in HTML


def test_widget_sentences_read_right(tmp_path):
    """Guards the plural slips ('1 chunks', 'these numbers' for one id), the wording of the verdict for a hidden letter,
    an easy letter, a missing letter, a one-chunk word and a capital letter, and the lesson's starting word."""
    if shutil.which("node") is None:
        pytest.skip("Node.js is not installed")
    run = run_node(tmp_path, "sentences.mjs", SENTENCES, str(WIDGET))
    assert run.returncode == 0, run.stdout + run.stderr
    assert json.loads(run.stdout) == SENTENCES_WANT


def test_widget_tokenizer_matches_the_python_code(tmp_path):
    """Guards the widget's JavaScript drifting from bpe.py: for random ASCII text (capitals, spaces, hyphens, digits,
    punctuation) and for the lesson's words, with 0, 100 and all merges, ids, spellings and start offsets must be equal."""
    if shutil.which("node") is None:
        pytest.skip("Node.js is not installed")
    rng = random.Random(3)
    alphabet = string.ascii_letters * 3 + string.digits + " -.,'\n\t"
    texts = ["strawberry", " strawberry", "Strawberry", "STRAWBERRY", SEPARATOR.join("strawberry"), " ".join("banana")]
    texts += ["".join(rng.choice(alphabet) for _ in range(rng.randint(1, 24))) for _ in range(250)]
    texts += ["".join(rng.choice(string.ascii_lowercase) for _ in range(rng.randint(1, MAX_WORD))) for _ in range(250)]
    fixture = [{"text": t, "k": k, "want": with_offsets(chunks(t, MERGES[:k]))} for t in texts for k in (0, 100, len(MERGES))]
    (tmp_path / "fixture.json").write_text(json.dumps(fixture), encoding="utf-8")
    run = run_node(tmp_path, "parity.mjs", PARITY, str(tmp_path / "fixture.json"), str(WIDGET))
    assert run.returncode == 0, run.stdout + run.stderr
    assert json.loads(run.stdout)["checks"] == len(fixture) >= 1500


def test_widget_starts_from_the_lesson_example():
    """Guards the lesson text and the widget starting from different words: the config is strawberry and r."""
    cfg = embed_widget.config()
    assert (cfg["word"], cfg["letter"], cfg["maxWord"], cfg["separator"]) == ("strawberry", "r", MAX_WORD, "-")
    assert ["strawberry", "r"] in cfg["examples"]
