"""Guards for ../widget.html: its data block, that its sums give the same numbers, words and chart paths as rent_vs_own.py, and what a reader sees and can press."""
import json
import random
import re
import subprocess

import rent_vs_own as r
from embed_widget import CONFIG_BLOCK, WIDGET, config, embed

HTML = WIDGET.read_text(encoding="utf-8")
BASE = (300, 24, 10_000, 0.10, 0.50, 3.0, 1)
HOURS_SHOWN = (1, 4, 8, 12, 13, 14, 18, 24)


def grid():
    """Every monthly tokens setting against the hours that flip the answer and three people costs; every value of every other control one at a time, at three sizes; and a
    seeded sample of all seven at once."""
    cases = [(t, h, p, *BASE[3:]) for t in r.TOKENS_B for h in HOURS_SHOWN for p in (0, 10_000, 40_000)]
    values = {1: range(r.HOURS_RANGE[0], r.HOURS_RANGE[1] + 1), 2: range(r.PEOPLE_RANGE[0], r.PEOPLE_RANGE[1] + 1, r.PEOPLE_RANGE[2]), 3: r.PRICES_IN, 4: r.PRICES_OUT,
              5: r.GPU_HOURS, 6: r.SPARES}
    for t in (1, 300, 2000):
        for slot, options in values.items():
            cases += [(t, *BASE[1:slot], v, *BASE[slot + 1:]) for v in options]
    pick = random.Random(30)
    cases += [(pick.choice(r.TOKENS_B), pick.randint(1, 24), pick.randrange(0, 40_001, 1000), pick.choice(r.PRICES_IN), pick.choice(r.PRICES_OUT), pick.choice(r.GPU_HOURS),
               pick.choice(r.SPARES)) for _ in range(300)]
    return cases


# Reads the sums out of the widget between its marker comments, runs every setting of the grid with Node and compares the three lines of words and the chart paths 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, 400), JSON.stringify(c.want).slice(0, 400)]);
});
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 and reads back what a reader sees and hears.
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, 400), JSON.stringify(expected).slice(0, 400)]); };
const cfg = JSON.parse(config);
const shown = () => ({ headline: page['cp-headline'].textContent, verdict: page['cp-verdict'].textContent, detail: page['cp-detail'].textContent,
  caption: page['cp-caption'].textContent, rent: page['cp-rent'].attrs.d, own: page['cp-own'].attrs.d, cross: page['cp-cross'].attrs.d, mark: page['cp-mark'].attrs.d,
  dots: [page['cp-dot-rent'].attrs.cx, page['cp-dot-rent'].attrs.cy, page['cp-dot-own'].attrs.cx, page['cp-dot-own'].attrs.cy],
  outs: ['tokens', 'hours', 'people', 'pin', 'pout', 'gpu', 'spare'].map((k) => String(page['cp-' + k + '-out'].textContent)) });
const set = (a) => {
  page['cp-tokens'].value = String(cfg.tokensB.indexOf(a[0])); page['cp-hours'].value = String(a[1]); page['cp-people'].value = String(a[2]);
  page['cp-pin'].value = String(cfg.pricesIn.indexOf(a[3])); page['cp-pout'].value = String(cfg.pricesOut.indexOf(a[4]));
  page['cp-gpu'].value = String(cfg.gpuHours.indexOf(a[5])); page['cp-spare'].value = String(cfg.spares.indexOf(a[6]));
};
want('opening controls', ['tokens', 'hours', 'people', 'pin', 'pout', 'gpu', 'spare'].map((k) => page['cp-' + k].value),
  [String(cfg.tokensB.indexOf(cfg.opening.tokens_b)), String(cfg.opening.hours), String(cfg.opening.people), String(cfg.pricesIn.indexOf(cfg.opening.price_in)),
   String(cfg.pricesOut.indexOf(cfg.opening.price_out)), String(cfg.gpuHours.indexOf(cfg.opening.gpu_hour)), String(cfg.spares.indexOf(cfg.opening.spare))]);
want('opening state', shown(), fx.opening);
want('nothing spoken yet', page['cp-live'].textContent, '');
set(fx.peaky.args);
fire(page['cp-hours'], 'input');
want('4 hours, dragging', shown(), fx.peaky.want);
want('dragging stays silent', page['cp-live'].textContent, '');
fire(page['cp-hours'], 'change');
want('release speaks', page['cp-live'].textContent, [fx.peaky.want.headline, fx.peaky.want.verdict, fx.peaky.want.detail].join(' '));
want('words on the controls', [page['cp-hours'].attrs['aria-valuetext'], page['cp-tokens'].attrs['aria-valuetext'], page['cp-people'].attrs['aria-valuetext'],
  page['cp-chart'].attrs['aria-label'].endsWith(fx.peaky.want.headline)], ['4 hours a day', '300 billion tokens a month', '$10,000 a month', true]);
fx.others.forEach((c) => { set(c.args); fire(page['cp-gpu'], '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(*args):
    """What the page shows for one setting, in the form the PAGE script reads back."""
    d = r.describe(*args)
    c = d["chart"]
    tokens_b, hours, people, price_in, price_out, gpu_hour, spare = args
    line = lambda x: f"M{x},{r.PAD} V{r.H - r.PAD}"
    return {"headline": d["headline"], "verdict": d["verdict"], "detail": d["detail"], "caption": c["caption"], "rent": c["rent"], "own": c["own"],
            "cross": "" if c["cross"] is None else line(c["cross"]), "mark": line(c["mark"]), "dots": [c["mark"], c["dots"][0], c["mark"], c["dots"][1]],
            "outs": [f"{tokens_b:,} billion", str(hours), f"${people:,}", f"${price_in:.2f}", f"${price_out:.2f}", f"${gpu_hour:.2f}", str(spare)]}


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"] == {"tokens_b": 300, "hours": 24, "people": 10_000, "price_in": 0.1, "price_out": 0.5, "gpu_hour": 3.0, "spare": 1}
    for name, (low, high, step) in {"hours": r.HOURS_RANGE, "people": r.PEOPLE_RANGE}.items():          # the sliders cover the settings the grid 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_words_and_paths_as_python(tmp_path):
    cases = [{"args": list(g), "want": r.describe(*g)} for g in grid()]
    words = " ".join(c["want"]["headline"] + c["want"]["verdict"] + c["want"]["detail"] for c in cases)
    assert "never catches up" in words and "Renting costs less" in words and "Owning costs less" in words and "no spare" in words and "2 spares" in words     # the grid reaches both answers
    assert any(c["want"]["chart"]["cross"] is None for c in cases) and any(c["want"]["chart"]["cross"] is not None for c in 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(cases)


def test_widget_page_shows_the_opening_state_and_follows_the_controls(tmp_path):
    opening, peaky = seen(*BASE), (300, 4, 10_000, 0.10, 0.50, 3.0, 1)
    assert opening["headline"] == "Below about 225 billion tokens a month, renting costs less."
    assert opening["verdict"] == "At 300 billion tokens a month, renting costs $39,240 and owning $35,920. Owning costs less."
    assert seen(*peaky)["headline"] == "At these settings owning never catches up with renting." and seen(*peaky)["cross"] == ""
    others = [(5, 18, 0, 2.0, 10.0, 1.0, 0), (2000, 13, 40_000, 10.0, 50.0, 8.0, 2), (1, 1, 0, 0.5, 2.0, 1.5, 0), (750, 14, 20_000, 4.0, 20.0, 6.0, 1)]
    fixture = {"opening": opening, "peaky": {"args": list(peaky), "want": seen(*peaky)}, "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 toy: round prices" 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)
