"""Prueba de extremo a extremo del MODO EN VIVO.
Requiere el servidor de prueba (Apache + PHP + MariaDB) descrito en PRUEBAS.md.
Reinicia la base de datos de prueba al comenzar."""
import urllib.request, urllib.parse, urllib.error, http.cookiejar, re, json, subprocess, html, os, time, random

RAIZ_WEB = 'http://localhost/cucosta/casosvideo'
BASE = RAIZ_WEB + '/index.php'
APP = '/var/www/html/cucosta/casosvideo'
AQUI = os.path.dirname(os.path.abspath(__file__))
OK = []; FAIL = []

def check(cond, nombre):
    (OK if cond else FAIL).append(nombre)
    print(('  OK   ' if cond else '  FALLA ') + nombre)

class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *a, **k): return None

class Cliente:
    def __init__(self):
        self.cj = http.cookiejar.CookieJar()
        self.op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.cj), NoRedirect)
    def _abrir(self, r):
        try:
            resp = self.op.open(r); return resp.status, resp.read(), resp.headers
        except urllib.error.HTTPError as e:
            return e.code, e.read(), e.headers
    def req(self, ruta, datos=None, q=None, seguir=True):
        url = BASE + '?' + urllib.parse.urlencode(dict({'r': ruta}, **(q or {})))
        body = urllib.parse.urlencode(datos).encode() if datos is not None else None
        r = urllib.request.Request(url, data=body, headers={'Content-Type': 'application/x-www-form-urlencoded'} if body else {})
        c, t, hd = self._abrir(r)
        loc = hd.get('Location')
        if loc and seguir and c in (301, 302, 303):
            return self.url(loc if loc.startswith('http') else 'http://localhost' + loc)
        return c, t.decode('utf-8', 'replace'), loc
    def url(self, u, headers=None):
        c, t, hd = self._abrir(urllib.request.Request(u, headers=headers or {}))
        loc = hd.get('Location')
        if loc and c in (301, 302, 303):
            return self.url(loc if loc.startswith('http') else 'http://localhost' + loc)
        return c, t.decode('utf-8', 'replace'), hd
    def json(self, ruta, datos, q=None, headers=None):
        url = BASE + '?' + urllib.parse.urlencode(dict({'r': ruta}, **(q or {})))
        h = {'Content-Type': 'application/json', 'Accept': 'application/json'}; h.update(headers or {})
        c, t, hd = self._abrir(urllib.request.Request(url, data=json.dumps(datos).encode(), headers=h))
        try: return c, json.loads(t.decode('utf-8'))
        except Exception: return c, {'_crudo': t.decode('utf-8', 'replace')[:300]}
    def get_json(self, ruta, q=None):
        c, t, _ = self.req(ruta, q=q)
        try: return c, json.loads(t)
        except Exception: return c, {'_crudo': t[:300]}
    def csrf(self, texto):
        m = re.search(r'name="_csrf" value="([0-9a-f]+)"', texto); return m.group(1) if m else ''

def sql(q):
    return subprocess.run(['mariadb', '--default-character-set=utf8mb4', '-N', '-r', '-ucp_cvapp', '-pPrueba-BD-2026x', 'cp_casosvideo', '-e', q], capture_output=True, text=True).stdout.strip()

def datos_js(texto):
    m = re.search(r'<script type="application/json" id="cv-datos">(.*?)</script>', texto, re.S)
    return json.loads(m.group(1)) if m else {}

def ms_ahora(): return int(time.time() * 1000)

print('== Preparación')
subprocess.run([os.path.join(AQUI, 'reiniciar_bd.sh')], check=True, capture_output=True)
d = Cliente()
c, t, _ = d.req('setup'); tok = d.csrf(t)
c, t, _ = d.req('setup', [('_csrf', tok), ('token', 'token-de-prueba-largo-2026-cucosta'), ('codigo', 'jprofesor'), ('nombre', 'Juan Profesor'), ('correo', ''), ('clave', 'Docente-Seguro-2026'), ('clave2', 'Docente-Seguro-2026')])
c, t, _ = d.req('login'); tok = d.csrf(t)
c, t, _ = d.req('login', [('_csrf', tok), ('codigo', 'jprofesor'), ('clave', 'Docente-Seguro-2026')])
check('En vivo' in t and 'docente%2Fvivo' in t, 'menú del docente muestra «En vivo»')

