"""Check the figures in the cleaning-robot posts' Parts 8 onwards against the Python side.

Builds the Hugo site, drafts and future-dated posts included, into a temporary folder, serves it,
and opens each part in Chromium. Every figure exposes what it shows on its root element
(root._sx); this script recomputes each with the solutions one folder up and compares. It also
checks for console errors, that the CSS of every figure survived the build, that each Play button
advances and stops off screen, and for sideways scrolling at phone width.

    pip install playwright highspy && playwright install chromium
    python docs/check_or_figures.py [--site PATH] [--shots DIR] [--only SLUG]

Recomputing the cutting-plane loop in exact fractions takes a few minutes.
"""
import argparse
import functools
import http.server
import re
import subprocess
import sys
import tempfile
import threading
from fractions import Fraction
from pathlib import Path

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent))
sys.path.insert(0, str(HERE))

import or_figure_data
import simplex

DEFAULT_SITE = HERE.parent.parent / 'shaia' / 'shaia.github.io'
PART8 = 'the-cleaning-robot-puzzle-a-lower-bound-from-a-linear-program'
PART9 = 'the-cleaning-robot-puzzle-the-shortest-route-exactly'
PART10 = 'the-cleaning-robot-puzzle-planning-the-trips'
SIMPLEX_ROWS = [({0: 1, 1: 2}, simplex.LE, 8), ({0: 1, 1: 1}, simplex.LE, 5), ({0: 3, 1: 1}, simplex.LE, 12)]


# ---------------------------------------------------------------- Part 8

def check_lp(fig, check):
    want = or_figure_data.loop_data(or_figure_data.LOOP_ROOM)
    sx = fig.evaluate('r => ({n: r._sx.rounds(), values: r._sx.values, broken: r._sx.broken, part3: r._sx.part3, '
                      'fast: r._sx.fast, certificate: r._sx.certificate, value: r._sx.value})')
    check(sx['n'] == len(want['rounds']), f'sxRobLp: {sx["n"]} rounds, want {len(want["rounds"])}')
    check(sx['values'] == [r['value'] for r in want['rounds']], 'sxRobLp: round values differ from route_lp')
    check(sx['broken'] == [r['broken'] for r in want['rounds']], 'sxRobLp: broken cuts differ from route_lp')
    check(sx['part3'] == want['part3'] and sx['fast'] == want['fast'], 'sxRobLp: Part 3 bound or fast route differs')
    check(sx['value'] == want['value'] and sx['certificate'] == float(Fraction(want['value'])),
          f'sxRobLp: certificate {sx["certificate"]} does not prove {want["value"]}')
    # the page's slider shows every round's value as the data says
    for k in (1, len(want['rounds'])):
        fig.evaluate(f'r => {{ const s = r.querySelector("[data-o=r]"); s.value = {k}; s.dispatchEvent(new Event("input")); }}')
        shown = fig.evaluate('r => r._sx.shown()')
        check(shown == {'round': k, 'value': want['rounds'][k - 1]['value']}, f'sxRobLp: round {k} shows {shown}')


def check_simplex(fig, check):
    for a in range(6):
        for b in range(6):
            got = fig.evaluate(f'r => r._sx.solve({a}, {b})')
            r = simplex.solve([-a, -b], SIMPLEX_ROWS, trace=True)
            corners = [['0', '0']] + [[str(p[4][0]), str(p[4][1])] for p in r.pivots]
            moves = [[p[1], p[2]] for p in r.pivots]
            prices = [str(-y) for y in r.y]
            check(got == {'corners': corners, 'moves': moves, 'prices': prices},
                  f'sxRobSimplex a={a} b={b}: {got} != {corners}, {moves}, {prices}')


def check_gap(fig, check):
    got = fig.evaluate('r => r._sx.rooms')
    check(got == or_figure_data.gap_data(), 'sxRobGap: rooms differ from or_figure_data.gap_data')


# ---------------------------------------------------------------- Part 9

def check_bnb(fig, check):
    import branch_cut
    from test_branch_cut import GAP_ROOMS
    from shortest_route_fast import solution as fast
    for key, rounds in (('plain', 0), ('gomory', 1)):
        want = []
        route, _ = branch_cut.solve(GAP_ROOMS[0], gomory_rounds=rounds,
                                    record=lambda node, parent, branch, bound, outcome, x, z: want.append(
                                        [node, parent, list(branch) if branch else None,
                                         str(bound) if bound is not None else None, outcome]))
        check(fig.evaluate(f'r => r._sx.nodes({key!r})') == want, f'sxRobBnb {key}: the tree differs from branch_cut.solve')
        check(fig.evaluate(f'r => r._sx.best({key!r})') == len(route), f'sxRobBnb {key}: best route differs')
    check(fig.evaluate('r => r._sx.fast') == fast(GAP_ROOMS[0]), 'sxRobBnb: fast route differs')


