"""Guards for ../widget.html: its data block, its size, and that its bill arithmetic is bill.py's."""
import itertools
import json
import re
import shutil
import subprocess
from pathlib import Path

import pytest

from bill import DAYS_PER_MONTH, EXAMPLE, EXAMPLE_PRICES, Chat, bill, closed_form, turn_cost, turn_parts
import embed_widget
from embed_widget import WIDGET, embed

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

# Reads the bill functions out of the widget between its marker comments, runs them, and compares
# with the numbers 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 js = html.slice(html.indexOf('/* ---------- the bill'), html.indexOf('/* ---------- state'));
const api = new Function("'use strict';" + js + '; return {turnParts, totals, turnCost, bill};')();
const bad = [];
let checks = 0;
const close = (a, b) => Math.abs(a - b) <= 1e-12 * Math.max(1, Math.abs(b));
for (const f of fx) {
  const c = f.chat, [pIn, pOut] = f.prices;
  checks++;
  const t = api.totals(c);
  if (t.input !== f.input || t.output !== f.output) bad.push(['totals', c, t]);
  for (const [k, want] of f.parts) {
    if (JSON.stringify(api.turnParts(c, k)) !== JSON.stringify(want) || !close(api.turnCost(c, k, pIn, pOut), f.turnCosts[k - 1])) bad.push(['turn', c, k]);
  }
  const b = api.bill(c, pIn, pOut, f.retry);
  if (!close(b.inputCost, f.inputCost) || !close(b.outputCost, f.outputCost) || !close(b.retryCost, f.retryCost) || !close(b.total, f.total)) bad.push(['bill', c, b]);
}
console.log(JSON.stringify({ checks, bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""


def test_widget_data_block_matches_bill_py():
    """Guards a change to the example chat or prices in bill.py 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."""
    root = HTML[HTML.index('<div class="bil-root">'):HTML.index("<script")]
    script = HTML[HTML.index("<script>"):]
    ids = set(re.findall(r"\$\('([\w-]+)'\)", script)) | set(re.findall(r"id: '([\w-]+)'", script)) | set(re.findall(r"hint: '([\w-]+)'", script)) | set(re.findall(r"range: '([\w-]+)'", script))
    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 "$('bil-config')" not in script


def test_widget_is_small_and_asks_for_nothing_online():
    """The lesson page links the pricing pages; the widget, in a sandbox that cannot open links, holds no address."""
    assert len(HTML.encode("utf-8")) < 40 * 1024
    page = 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


# Calls the widget's two sentence builders the way paint() does, for three chats, and prints what they say.
NOTES = r"""
import fs from 'node:fs';
const html = fs.readFileSync(process.argv[2], 'utf8');
const js = html.slice(html.indexOf('/* ---------- the bill'), html.indexOf('/* ---------- state'));
const api = new Function("'use strict';" + js + '; return {bill, turnCost, trendNote, trimNote};')();
const out = [];
for (const [turns, keep] of [[1, null], [1, 0], [10, 1], [10, 0], [10, null]]) {
  const mk = k => ({ system: 500, message: 60, reply: 200, turns, reasoning: 0, keep: k });
  const trim = keep !== null, chat = mk(keep), whole = mk(null);
  const b = api.bill(chat, 2, 10, 0), full = api.bill(whole, 2, 10, 0);
  out.push([api.trendNote(turns, api.turnCost(chat, 1, 2, 10), api.turnCost(chat, turns, 2, 10)),
            api.trimNote(trim, keep, trim && b.input !== full.input, b.total, full.total)]);
}
console.log(JSON.stringify(out));
"""

NOTES_WANT = [
    ["A one-turn chat has no history to re-send.", "Tick “Keep last” to compare."],
    ["A one-turn chat has no history to re-send.", "Your chat has no more than 1 turn, so nothing is cut yet."],
    ["Turn 1 costs $0.00312. Turn 10 costs $0.00364, 1.2 times as much.",
     "Sending only the last earlier turn: $0.03588 per chat instead of $0.0546, 34.3% less."],
    ["Turn 1 costs $0.00312. Turn 10 costs $0.00312, 1.0 times as much.",
     "Sending no earlier turns: $0.0312 per chat instead of $0.0546, 42.9% less."],
    ["Turn 1 costs $0.00312. Turn 10 costs $0.0078, 2.5 times as much.", "Tick “Keep last” to compare."],
]


def test_widget_sentences_read_right_for_one_turn_one_kept_turn_and_the_default(tmp_path):
    """Guards the plural slips in the trend and trim sentences ("last 1 earlier turns", "1 turns") and the
    wording of the sentences under the answer: one turn, one or no kept turns with a cut, and the lesson's default."""
    if shutil.which("node") is None:
        pytest.skip("Node.js is not installed")
    (tmp_path / "notes.mjs").write_text(NOTES, encoding="utf-8")
    run = subprocess.run(["node", str(tmp_path / "notes.mjs"), str(WIDGET)], capture_output=True, text=True, timeout=60)
    assert run.returncode == 0, run.stdout + run.stderr
    assert json.loads(run.stdout) == NOTES_WANT


def test_widget_bill_arithmetic_matches_the_python_code(tmp_path):
    """Guards the widget's JavaScript drifting from bill.py: its token counts must equal bill.py's exactly
    and its dollars must agree to rounding error, for trimmed, untrimmed, reasoning and retry cases."""
    if shutil.which("node") is None:
        pytest.skip("Node.js is not installed")
    chats = [Chat(s, m, r, n, think, keep) for s, m, r, n, think, keep in
             itertools.product((0, 500, 2000), (0, 60, 900), (0, 200), (1, 2, 3, 4, 10, 30, 60), (0, 800), (None, 0, 1, 3, 59))]
    chats.append(EXAMPLE)
    fixture = []
    for chat in chats:
        for prices, retry in (((2, 10), "0"), ((0.1, 0.5), "0.25"), ((10, 50), "1")):
            b = bill(chat, *prices, retry_rate=retry)
            fixture.append({
                "chat": {"system": chat.system, "message": chat.message, "reply": chat.reply, "turns": chat.turns,
                         "reasoning": chat.reasoning, "keep": chat.keep},
                "prices": list(prices), "retry": float(retry), "input": closed_form(chat)[0], "output": closed_form(chat)[1],
                "inputCost": float(b.input_cost), "outputCost": float(b.output_cost), "retryCost": float(b.retry_cost),
                "total": float(b.total),
                "parts": [[k, {"system": p.system, "history": p.history, "message": p.message, "reply": p.reply, "reasoning": p.reasoning}]
                          for k in {1, chat.turns, (chat.turns + 1) // 2} for p in [turn_parts(chat, k)]],
                "turnCosts": [float(turn_cost(chat, k, *prices)) for k in range(1, chat.turns + 1)],
            })
    (tmp_path / "fixture.json").write_text(json.dumps(fixture), encoding="utf-8")
    (tmp_path / "parity.mjs").write_text(PARITY, encoding="utf-8")
    run = subprocess.run(["node", str(tmp_path / "parity.mjs"), str(tmp_path / "fixture.json"), str(WIDGET)],
                         capture_output=True, text=True, timeout=120)
    assert run.returncode == 0, run.stdout + run.stderr
    assert json.loads(run.stdout)["checks"] == len(fixture) >= 1000


def test_widget_starts_from_the_lesson_example():
    """Guards the lesson text and the widget starting from different chats: the config is the lesson's example."""
    cfg = embed_widget.config()
    assert (cfg["system"], cfg["message"], cfg["reply"], cfg["turns"]) == (500, 60, 200, 10)
    assert (cfg["priceIn"], cfg["priceOut"]) == EXAMPLE_PRICES == (2, 10) and cfg["daysPerMonth"] == DAYS_PER_MONTH == 30