anon = Cliente()
c, j = anon.get_json('docente/vivo-api', {'id': 1})
check(c == 401 and j.get('codigo') == 'sesion', 'API del docente exige sesión (401 JSON)')
c, t, loc = anon.req('docente/vivo', seguir=False)
check(c in (302, 303) and 'login' in (loc or ''), 'panel en vivo redirige al acceso sin sesión')

print('== Equipos')
c, t, _ = d.req('docente/vivo'); tok = d.csrf(t)
check(c == 200 and 'Primero crea los' in t, 'panel en vivo pide crear equipos primero')
c, t, _ = d.req('docente/equipos'); tok = d.csrf(t)
c, t, _ = d.req('docente/equipos', [('accion', 'generar'), ('conjunto', 'Prueba'), ('cantidad', '3')])
check(c == 400, 'generar sin CSRF rechazado (400)')
c, t, _ = d.req('docente/equipos', [('_csrf', tok), ('accion', 'generar'), ('conjunto', 'Auditorio 2026'), ('cantidad', '12')])
n = sql("SELECT COUNT(*) FROM equipos WHERE conjunto='Auditorio 2026'")
check(n == '12' and 'Se agregaron 12 equipos' in t, 'se generan 12 equipos con nombre, emoji y color')
cods = sql("SELECT codigo FROM equipos").split('\n')
check(all(re.fullmatch(r'[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}', x) for x in cods) and len(set(cods)) == len(cods), 'códigos de 6 caracteres, únicos y sin 0/O/1/I')
check(sql("SELECT COUNT(DISTINCT color) FROM equipos") == '12', 'cada equipo recibe un color distinto (12)')
lista = "🦈 Los Tiburones; Ana López, Luis Pérez\nEquipo Beta, Carla Ruiz\nGamma\tDaniel Soto\n<img src=x onerror=alert(1)>"
c, t, _ = d.req('docente/equipos', [('_csrf', tok), ('accion', 'importar'), ('conjunto', 'Auditorio 2026'), ('lista', lista)])
fila = sql("SELECT CONCAT(emoji,'|',nombre,'|',integrantes) FROM equipos WHERE nombre='Los Tiburones'")
check(fila == '🦈|Los Tiburones|Ana López, Luis Pérez', 'importación: emoji, nombre e integrantes separados por punto y coma')
check(sql("SELECT integrantes FROM equipos WHERE nombre='Equipo Beta'") == 'Carla Ruiz' and sql("SELECT integrantes FROM equipos WHERE nombre='Gamma'") == 'Daniel Soto', 'importación: separadores coma y tabulador')
c, t, _ = d.req('docente/equipos', q={'conjunto': 'Auditorio 2026'})
check('&lt;img src=x onerror=alert(1)&gt;' in t and '<img src=x' not in t, 'nombre de equipo con HTML se muestra escapado')
xss_id = sql("SELECT id FROM equipos WHERE nombre LIKE '<img%'")
c, t, _ = d.req('docente/equipos', [('_csrf', tok), ('accion', 'eliminar'), ('equipo', xss_id)])
check(sql("SELECT COUNT(*) FROM equipos WHERE id=" + xss_id) == '0', 'equipo sin participación se puede eliminar')
eid = sql("SELECT id FROM equipos WHERE nombre='Gamma'")
viejo = sql("SELECT codigo FROM equipos WHERE id=" + eid)
c, t, _ = d.req('docente/equipos', [('_csrf', tok), ('accion', 'codigo'), ('equipo', eid)])
check(sql("SELECT codigo FROM equipos WHERE id=" + eid) != viejo, 'nuevo código invalida el anterior')
c, t, _ = d.req('docente/equipos', [('_csrf', tok), ('accion', 'editar'), ('equipo', eid), ('nombre', 'Gamma Jurídico'), ('emoji', '⚖️'), ('color', '4'), ('integrantes', 'Daniel Soto'), ('activo', '1')])
check(sql("SELECT CONCAT(nombre,'|',emoji,'|',color) FROM equipos WHERE id=" + eid) == 'Gamma Jurídico|⚖️|4', 'editar nombre, emoji y color')
c, t, _ = d.req('docente/equipos', [('_csrf', tok), ('accion', 'generar'), ('conjunto', 'Otro grupo'), ('cantidad', '2')])
c, t, _ = d.req('docente/equipos-tarjetas', q={'conjunto': 'Auditorio 2026'})
dj = datos_js(t)
n_eq = int(sql("SELECT COUNT(*) FROM equipos WHERE conjunto='Auditorio 2026' AND activo=1"))
check(c == 200 and n_eq == 15 and len(dj.get('tarjetas', [])) == n_eq and all(x['url'].startswith(RAIZ_WEB + '/vivo/?e=') for x in dj['tarjetas']), 'tarjetas: una por equipo (15) con URL absoluta y código para el QR')
check(all(cd in t for cd in sql("SELECT codigo FROM equipos WHERE conjunto='Auditorio 2026'").split('\n')), 'tarjetas muestran el código de cada equipo')