def check_routes(fig, check):
    import route_ip
    from shortest_route_fast import parse
    from shortest_route_fast import solution as fast
    from test_cleaning_robot import simulate
    sx = fig.evaluate('r => ({room: r._sx.room, fast: r._sx.fast, best: r._sx.best, runs: r._sx.runs()})')
    room = sx['room']
    floor = parse(room)[1]
    check(sx['fast'] == fast(room), 'sxRobRoutes: fast route differs from shortest_route_fast')
    solved = route_ip.solve(room, time_limit=900)
    check(solved['optimal'] and len(sx['best']) == len(solved['route']), 'sxRobRoutes: the shortest route is not proven')
    for key in ('fast', 'best'):
        cmds = sx[key]
        check(simulate(room, cmds) == floor, f'sxRobRoutes {key}: does not clean every field')
        start, _ = parse(room)
        at, seen, back, walks = start, {start}, 0, {}
        step = {'^': (-1, 0), 'v': (1, 0), '<': (0, -1), '>': (0, 1)}
        for ch in cmds:
            nxt = (at[0] + step[ch][0], at[1] + step[ch][1])
            pair = tuple(sorted((at, nxt)))
            walks[pair] = walks.get(pair, 0) + 1
            back += nxt in seen
            seen.add(nxt)
            at = nxt
        want = {'n': len(cmds), 'cleaned': len(seen), 'back': back, 'two': sum(1 for v in walks.values() if v >= 2)}
        check(sx['runs'][key] == want, f'sxRobRoutes {key}: {sx["runs"][key]} != {want}')
    check(sx['runs']['floor'] == len(floor), 'sxRobRoutes: field count differs')


# ---------------------------------------------------------------- Part 10

def check_colgen(fig, check):
    import cg_figure_data
    want = cg_figure_data.rounds_data()
    sx = fig.evaluate('r => ({rounds: r._sx.rounds, best: r._sx.best, enumerated: r._sx.enumerated, fast: r._sx.fast, '
                      'oracle: r._sx.oracle, part3: r._sx.part3})')
    check(sx['rounds'] == [[x['value'], x['prices'], x['sigma'], x['added']] for x in want['rounds']],
          'sxRobColgen: rounds differ from column_gen.generate')
    check(sx['best'] == want['best'] == want['oracle'] and sx['oracle'] == want['oracle'],
          f'sxRobColgen: best {sx["best"]} is not the trip oracle {want["oracle"]}')
    check(sx['enumerated'] == want['enumerated'] and sx['fast'] == want['fast'] and sx['part3'] == want['part3'],
          'sxRobColgen: enumeration, fast plan or Part 3 bound differs')


def check_plan(fig, check):
    import column_gen
    import pick_cells_dock
    from test_dock import simulate_dock
    sx = fig.evaluate('r => ({room: r._sx.room, capacity: r._sx.capacity, fast: r._sx.fast, best: r._sx.best, plans: r._sx.plans()})')
    room, cap = sx['room'], sx['capacity']
    check(sx['fast'] == pick_cells_dock.solution(room, cap), 'sxRobPlan: fast plan differs from pick_cells_dock')
    solved = column_gen.solve(room, cap)
    check(solved['proven'] and solved['moves'] == simulate_dock(room, sx['best'], cap, True), 'sxRobPlan: best plan not proven')
    for key in ('fast', 'best'):
        moves = simulate_dock(room, sx[key], cap, True)
        check(sx['plans'][key]['moves'] == moves, f'sxRobPlan {key}: moves {sx["plans"][key]["moves"]} != {moves}')
        check(all(0 < load <= cap for load in sx['plans'][key]['loads']), f'sxRobPlan {key}: a trip overflows or is empty')


FIGURES = {
    PART8: {'sxRobSimplex': check_simplex, 'sxRobLp': check_lp, 'sxRobGap': check_gap},
    PART9: {'sxRobBnb': check_bnb, 'sxRobRoutes': check_routes},
    PART10: {'sxRobColgen': check_colgen, 'sxRobPlan': check_plan},
}
PLAYERS = {'sxRobSimplex', 'sxRobLp', 'sxRobBnb', 'sxRobRoutes', 'sxRobColgen', 'sxRobPlan'}


