"""Guards for ../widget.html: its data block, that its plan gives the same numbers and words as capacity_plan.py, and what a reader sees and can press."""
import json
import re
import subprocess

import capacity_plan as cp
from embed_widget import CONFIG_BLOCK, WIDGET, config, embed

HTML = WIDGET.read_text(encoding="utf-8")
GRID = [(u, a, r, mix, t) for u in (100, 1000, 2500, 5000) for a in (10, 60, 100) for r in (10, 120, 300) for mix in cp.MIXES for t in cp.TARGETS]

# Reads the plan out of the widget between its marker comments, runs every setting of the grid with Node and compares the two bars and the four lines of words with
# the ones Python wrote into the fixture, character for character.
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="cp-config">([\s\S]*?)<\/script>/)[1]);
const js = html.slice(html.indexOf('/* ---------- the model ('), html.indexOf('/* ---------- display'));
const api = new Function('cfg', "'use strict';" + js + '; return {describe};')(cfg);
const bad = [];
fx.cases.forEach((c) => {
  const got = api.describe(...c.args);
  if (JSON.stringify(got) !== JSON.stringify(c.want)) bad.push([c.args, JSON.stringify(got).slice(0, 300), JSON.stringify(c.want).slice(0, 300)]);
});
console.log(JSON.stringify({ checks: fx.cases.length, bad: bad.slice(0, 3) }));
process.exit(bad.length ? 1 : 0);
"""

# Runs the page script against a small stand-in for the DOM, moves the sliders, picks another chat mix 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="cp-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: {}, textContent: '' };
  e.setAttribute = (k, v) => { e.attrs[k] = v; };
  e.getAttribute = (k) => e.attrs[k];
  e.appendChild = (c) => { e.children.push(c); };
  e.addEventListener = (type, f) => (e.listeners[type] = e.listeners[type] || []).push(f);
  return e;
}
const page = { root: el('html'), messages: [], parent: { other: true } };
[...html.matchAll(/id="(cp-[^"]+)"/g)].forEach((m) => { page[m[1]] = el('x'); });
page['cp-config'].textContent = config;
const doc = { getElementById: (id) => page[id], documentElement: page.root, createElement: el };
const win = { addEventListener: (type, f) => page.messages.push(f) };
new Function('document', 'window', 'parent', code)(doc, win, page.parent);
const fire = (e, type) => (e.listeners[type] || []).forEach((f) => f({ target: e }));
const bad = [];
const want = (what, got, expected) => { if (JSON.stringify(got) !== JSON.stringify(expected)) bad.push([what, JSON.stringify(got).slice(0, 300), JSON.stringify(expected).slice(0, 300)]); };
const shown = () => ({ names: [0, 1].map((k) => page['cp-name-' + k].textContent), widths: [0, 1].map((k) => parseFloat(page['cp-bar-' + k].style.width)),
  line: page['cp-line'].textContent, rate: page['cp-rate'].textContent, limits: page['cp-limits'].textContent, note: page['cp-note'].textContent });
const cfg = JSON.parse(config);
want('opening controls', [page['cp-users'].value, page['cp-active'].value, page['cp-replies'].value, page['cp-target'].value, page['cp-target'].max, page['cp-mix'].value],
  [String(cfg.opening.users), String(cfg.opening.active), String(cfg.opening.replies), String(cfg.targets.indexOf(cfg.opening.target)), String(cfg.targets.length - 1), cfg.opening.mix]);
want('the chat mixes offered', page['cp-mix'].children.map((o) => [o.value, o.textContent]), fx.options);
want('opening state', shown(), fx.opening);
want('nothing spoken yet', page['cp-live'].textContent, '');
page['cp-target'].value = '0';
fire(page['cp-target'], 'input');
want('30 ms, dragging', shown(), fx.thirty);
want('dragging stays silent', page['cp-live'].textContent, '');
fire(page['cp-target'], 'change');
want('release speaks', page['cp-live'].textContent, fx.thirty.line + ' ' + fx.thirty.limits);
want('words on the controls', [page['cp-users-out'].textContent, page['cp-active-out'].textContent, String(page['cp-replies-out'].textContent), page['cp-target-out'].textContent,
  page['cp-target'].attrs['aria-valuetext'], page['cp-users'].attrs['aria-valuetext']], ['1,000', '60%', '120', '30 ms', '30 milliseconds', '1,000 users']);
fx.others.forEach((c) => {
  page['cp-mix'].value = c.args[3];
  fire(page['cp-mix'], 'change');
  want('picking ' + c.args[3] + ' sets its replies per user', page['cp-replies'].value, String(cfg.mixes[c.args[3]].each));
  page['cp-users'].value = String(c.args[0]); page['cp-active'].value = String(c.args[1]); page['cp-replies'].value = String(c.args[2]);
  page['cp-target'].value = String(cfg.targets.indexOf(c.args[4]));
  fire(page['cp-replies'], 'input');
  want('state ' + c.args.join('/'), shown(), c.want);
});
const theme = (source, name) => page.messages.forEach((f) => f({ data: { type: 'spicyshark:theme', theme: name }, source }));
theme({ other: 'frame' }, 'dark');
want('theme from another sender is ignored', page.root.attrs['data-theme'], undefined);
theme(page.parent, 'dark');
want('theme from the host page', page.root.attrs['data-theme'], 'dark');
console.log(JSON.stringify({ bad: bad.slice(0, 3) }));
process.exit(bad.length ? 1 : 0);
"""


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