print('== Carpeta vivo/ y dirección corta')
r = urllib.request.Request(RAIZ_WEB + '/vivo/?e=ab-c123')
try:
    resp = urllib.request.build_opener(NoRedirect).open(r); cod = resp.status; loc = resp.headers.get('Location')
except urllib.error.HTTPError as e:
    cod = e.code; loc = e.headers.get('Location')
check(cod == 302 and loc == '/cucosta/casosvideo/index.php?r=vivo&e=abc123', 'vivo/?e=… redirige a la pantalla del equipo con el código limpio')
open(APP + '/vivo/otro.txt', 'w').write('x'); os.chmod(APP + '/vivo/otro.txt', 0o644)
for ruta in ['vivo/otro.txt', 'vivo/.htaccess', 'vivo/s-123.json']:
    c, _, _ = anon.url(RAIZ_WEB + '/' + ruta)
    check(c in (403, 404) and c != 200, 'acceso bloqueado: %s (%d)' % (ruta, c))
os.remove(APP + '/vivo/otro.txt')

print('== Unirse sin sesión')
codigos = sql("SELECT codigo FROM equipos WHERE conjunto='Auditorio 2026' ORDER BY id").split('\n')
tel = Cliente()
c, j = tel.json('vivo/unirse', {'codigo': codigos[0]})
check(c == 409 and j.get('codigo') == 'sin_sesion', 'código válido sin sesión abierta → aviso de espera')
c, j = tel.json('vivo/unirse', {'codigo': 'ZZZZZ2'})
check(c == 404 and j.get('codigo') == 'codigo', 'código inexistente rechazado (404)')
c, t, _ = tel.req('vivo/unirse')
check(c == 405, 'unirse por GET no permitido (405)')
c, t, _ = tel.req('vivo', q={'e': codigos[0].lower()})
check(c == 200 and datos_js(t).get('codigo') == codigos[0] and 'vivo-equipo.js' in t, 'pantalla del equipo precarga el código desde el QR')

