#!/usr/bin/env python3
"""Append simulated runs to an AXti.me live results page.

Each invocation adds one new run for a batch of drivers (those with the
fewest runs go first, like a heat working through its run order), then
rebuilds the Class, Overall and PAX tables so positions, bests and diffs
stay consistent.

Usage: ./simulate_live_runs.py [html_file] [--drivers N] [--seed S]
       ./simulate_live_runs.py [html_file] --reset

--reset wipes every run but keeps the drivers listed. Each driver's current
best is saved as their "pace" in <html_file>.pace.json so later simulated
runs land at a realistic speed for that car and driver.
"""

import argparse
import html
import json
import os
import math
import random
import re

CONE_SECONDS = 2.0
TIME_RE = re.compile(r'<div class="time( best)?">\s*(.*?)\s*</div>', re.S)
PEN_RE = re.compile(r'<span class="pen">(.*?)</span>')


def trunc(value):
    # AXti.me truncates to 3 decimals rather than rounding, float warts and
    # all (e.g. 47.537 - 46.34 displays as 1.196).
    return math.floor(value * 1000) / 1000


def fmt_num(value):
    """Match AXti.me formatting: 3 decimals, trailing zeros stripped."""
    return f'{value:.3f}'.rstrip('0').rstrip('.')


def fmt_diff(value):
    value = trunc(value)
    return '-' if value == 0 else '+' + fmt_num(value)


def parse_runs(cell):
    runs = []
    for _, body in TIME_RE.findall(cell):
        pens = PEN_RE.findall(body)
        num = PEN_RE.sub('', body).strip()
        runs.append({'time': float(num) if num else None, 'pens': pens})
    return runs


def adjusted(run):
    """Time with cones added, or None if the run doesn't count."""
    if run['time'] is None or any(p in ('DNF', 'RRN') for p in run['pens']):
        return None
    cones = sum(int(p[1:]) for p in run['pens'] if p.startswith('+'))
    return run['time'] + CONE_SECONDS * cones


def table_body(doc, table_id):
    m = re.search(
        rf'(<table id="{table_id}".*?<tbody>)(.*?)(\s*</tbody>)', doc, re.S)
    return m


def parse(doc):
    drivers = {}
    order = []
    body = table_body(doc, 'result-class').group(2)
    group = None
    for row in re.findall(r'<tr class="(?:divider|row)">.*?</tr>', body, re.S):
        tds = re.findall(r'<td[^>]*>(.*?)</td>', row, re.S)
        if 'divider' in row[:30]:
            group = tds[0].strip()
            continue
        name = html.unescape(tds[1].strip())
        drivers[name] = {
            'group': group,
            'name': name,
            'car': html.unescape(tds[2].strip()),
            'runs': parse_runs(tds[6]),
        }
        order.append(name)

    # Overall table has the full class label; PAX table gives the factor.
    raw_best, pax_best = {}, {}
    for table_id, store in (('result-overall', raw_best), ('result-pax', pax_best)):
        body = table_body(doc, table_id).group(2)
        for row in re.findall(r'<tr class="row">.*?</tr>', body, re.S):
            tds = re.findall(r'<td[^>]*>(.*?)</td>', row, re.S)
            name = html.unescape(tds[2].strip())
            drivers[name]['label'] = tds[1].strip()
            best = re.sub(r'<.*?>', '', tds[4]).strip()
            store[name] = float(best) if best else None
    factors_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                                'pax-factors.json')
    with open(factors_path) as f:
        factors = {c['name']: c['factor'] for c in json.load(f)}
    rank = {n: i for i, n in enumerate(raw_best)}
    for name, d in drivers.items():
        d.setdefault('label', d['group'])
        d['factor'] = factors.get(d['label'].split('-')[-1], 1.0)
        d['rank'] = rank.get(name, len(rank))
        # PAX groups (P, Z, Consolidated) have labels like "Z-KM" and show
        # PAX time in the class table.
        d['class_key'] = 'pax' if '-' in d['label'] else 'raw'
    return drivers, order


def counted_runs(d):
    # A rerun doesn't use up one of the driver's runs.
    return sum('RRN' not in r['pens'] for r in d['runs'])


def simulate_run(d, pace, rng):
    if pace is None:
        valid = [t for t in map(adjusted, d['runs']) if t is not None]
        pace = min(valid) if valid else 45.0
    roll = rng.random()
    if roll < 0.07:
        return {'time': None, 'pens': ['DNF']}
    # Early runs are slower while the driver learns the course.
    learning = 1 + 0.10 * 0.6 ** counted_runs(d)
    t = round(pace * learning * rng.uniform(0.99, 1.03), 3)
    if roll < 0.10:
        return {'time': round(t * rng.uniform(1.1, 1.6), 3), 'pens': ['RRN']}
    if roll < 0.22:
        return {'time': t, 'pens': ['+1']}
    if roll < 0.25:
        return {'time': t, 'pens': ['+2']}
    return {'time': t, 'pens': []}