def seen(users, active, replies, mix, target):
    """What the page shows for one setting, in the form the PAGE script reads back."""
    d = cp.describe(users, active, replies, mix, target)
    widest = max(d["bars"])
    return {"names": [f"Memory needs {cp.gpu_word(d['bars'][0])}", f"Speed needs {cp.gpu_word(d['bars'][1])}"], "widths": [max(2, n / widest * 100) for n in d["bars"]],
            "line": d["line"], "rate": d["rate"], "limits": d["limits"], "note": d["note"]}


def test_widget_data_block_matches_the_python_module():
    assert embed(HTML) == HTML
    block = json.loads(CONFIG_BLOCK.search(HTML).group(2))
    assert block == json.loads(json.dumps(config()))
    assert block["opening"] == {"users": 1000, "active": 60, "replies": 120, "target": 50, "mix": "usual"}
    for name, (low, high, step) in cp.SLIDERS.items():                                   # each slider covers the settings the grid below covers
        assert re.search(r'id="cp-%s" min="%d" max="%d" step="%d"' % (name, low, high, step), HTML), name


def test_widget_gives_the_same_numbers_and_words_as_python(tmp_path):
    cases = [{"args": list(g), "want": cp.describe(*g)} for g in GRID]
    lines = " ".join(case["want"]["line"] + case["want"]["rate"] for case in cases)
    assert "less than 1 chat" in lines and "lower the replies per user" in lines and "memory sets the limit" in " ".join(c["want"]["limits"] for c in cases)    # the grid reaches the odd cases
    (tmp_path / "fx.json").write_text(json.dumps({"cases": cases}), encoding="utf-8")
    assert run_node(tmp_path, PARITY, str(tmp_path / "fx.json"), str(WIDGET))["checks"] == len(GRID)


def test_widget_page_shows_the_opening_state_and_follows_the_controls(tmp_path):
    opening = seen(1000, 60, 120, "usual", 50)
    assert opening["line"] == "1,000 users: about 110 chats at once in the busiest hour. Speed needs 4 GPUs, memory 1 GPU. Plan 5 GPUs, with a spare."
    others = [(5000, 100, 300, "answers", 75), (100, 10, 10, "usual", 50), (1000, 60, 120, "documents", 30), (1000, 60, 30, "answers", 50)]
    fixture = {"options": [[mix, m["label"]] for mix, m in cp.MIXES.items()], "opening": opening, "thirty": seen(1000, 60, 120, "usual", 30),
               "others": [{"args": list(o), "want": seen(*o)} for o in others]}
    (tmp_path / "fx.json").write_text(json.dumps(fixture), encoding="utf-8")
    run_node(tmp_path, PAGE, str(WIDGET), str(tmp_path / "fx.json"))


def test_widget_is_small_themed_and_uses_only_the_site_palette():
    body = HTML[HTML.index('<div class="cp-root">'):HTML.index("<script")]
    visible = re.sub(r"<details.*?</details>", "", body, flags=re.S)
    assert len(re.findall(r"<input|<select|<button", visible)) <= 5 and "More settings" in body and "A paper plan" in visible     # at most five controls on show, and the caveat is always on show
    assert len(HTML.splitlines()) < 300 and "light-dark(#ffffff, #0a0f17)" in HTML and "spicyshark:theme" in HTML and "prefers-reduced-motion" in HTML
    assert not re.search(r"0072B2|56B4E9|D55E00|ht" r"tps?://|<img|@import", HTML, re.I)