print('== Crear sesión')
caso_id = sql("SELECT id FROM casos WHERE titulo LIKE 'Arrebato%'")
c, t, _ = d.req('docente/vivo'); tok = d.csrf(t)
c, t, _ = d.req('docente/vivo', [('_csrf', tok), ('caso_id', caso_id), ('conjunto', 'Auditorio 2026'), ('titulo', 'Reto <b>Penal</b>'), ('t_opcion_multiple', '30'), ('t_seleccion_multiple', '45'), ('t_relacion', '60'), ('t_respuesta_breve', '120'), ('abiertas', '1'), ('explicacion', '1'), ('cierre_auto', '1'), ('rachas', '1')])
sid = sql("SELECT MAX(id) FROM vivo_sesiones")
token = sql("SELECT token FROM vivo_sesiones WHERE id=" + sid)
D = datos_js(t)
check(c == 200 and D.get('api', '').endswith('r=docente%2Fvivo-api&id=' + sid) and 'vivo-pantalla.js' in t, 'se crea la sesión y abre la pantalla proyectada')
check('Reto &lt;b&gt;Penal&lt;/b&gt;' in t, 'título de la sesión escapado en la pantalla')
crudo_datos = re.search(r'id="cv-datos">(.*?)</script>', t, re.S).group(1)
check(not re.search(r'"(clave|correcta|correctas|pares|respuesta_sugerida|explicacion_q)"\s*:', crudo_datos), 'la pantalla no recibe claves en sus datos iniciales')
snap = json.loads(sql("SELECT snapshot FROM vivo_sesiones WHERE id=" + sid))
PREG = snap['preguntas']
check(len(PREG) == 5 and {q['tipo'] for q in PREG} >= {'opcion_multiple', 'respuesta_breve'}, 'instantánea con las 5 preguntas del caso (incluye la abierta)')
archivo = RAIZ_WEB + '/vivo/s-' + token + '.json'
c, t, hd = anon.url(archivo)
st = json.loads(t)
check(c == 200 and st['fase'] == 'lobby' and 'application/json' in hd.get('Content-Type', ''), 'archivo de estado publicado (200, JSON, fase lobby)')
check('no-cache' in (hd.get('Cache-Control') or ''), 'archivo de estado con Cache-Control: no-cache')
et = hd.get('ETag'); lm = hd.get('Last-Modified')
c2, _, _ = anon.url(archivo, headers={'If-None-Match': et} if et else {'If-Modified-Since': lm})
check(c2 == 304, 'revalidación del archivo responde 304 (sin reenviar datos)')

print('== Unirse')
equipos_ids = {}
tiempos = []
for cod in codigos[:10]:
    t1 = time.time(); c, j = tel.json('vivo/unirse', {'codigo': cod}); tiempos.append(time.time() - t1)
    if c == 200: equipos_ids[cod] = j['equipo']['id']
check(len(equipos_ids) == 10 and j.get('token') == token and j.get('archivo', '').endswith('/vivo/s-' + token + '.json'), '10 equipos se unen y reciben el archivo de estado')
check(isinstance(j.get('ahora'), int) and abs(j['ahora'] - ms_ahora()) < 5000, 'unirse devuelve la hora del servidor para sincronizar el reloj')
c, j = tel.json('vivo/unirse', {'codigo': codigos[0].lower()})
check(c == 200 and sql("SELECT dispositivos FROM vivo_equipos WHERE sesion_id=%s AND equipo_id=%s" % (sid, equipos_ids[codigos[0]])) == '2', 'reingreso (minúsculas) cuenta un segundo dispositivo sin duplicar el equipo')
otro = sql("SELECT codigo FROM equipos WHERE conjunto='Otro grupo' LIMIT 1")
c, j = tel.json('vivo/unirse', {'codigo': otro})
check(c == 409 and j.get('codigo') == 'sin_sesion', 'equipo de otro conjunto no entra a esta sesión')
c, j = d.get_json('docente/vivo-api', {'id': sid})
E = j['estado']
check(c == 200 and E['fase'] == 'lobby' and len(E['unidos']) == 10, 'API del docente: sala de espera con 10 equipos unidos')

def accion(nombre, extra=None, v=None, csrf=True):
    cuerpo = {'accion': nombre, 'v': E['v'] if v is None else v}; cuerpo.update(extra or {})
    return d.json('docente/vivo-api', cuerpo, q={'id': sid}, headers={'X-CSRF-Token': D['csrf']} if csrf else {})

c, j = accion('siguiente', csrf=False)
check(c == 400 and j.get('codigo') == 'csrf', 'acción sin CSRF rechazada')
c, j = accion('siguiente', v=E['v'] - 1)
check(c == 409 and j.get('codigo') == 'version', 'acción con versión vieja rechazada (evita doble avance)')
c, j = accion('siguiente'); E = j['estado']
check(E['fase'] == 'caso', 'lobby → caso')
st = json.loads(anon.url(archivo)[1])
check(st['fase'] == 'caso' and st['v'] == E['v'] and 'ficha' in st and st['ficha'], 'el archivo de los celulares se actualiza (fase caso con ficha)')