def check_play(page, fig, check, name):
    before = fig.evaluate('r => r.textContent')
    fig.scroll_into_view_if_needed()
    fig.query_selector('[data-b=play]').click()
    page.wait_for_timeout(1500)
    check(fig.evaluate('r => r.textContent') != before, f'{name}: Play does not advance')
    page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
    page.wait_for_timeout(400)
    first = fig.evaluate('r => r.textContent')
    page.wait_for_timeout(1600)
    check(fig.evaluate('r => r.textContent') == first, f'{name}: Play keeps running off screen')


class Quiet(http.server.SimpleHTTPRequestHandler):
    def log_message(self, format, *args):
        """No line of log for every request."""


def main():
    ap = argparse.ArgumentParser(description='Check the cleaning-robot figures of Parts 8 onwards in a real browser.')
    ap.add_argument('--site', type=Path, default=DEFAULT_SITE)
    ap.add_argument('--shots', type=Path)
    ap.add_argument('--only')
    args = ap.parse_args()
    failures, count = [], [0]

    def check(ok, message):
        count[0] += 1
        if not ok:
            failures.append(message)

    from playwright.sync_api import sync_playwright

    with tempfile.TemporaryDirectory() as out:
        server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), functools.partial(Quiet, directory=out))
        threading.Thread(target=server.serve_forever, daemon=True).start()
        base = f'http://127.0.0.1:{server.server_address[1]}/'
        build = subprocess.run(['hugo', '--source', str(args.site), '--destination', out, '--environment', 'development',
                                '--minify', '-D', '-F', '--baseURL', base, '--logLevel', 'warn'],
                               capture_output=True, text=True, encoding='utf-8')
        log = build.stdout + build.stderr
        if build.returncode or re.search(r'\b(WARN|ERROR)\b', log):
            sys.exit(f'hugo did not build clean:\n{log}')

        with sync_playwright() as p:
            browser = p.chromium.launch()
            print(f'Chromium {browser.version}')
            for width in (1280, 400):
                for slug, figs in FIGURES.items():
                    if args.only and args.only not in slug:
                        continue
                    page = browser.new_page(viewport={'width': width, 'height': 900})
                    errors = []
                    page.on('console', lambda m: errors.append(m.text) if m.type == 'error' else None)
                    page.on('pageerror', lambda e: errors.append(str(e)))
                    page.route('**/*', lambda route: route.continue_() if route.request.url.startswith(base) else route.abort())
                    page.goto(base + f'blog/{slug}/', wait_until='load')
                    for fid, fn in figs.items():
                        fig = page.query_selector('#' + fid)
                        check(fig is not None and fig.evaluate('r => !!r._sx'), f'{fid}: missing, or its script did not run, at {width}px')
                        if fig is None:
                            continue
                        if width == 1280:
                            style = fig.evaluate("""r => [getComputedStyle(r).getPropertyValue('--c-teal').trim(),
                                getComputedStyle(r).borderTopStyle, getComputedStyle(r).backgroundColor,
                                [...r.querySelectorAll('style')].every(s => !/\\u2013c-|<p>/.test(s.textContent))]""")
                            check(bool(style[0]) and style[1] == 'solid' and style[2] != 'rgba(0, 0, 0, 0)' and style[3],
                                  f'{fid}: its CSS did not survive the build: {style}')
                            fn(fig, check)
                            if fid in PLAYERS:
                                check_play(page, fig, check, fid)
                        if args.shots:
                            args.shots.mkdir(parents=True, exist_ok=True)
                            fig.scroll_into_view_if_needed()
                            fig.screenshot(path=str(args.shots / f'{fid}-{width}.png'))
                    wide = page.evaluate("""() => [document.documentElement.scrollWidth - innerWidth,
                        ...[...document.querySelectorAll('.sxfig')].map(f => f.scrollWidth - f.clientWidth)]""")
                    check(max(wide) <= 1, f'{slug}: sideways overflow at {width}px: {max(wide)}px')
                    check(not errors, f'{slug}: console errors at {width}px: {errors}')
                    page.close()
            browser.close()
        server.shutdown()
        server.server_close()

    print(f'{count[0]} checks, {len(failures)} failed')
    for message in failures[:60]:
        print('  ' + message)
    sys.exit(1 if failures else 0)


if __name__ == '__main__':
    main()
