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

from embed_widget import WIDGET, config, embed
from leak_sim import EXPERIMENTS, GUESSES, RIGHT, TRIES, simulate, timing, totals

HTML = WIDGET.read_text(encoding="utf-8")
FIGS = sorted((WIDGET.parent / "figs").glob("*.svg"))

# 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="lk-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 {simulate, totals, percent};')(cfg);
const bad = [];
fx.modes.forEach((m) => {
  const steps = api.simulate(m.perUser);
  if (JSON.stringify(steps) !== JSON.stringify(m.steps)) bad.push(['steps', m.perUser]);
  if (JSON.stringify(api.totals(steps)) !== JSON.stringify(m.totals)) bad.push(['totals', m.perUser]);
});
fx.percents.forEach(([a, b, want]) => { if (api.percent(a, b) !== want) bad.push(['percent', a, b]); });
console.log(JSON.stringify({ modes: fx.modes.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 fx = JSON.parse(fs.readFileSync(fixture, 'utf8'));
const cfgText = html.match(/<script type="application\/json" id="lk-config">([\s\S]*?)<\/script>/)[1];
const code = html.match(/<script>([\s\S]*?)<\/script>/)[1];

function el(tag) {
  const e = { tag, children: [], listeners: {}, attrs: {}, checked: false, style: {}, _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] = String(v); };
  Object.defineProperty(e, 'textContent', { get: () => e._text, set: (v) => { e._text = v; e.children = []; } });
  return e;
}
function open() {
  const page = {};
  ['lk-config', 'lk-live', 'lk-key', 'lk-keyline', 'lk-big', 'lk-detail', 'lk-first', 'lk-looked', 'lk-sent', 'lk-right', 'lk-label', 'lk-grid',
   'lk-tnote', 'lk-rows', 'lk-pick', 'lk-why'].forEach((id) => { page[id] = el('x'); });
  page['lk-config']._text = cfgText;
  const doc = { getElementById: (id) => page[id], createElement: el, documentElement: { setAttribute() {} } };
  new Function('document', 'window', 'parent', code)(doc, { addEventListener() {} }, {});
  return page;
}
const fire = (e) => (e.listeners.change || e.listeners.click || []).forEach((f) => f());
const chips = (page) => page['lk-grid'].children;
const bad = [];
const want = (what, got, expected) => { if (got !== expected) bad.push([what, got, expected]); };

const start = open();
want('opening: headline', start['lk-big']._text, fx.modes[0].big);
want('opening: first line names the leak', start['lk-first']._text, fx.opening);
want('opening: the leak is marked in the panel', start['lk-detail'].attrs['data-state'], 'leak');
want('opening: chip count', chips(start).length, 14);
want('opening: the switch starts off', start['lk-key'].checked, false);
want('opening: the opening request is pressed', chips(start).map((c) => c.attrs['aria-pressed']).join(), fx.modes[0].pressedAtOpening);
want('opening: timing note carries the run count', start['lk-tnote']._text.includes('2,000 runs'), true);

fx.modes.forEach((m) => {
  const page = open();
  page['lk-key'].checked = m.perUser;
  fire(page['lk-key']);
  want('headline, per user ' + m.perUser, page['lk-big']._text, m.big);
  want('key line, per user ' + m.perUser, page['lk-keyline']._text, m.keyline);
  want('spoken update after the switch, per user ' + m.perUser, page['lk-live']._text, m.requests[1].live);
  want('chip labels, per user ' + m.perUser, chips(page).map((c) => c._text).join('|'), m.chips.join('|'));
  want('chip states, per user ' + m.perUser, chips(page).map((c) => c.attrs['data-state']).join(), m.states.join());
  m.requests.forEach((r, i) => {
    fire(chips(page)[i]);
    ['first', 'looked', 'sent', 'right', 'live'].forEach((k) => want('request ' + (i + 1) + ' ' + k + ', per user ' + m.perUser, page['lk-' + k]._text, r[k]));
    want('request ' + (i + 1) + ' is the only one pressed', chips(page).filter((c) => c.attrs['aria-pressed'] === 'true').length === 1 && chips(page)[i].attrs['aria-pressed'] === 'true', true);
  });
  want('timing rows, per user ' + m.perUser, page['lk-rows'].children.map((r) => r.children[0]._text).join('|'), m.rows.join('|'));
  want('timing bar widths, per user ' + m.perUser, page['lk-rows'].children.map((r) => r.children[1].style.width).join('|'), m.bars.join('|'));
  want('timing pick, per user ' + m.perUser, page['lk-pick']._text, m.pick);
  want('timing reason, per user ' + m.perUser, page['lk-why']._text, m.why);
});
console.log(JSON.stringify({ modes: fx.modes.length, bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""


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


def label(s):
    return f"{s.n} {s.user} " + ("LEAK" if s.leak else "hit" if s.hit else "miss")


def first_line(steps, i):
    s = steps[i]
    asked = f'User {s.user} asked "{s.question}"'
    if s.leak:
        return f"Leak: {asked} and got User {steps[s.written_by - 1].user}'s answer."
    if s.hit:
        return f"{asked} and got the answer saved at request {s.written_by}. It was right."
    return f"{asked}. Nothing was saved under this key, so the bot answered and saved it."


def test_widget_data_block_matches_leak_sim():
    """Guards a leak_sim.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="lk-root">'):HTML.index("<script")]
    lookups = set(re.findall(r"\$\('([\w-]+)'\)", HTML[HTML.index("<script>"):]))
    assert lookups and all(f'id="{i}"' in root for i in lookups - {"lk-config"}), 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="lk-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 and "<form" not in page


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 "prefers-reduced-motion: no-preference" in HTML


def test_widget_rules_match_the_python_toy(tmp_path):
    """Guards the widget's JavaScript drifting from leak_sim.py: both keys, all 14 requests, every field, and the rounded percentages."""
    modes = []
    for per_user in (False, True):
        steps = simulate(per_user)
        modes.append({"perUser": per_user, "totals": totals(steps),
                      "steps": [{"n": s.n, "user": s.user, "question": s.question, "key": s.key, "hit": s.hit, "writtenBy": s.written_by,
                                 "served": s.served, "right": s.right, "leak": s.leak} for s in steps]})
    percents = [[a, b, (200 * a + b) // (2 * b)] for b in range(1, 40) for a in range(b + 1)]
    (tmp_path / "fixture.json").write_text(json.dumps({"modes": modes, "percents": percents}), encoding="utf-8")
    assert run_node(tmp_path, "parity.mjs", PARITY, str(tmp_path / "fixture.json"), str(WIDGET)) == {"modes": 2, "bad": []}


def test_widget_page_shows_what_the_lesson_names(tmp_path):
    """Guards the part a reader sees: it opens on a leak with its sentence, the headline counts, a word on every request (not colour alone),
    the detail of each request in both modes, the sentence spoken to a screen reader after each change, and the timing numbers and bar
    widths that follow the switch."""
    modes = []
    for per_user in (False, True):
        steps, t = simulate(per_user), totals(simulate(per_user))
        sim = timing(per_user)
        avg = [round(w) for w in sim["average"]]
        axis = config()["axis"]
        modes.append({
            "perUser": per_user,
            "big": f"{t['leaks']} of 14 answers went to the wrong user. Hit rate {t['rate']}% ({t['hits']} of 14).",
            "keyline": "Cache key: " + ("the user plus the question." if per_user else "the question alone."),
            "chips": [label(s) for s in steps], "states": ["leak" if s.leak else "hit" if s.hit else "miss" for s in steps],
            "pressedAtOpening": ",".join(["true" if s.n == 2 else "false" for s in steps]),
            "requests": [{"first": first_line(steps, i), "looked": "Key looked up: " + s.key, "sent": "Answer sent: " + s.served,
                          "right": f"Right answer for User {s.user}: {s.right}" if s.leak else "",
                          "live": f"{t['leaks']} of 14 answers went to the wrong user. " + first_line(steps, i)} for i, s in enumerate(steps)],
            "rows": [g + (" (the real street)" if i == RIGHT else "") + f": {avg[i]} ms" for i, g in enumerate(GUESSES)],
            "bars": [f"{a / axis * 100:.1f}%" for a in avg],
            "pick": f"Picking the fastest guess finds the real street {sim['one']}% of the time from one try each and {sim['many']}% from the average of {TRIES}. Guessing blind gets 33%.",
            "why": "Every guess waits the same, so the wait tells User B nothing." if per_user else "The real street waits less, because User A’s stored start is reused for User B."})
    assert modes[0]["chips"][1] == "2 B LEAK" and sum("LEAK" in c for c in modes[0]["chips"]) == 6 and not any("LEAK" in c for c in modes[1]["chips"])
    fixture = {"modes": modes, "opening": 'Leak: User B asked "Where is my order?" and got User A\'s answer.'}
    (tmp_path / "fixture.json").write_text(json.dumps(fixture), encoding="utf-8")
    assert EXPERIMENTS == 2000
    assert run_node(tmp_path, "page.mjs", PAGE_TEST, str(WIDGET), str(tmp_path / "fixture.json")) == {"modes": 2, "bad": []}


def test_figures_are_valid_and_stay_on_the_site_palette():
    """Guards a figure that breaks the site's look or its checks: every colour in a figure is one of the site's, and each has a title,
    a description, a viewBox and a white background."""
    allowed = {"#0a1626", "#44566b", "#b36200", "#00855f", "#d2dae4", "#ebf0f6", "#ffffff", "none"}
    assert 3 <= len(FIGS) <= 5
    for path in FIGS:
        svg = path.read_text(encoding="utf-8")
        used = {c.lower() for c in re.findall(r'(?:fill|stroke)="([^"]+)"', svg)}
        assert used <= allowed, (path.name, used - allowed)
        assert "viewBox=" in svg and "<title>" in svg and "<desc>" in svg and 'fill="#ffffff"' in svg
        assert "<script" not in svg and "href=" not in svg and "<image" not in svg