# ---------- Respuestas por tipo ----------
def correcta(q):
    cl = q['clave']
    if cl.get('insuficiente'): return {'ins': 1, 'falta': 'faltan datos'}
    if q['tipo'] == 'opcion_multiple': return {'v': cl['correcta']}
    if q['tipo'] == 'seleccion_multiple': return {'v': cl['correctas']}
    if q['tipo'] == 'relacion': return {'v': cl['pares']}
    return {'v': 'Análisis del equipo: hechos, figura, fundamento y aplicación.'}
def incorrecta(q):
    cl = q['clave']; ops = [o['id'] for o in q['datos'].get('opciones', [])]
    if q['tipo'] == 'opcion_multiple':
        if cl.get('insuficiente'): return {'v': ops[0]}
        return {'v': [o for o in ops if o != cl['correcta']][0]} if not q['permite_insuficiente'] else {'ins': 1}
    if q['tipo'] == 'seleccion_multiple':
        if cl.get('insuficiente'): return {'v': ops[:1]}
        return {'v': [o for o in ops if o not in cl['correctas']][:1] or ops[:1]}
    if q['tipo'] == 'relacion':
        items = [it['id'] for it in q['datos']['items']]
        mitad = {k: cl['pares'][k] for k in items[: len(items) // 2]}
        for k in items[len(items) // 2:]:
            mitad[k] = [o for o in ops if o != cl['pares'][k]][0]
        return {'v': mitad}
    return {'v': 'Respuesta breve.'}
def esperado(frac, ms, dur):
    if frac <= 0: return 0
    t = min(max(ms, 0), dur) / dur
    return round(1000 * frac * (1 - 0.5 * t))

A, B, C, Dq = codigos[0], codigos[1], codigos[2], codigos[3]  # A siempre acierta; B falla; C a veces; D no responde
racha_A = 0
puntos_A = 0
for i, q in enumerate(PREG):
    print('== Pregunta %d (%s)' % (i + 1, q['tipo']))
    c, j = accion('siguiente'); E = j['estado']
    check(E['fase'] == 'pregunta' and E['i'] == i, 'se abre la pregunta %d' % (i + 1))
    st_txt = anon.url(archivo)[1]; st = json.loads(st_txt)
    fuga = re.search(r'"(clave|correcta|correctas|pares|respuesta_sugerida|explicacion|rubrica|rev)"\s*:', st_txt)
    check(st['fase'] == 'pregunta' and st['q']['enunciado'] == q['enunciado'] and not fuga, 'archivo público con la pregunta y SIN claves ni explicación')
    if i == 0:
        c, j = tel.json('vivo/responder', {'token': token, 'codigo': A, 'k': q['k'], 'r': correcta(q)})
        check(c == 409 and j.get('codigo') == 'pronto', 'respuesta antes de habilitarse rechazada (tiempo de lectura)')
    # saltar el tiempo de lectura para no esperar
    sql("UPDATE vivo_sesiones SET q_inicio_ms = %d WHERE id = %s" % (ms_ahora() - 2000, sid))
    dur = int(sql("SELECT q_duracion FROM vivo_sesiones WHERE id=" + sid)) * 1000
    if i == 1:
        c, j = accion('mas_tiempo', v=0); E = j['estado']
        check(E['q']['dur'] * 1000 == dur + 15000, '+15 s amplía el tiempo de la pregunta')
        dur += 15000
    t1 = time.time(); c, j = tel.json('vivo/responder', {'token': token, 'codigo': A, 'k': q['k'], 'r': correcta(q)}); tiempos.append(time.time() - t1)
    check(c == 200 and j.get('ok'), 'equipo A responde')
    c, j = tel.json('vivo/responder', {'token': token, 'codigo': A, 'k': q['k'], 'r': incorrecta(q)})
    check(c == 409 and j.get('codigo') == 'ya', 'segunda respuesta del mismo equipo rechazada (cuenta la primera)')
    c, j = tel.json('vivo/responder', {'token': token, 'codigo': B, 'k': q['k'], 'r': incorrecta(q)})
    if i % 2 == 0:
        c, j = tel.json('vivo/responder', {'token': token, 'codigo': C, 'k': q['k'], 'r': correcta(q)})
    if i == 0:
        c, j = tel.json('vivo/responder', {'token': token, 'codigo': codigos[4], 'k': q['k'], 'r': {'v': 'no-existe'}})
        check(c == 400 and j.get('codigo') == 'vacia', 'respuesta con opción inexistente rechazada')
        c, j = tel.json('vivo/responder', {'token': token, 'codigo': codigos[4], 'k': 'q999999', 'r': correcta(q)})
        check(c == 409 and j.get('codigo') == 'cerrada', 'respuesta a otra pregunta rechazada')
        c, j = tel.json('vivo/responder', {'token': 'f' * 24, 'codigo': codigos[4], 'k': q['k'], 'r': correcta(q)})
        check(c == 403, 'token de sesión falso rechazado')
        c, j = tel.json('vivo/responder', {'token': token, 'codigo': otro, 'k': q['k'], 'r': correcta(q)})
        check(c == 403 and j.get('codigo') == 'equipo', 'equipo de otro conjunto no puede responder')
    c, j = d.get_json('docente/vivo-api', {'id': sid})
    check(len(j['estado']['respondieron']) == (3 if i % 2 == 0 else 2), 'contador de respuestas en la pantalla')
    if i == 3:
        # respuesta tardía: simular que ya pasó el tiempo más la gracia
        sql("UPDATE vivo_sesiones SET q_inicio_ms = %d WHERE id = %s" % (ms_ahora() - dur - 3000, sid))
        c, j = tel.json('vivo/responder', {'token': token, 'codigo': codigos[5], 'k': q['k'], 'r': correcta(q)})
        check(c == 409 and j.get('codigo') == 'tiempo', 'respuesta fuera de tiempo rechazada')
    c, j = accion('siguiente'); E = j['estado']
    check(E['fase'] == 'revelada' and 'rev' in E, 'se cierra y revela la pregunta %d' % (i + 1))
    st = json.loads(anon.url(archivo)[1])
    check(st['fase'] == 'revelada' and st['rev']['texto'] != '', 'los celulares reciben la respuesta esperada al revelar')
    filas = {int(x.split('|')[0]): x.split('|')[1:] for x in sql("SELECT CONCAT(equipo_id,'|',IFNULL(fraccion,''),'|',puntos,'|',bonus,'|',ms) FROM vivo_respuestas WHERE sesion_id=%s AND pregunta_k='%s'" % (sid, q['k'])).split('\n') if x}
    fa = filas[equipos_ids[A]]
    if q['tipo'] == 'respuesta_breve':
        check(fa[1] == '0' and fa[2] == '0', 'respuesta abierta sin puntos automáticos')
        muro = E.get('muro', [])
        check(len(muro) == 3 - (i % 2) and any('Análisis del equipo' in m['t'] for m in muro), 'muro de respuestas abiertas para el docente')
        check('muro' not in st, 'el texto de las respuestas abiertas no se publica en el archivo de los celulares')
        c, j = accion('otorgar', {'equipo': equipos_ids[A], 'puntos': 500}, v=0)
        c, j2 = accion('otorgar', {'equipo': equipos_ids[B], 'puntos': 300}, v=0)
        fa2 = sql("SELECT CONCAT(extra,'|',fraccion) FROM vivo_respuestas WHERE sesion_id=%s AND equipo_id=%s AND pregunta_k='%s'" % (sid, equipos_ids[A], q['k']))
        check(fa2 == '500|0.5000' and sql("SELECT extra FROM vivo_respuestas WHERE sesion_id=%s AND equipo_id=%s AND pregunta_k='%s'" % (sid, equipos_ids[B], q['k'])) == '0', 'otorgar 500 puntos a una respuesta abierta (valores no permitidos se ignoran)')
        st = json.loads(anon.url(archivo)[1])
        check(st['rev']['res'][str(equipos_ids[A])]['p'] == 500, 'los puntos otorgados llegan al celular del equipo')
        puntos_A += 500
        E = j2['estado']
    else:
        frac = float(fa[0]); pts = int(fa[1]); bonus = int(fa[2]); ms = int(fa[3])
        racha_A += 1
        bono_esp = min(racha_A - 1, 5) * 100 if racha_A >= 2 else 0
        check(abs(frac - 1) < 1e-6 and pts == esperado(1, ms, dur) and bonus == bono_esp, 'equipo A: correcto, %d pts por rapidez y bono de racha %d' % (pts, bonus))
        puntos_A += pts + bonus
        fb = filas[equipos_ids[B]]
        if q['tipo'] == 'relacion':
            check(0 < float(fb[0]) < 1 and int(fb[1]) == esperado(float(fb[0]), int(fb[3]), dur), 'relación parcial: puntos proporcionales')
        else:
            check(float(fb[0]) == 0 and fb[1] == '0' and fb[2] == '0', 'equipo B: incorrecto, 0 puntos y sin bono')
        check(sql("SELECT racha FROM vivo_equipos WHERE sesion_id=%s AND equipo_id=%s" % (sid, equipos_ids[Dq])) == '0', 'equipo que no responde pierde la racha')
    c, j = accion('siguiente'); E = j['estado']
    check(E['fase'] == 'marcador', 'marcador después de revelar')
    top = E['equipos'][0]
    ea = [e for e in E['equipos'] if e['id'] == equipos_ids[A]][0]
    detalle = '' if (ea['r'] == 1 and ea['p'] == puntos_A) else ' [obtenido %s; A=%d]' % ([(e['id'], e['p'], e['r']) for e in E['equipos'][:3]], equipos_ids[A])
    check(ea['r'] == 1 and ea['p'] == puntos_A and top['p'] == puntos_A, 'marcador: equipo A en 1.er lugar (empates comparten lugar) con %d puntos' % puntos_A + detalle)
    if i == 2:
        c, j = accion('caso'); E = j['estado']
        check(E['fase'] == 'caso' and E['i'] == 2, 'volver al caso desde el marcador')

check(E['fase'] == 'marcador' and E['i'] == len(PREG) - 1, 'se recorrieron todas las preguntas')
c, j = accion('siguiente'); E = j['estado']
check(E['fase'] == 'final', 'después de la última pregunta: podio final')
st = json.loads(anon.url(archivo)[1])
check(st['fase'] == 'final' and st['equipos'][0]['id'] == equipos_ids[A], 'los celulares ven el resultado final')
c, j = tel.json('vivo/unirse', {'codigo': codigos[0]})
check(c == 409, 'con la sesión terminada ya no se puede unir')

print('== Resultados y CSV')
c, t, _ = d.req('docente/vivo-resultados', q={'id': sid})
nombre_A = sql("SELECT nombre FROM equipos WHERE id=%d" % equipos_ids[A])
check(c == 200 and 'Exportar CSV' in t and html.escape(nombre_A, quote=False) in t, 'página de resultados con ranking' + ('' if html.escape(nombre_A, quote=False) in t else ' [falta %r]' % nombre_A))
c, t, hd = d.url(BASE + '?r=docente/vivo-exportar&id=' + sid)
lineas = [l.replace('"', '') for l in t.lstrip('﻿').split('\r\n')]
check(t.startswith('﻿') and lineas[0].startswith('Lugar,Equipo,Código,Integrantes,Puntos totales'), 'CSV con BOM y encabezados' + ('' if lineas[0].startswith('Lugar,Equipo,Código') else ' [%r]' % lineas[0][:80]))
check(len([l for l in lineas if l]) == 1 + len(equipos_ids) and (',' + str(puntos_A) + ',') in lineas[1], 'CSV: una fila por equipo, ordenado por puntos')
c, t, hd = d.url(BASE + '?r=docente/vivo-exportar&id=' + sid + '&sep=pyc')
check(t.lstrip('﻿').replace('"', '').startswith('Lugar;Equipo;'), 'CSV con punto y coma')

print('== Reinicio (ensayo), nueva sesión y limpieza')
c, t, _ = d.req('docente/vivo-resultados', q={'id': sid}); tok = d.csrf(t)
c, t, _ = d.req('docente/vivo-gestion', [('_csrf', tok), ('id', sid), ('accion', 'reiniciar')])
check(sql("SELECT COUNT(*) FROM vivo_respuestas WHERE sesion_id=" + sid) == '0' and sql("SELECT fase FROM vivo_sesiones WHERE id=" + sid) == 'lobby', 'reiniciar borra respuestas y vuelve a la sala de espera')
st = json.loads(anon.url(archivo)[1])
check(st['fase'] == 'lobby' and all(e['p'] == 0 for e in st['equipos']) and len(st['equipos']) == 10, 'tras reiniciar, los equipos siguen dentro con 0 puntos')
c, t, _ = d.req('docente/vivo'); tok = d.csrf(t)
c, t, _ = d.req('docente/vivo', [('_csrf', tok), ('caso_id', sql("SELECT id FROM casos WHERE titulo LIKE 'Cambio de chapa%'")), ('conjunto', 'Auditorio 2026'), ('titulo', ''), ('t_opcion_multiple', '30'), ('t_seleccion_multiple', '45'), ('t_relacion', '60'), ('t_respuesta_breve', '120'), ('explicacion', '1')])
sid2 = sql("SELECT MAX(id) FROM vivo_sesiones"); token2 = sql("SELECT token FROM vivo_sesiones WHERE id=" + sid2)
st = json.loads(anon.url(archivo)[1])
check(sql("SELECT CONCAT(activa,fase) FROM vivo_sesiones WHERE id=" + sid) == '0final' and st.get('sigue') == token2, 'nueva sesión cierra la anterior y avisa a los celulares (paso automático)')
snap2 = json.loads(sql("SELECT snapshot FROM vivo_sesiones WHERE id=" + sid2))
check(all(q['tipo'] != 'respuesta_breve' for q in snap2['preguntas']) and len(snap2['preguntas']) == 4, 'sin «muro»: la sesión excluye la pregunta abierta')
c, j = tel.json('vivo/unirse', {'codigo': codigos[0]})
check(c == 200 and j['token'] == token2, 'el mismo código entra a la nueva sesión')
D_viejo = D
c, t, _ = d.req('docente/vivo-pantalla', q={'id': sid2}); D = datos_js(t)
sid_guardado = sid; sid = sid2
c, j = d.get_json('docente/vivo-api', {'id': sid}); E = j['estado']
check(D['cierre_auto'] is False and D['explicacion'] is True, 'opciones de la sesión llegan a la pantalla')
sid = sid_guardado; D = D_viejo
c, j = d.get_json('docente/vivo-api', {'id': sid}); E = j['estado']
c, j = accion('siguiente')
check(c == 409 and j.get('codigo') == 'inactiva', 'la sesión cerrada ya no acepta acciones')
c, t, _ = d.req('docente/vivo-resultados', q={'id': sid}); tok = d.csrf(t)
c, t, _ = d.req('docente/vivo-gestion', [('_csrf', tok), ('id', sid), ('accion', 'eliminar')])
c2, _, _ = anon.url(archivo)
check(sql("SELECT COUNT(*) FROM vivo_sesiones WHERE id=" + sid) == '0' and c2 in (403, 404), 'eliminar la sesión borra sus datos y su archivo')

print('== Límite de intentos por red')
sql("INSERT INTO intentos_acceso (ip, codigo, exito, momento) SELECT '127.0.0.1', 'vivo:XXXXXX', 0, NOW() FROM seq_1_to_200")
c, j = tel.json('vivo/unirse', {'codigo': codigos[0]})
check(c == 429 and j.get('codigo') == 'limite', '200 códigos erróneos desde la misma red → espera obligatoria (429)')
x = Cliente(); c, t, _ = x.req('login'); tk = x.csrf(t)
c, t, _ = x.req('login', [('_csrf', tk), ('codigo', 'jprofesor'), ('clave', 'Docente-Seguro-2026')])
check(c == 200 and 'menu-principal' in t and 'Demasiados' not in t, 'los códigos de equipo erróneos NO bloquean el acceso de docentes')
sql("DELETE FROM intentos_acceso WHERE codigo LIKE 'vivo:%'")

print('\nTiempo medio de unirse/responder: %.1f ms (máx. %.1f ms, %d llamadas)' % (1000 * sum(tiempos) / len(tiempos), 1000 * max(tiempos), len(tiempos)))
print('RESUMEN: %d correctas, %d fallidas' % (len(OK), len(FAIL)))
for f in FAIL: print('  FALLÓ: ' + f)