def compute(d):
    scored = [(adjusted(r), i) for i, r in enumerate(d['runs'])]
    scored = [s for s in scored if s[0] is not None]
    if not scored:
        d['raw'] = d['pax'] = None
        d['best_idx'] = set()
        return
    d['raw'] = min(scored)[0]
    d['best_idx'] = {i for t, i in scored if t == d['raw']}
    d['pax'] = trunc(d['raw'] * d['factor'])


def render_times(d, indent):
    pad = ' ' * indent
    out = []
    for i, r in enumerate(d['runs']):
        cls = 'time best' if i in d['best_idx'] else 'time'
        pens = ' '.join(f'<span class="pen">{p}</span>' for p in r['pens'])
        if r['time'] is None:
            inner = pens
        else:
            inner = f'{r["time"]:.3f}' + (' ' + pens if pens else '')
        out.append(f'{pad}<div class="{cls}">{inner}</div>')
    out.append(f'{pad}<div style="clear: left"></div>')
    return '\n'.join(out)


def render_rows(ranked, key, with_class):
    rows = []
    leader = prev = None
    for pos, d in enumerate(ranked, 1):
        best = d[key]
        if best is None:
            # No time yet: listed at the bottom with no best or diffs.
            diff = diff_prev = '-'
        else:
            diff = '-' if leader is None else fmt_diff(best - leader)
            diff_prev = '-' if prev is None else fmt_diff(best - prev)
            leader = best if leader is None else leader
            prev = best
        first = (f'<td>{pos}</td>\n          <td>{d["label"]}</td>'
                 if with_class else f'<td align="center">{pos}</td>')
        rows.append(f'''        <tr class="row">
          {first}
          <td nowrap>{html.escape(d["name"], quote=False)}</td>
          <td nowrap>{html.escape(d["car"], quote=False)}</td>
          <td><strong>{'' if best is None else fmt_num(best)}</strong></td>
          <td>{diff}</td>
          <td>{diff_prev}</td>
          <td>
{render_times(d, 12)}
          </td>
        </tr>''')
    return rows


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('file', nargs='?', default='live_results.html')
    ap.add_argument('--drivers', type=int, default=12)
    ap.add_argument('--seed', type=int)
    ap.add_argument('--reset', action='store_true',
                    help='wipe all runs, keeping the drivers')
    args = ap.parse_args()
    rng = random.Random(args.seed)
    pace_path = args.file + '.pace.json'

    with open(args.file) as f:
        doc = f.read()
    drivers, order = parse(doc)

    if args.reset:
        paces = {}
        if os.path.exists(pace_path):
            with open(pace_path) as f:
                paces = json.load(f)
        for name, d in drivers.items():
            valid = [t for t in map(adjusted, d['runs']) if t is not None]
            if valid:
                paces[name] = min(valid)
            d['runs'] = []
        with open(pace_path, 'w') as f:
            json.dump(paces, f, indent=2)
        runs_header = lambda m: 'Runs: 0'
        print(f'Wiped all runs; paces saved to {pace_path}')
    else:
        paces = {}
        if os.path.exists(pace_path):
            with open(pace_path) as f:
                paces = json.load(f)
        # Fewest runs first, in run order within a tie.
        queue = sorted(order, key=lambda n: counted_runs(drivers[n]))
        for name in queue[:args.drivers]:
            run = simulate_run(drivers[name], paces.get(name), rng)
            drivers[name]['runs'].append(run)
            label = run['time'] if run['time'] is not None else ''
            print(f'{name:32} {label} {" ".join(run["pens"])}')
        added = min(args.drivers, len(queue))
        runs_header = lambda m: f'Runs: {int(m.group(1)) + added}'
        print(f'Added {added} runs to {args.file}')

    for d in drivers.values():
        compute(d)
    everyone = list(drivers.values())

    def ranking(key):
        return lambda d: (d[key] is None, d[key] or 0, d['rank'])

    # Class table: grouped, ranked by PAX time for PAX groups, raw otherwise.
    class_rows = []
    for group in sorted({d['group'] for d in everyone}):
        members = [d for d in everyone if d['group'] == group]
        key = members[0]['class_key']
        members.sort(key=ranking(key))
        class_rows.append(f'''        <tr class="divider">
          <td colspan="7">{group}</td>
        </tr>''')
        class_rows += render_rows(members, key, with_class=False)
    overall = sorted(everyone, key=ranking('raw'))
    pax = sorted(everyone, key=ranking('pax'))

    for table_id, rows in (
        ('result-class', class_rows),
        ('result-overall', render_rows(overall, 'raw', True)),
        ('result-pax', render_rows(pax, 'pax', True)),
    ):
        m = table_body(doc, table_id)
        doc = doc[:m.start(2)] + '\n' + '\n'.join(rows) + doc[m.end(2):]

    doc = re.sub(r'Runs: (\d+)', runs_header, doc, count=1)
    with open(args.file, 'w') as f:
        f.write(doc)


if __name__ == '__main__':
    main()
