import urllib.request, urllib.parse, http.cookiejar, re, json, sys, subprocess, html
BASE = 'http://localhost/cucosta/casosvideo/index.php'
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 req(self, ruta, datos=None, q=None, headers=None, files=None, seguir=True):
        url = BASE + '?' + urllib.parse.urlencode(dict({'r': ruta}, **(q or {})))
        body = None; h = dict(headers or {})
        if files is not None:
            b = '----cvlimite7'; partes = []
            for k, v in (datos or []):
                partes.append(('--%s\r\nContent-Disposition: form-data; name="%s"\r\n\r\n%s\r\n' % (b, k, v)).encode())
            for k, (fn, cont, ct) in files.items():
                partes.append(('--%s\r\nContent-Disposition: form-data; name="%s"; filename="%s"\r\nContent-Type: %s\r\n\r\n' % (b, k, fn, ct)).encode() + cont + b'\r\n')
            partes.append(('--%s--\r\n' % b).encode())
            body = b''.join(partes); h['Content-Type'] = 'multipart/form-data; boundary=' + b
        elif datos is not None:
            body = urllib.parse.urlencode(datos).encode(); h['Content-Type'] = 'application/x-www-form-urlencoded'
        r = urllib.request.Request(url, data=body, headers=h)
        try:
            resp = self.op.open(r); code = resp.status; txt = resp.read(); loc = resp.headers.get('Location')
        except urllib.error.HTTPError as e:
            code = e.code; txt = e.read(); loc = e.headers.get('Location')
        if loc and seguir and code in (301, 302, 303):
            return self.get_url(loc)
        return code, txt.decode('utf-8', 'replace'), loc
    def get_url(self, loc):
        if loc.startswith('/'): loc = 'http://localhost' + loc
        r = urllib.request.Request(loc)
        try:
            resp = self.op.open(r); return resp.status, resp.read().decode('utf-8', 'replace'), None
        except urllib.error.HTTPError as e:
            l = e.headers.get('Location')
            if l and e.code in (301,302,303): return self.get_url(l)
            return e.code, e.read().decode('utf-8', 'replace'), None
    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()

print('== Instalación')
d = Cliente()
c, t, _ = d.req('login'); check('aún no tiene una cuenta docente' in t, 'login avisa que falta la cuenta docente')
c, t, _ = d.req('setup'); tok = d.csrf(t); check('Primera cuenta docente' in t, 'asistente de instalación disponible')
c, t, _ = d.req('setup', [('_csrf', tok), ('token', 'equivocado'), ('codigo', 'jprofesor'), ('nombre', 'Juan Profesor'), ('correo', ''), ('clave', 'Docente-Seguro-2026'), ('clave2', 'Docente-Seguro-2026')])
check('no coincide' in t and sql("SELECT COUNT(*) FROM usuarios") == '0', 'token incorrecto rechazado')
c, t, _ = d.req('setup', [('_csrf', tok), ('token', 'token-de-prueba-largo-2026-cucosta'), ('codigo', 'jprofesor'), ('nombre', 'Juan Profesor'), ('correo', ''), ('clave', 'corta1'), ('clave2', 'corta1')])
check('al menos 8' in t or '12 caracteres' in t, 'contraseña débil rechazada')
c, t, _ = d.req('setup', [('_csrf', tok), ('token', 'token-de-prueba-largo-2026-cucosta'), ('codigo', 'jprofesor'), ('nombre', 'Juan Profesor'), ('correo', 'juan@example.com'), ('clave', 'Docente-Seguro-2026'), ('clave2', 'Docente-Seguro-2026')])
check('Cuenta administradora creada' in t, 'cuenta administradora creada')
c, t, _ = d.req('setup'); check(c == 404, 'asistente bloqueado tras la instalación (404)')
check(sql("SELECT password_hash LIKE '$2y$%' FROM usuarios WHERE codigo='jprofesor'") == '1', 'contraseña guardada con bcrypt')

print('== Acceso docente')
c, t, _ = d.req('login'); tok = d.csrf(t)
c, t, _ = d.req('login', [('_csrf', tok), ('codigo', 'jprofesor'), ('clave', 'mala-123')]); check('no son correctos' in t, 'contraseña incorrecta: mensaje genérico')
c, t, _ = d.req('login', [('codigo', 'jprofesor'), ('clave', 'Docente-Seguro-2026')]); check(c == 400, 'login sin CSRF rechazado')
c, t, _ = d.req('login'); tok = d.csrf(t)
c, t, _ = d.req('login', [('_csrf', tok), ('codigo', 'jprofesor'), ('clave', 'Docente-Seguro-2026')]); check('Panel docente' in t, 'docente ingresa al panel')
for ruta in ['docente/casos', 'docente/actividades', 'docente/grupos', 'docente/revisiones', 'docente/resultados', 'docente/videos', 'docente/docentes', 'docente/alumnos']:
    c, t, _ = d.req(ruta); check(c == 200 and 'Error' not in re.findall(r'<title>(.*?)</title>', t)[0], 'página ' + ruta)
for cid in range(1, 9):
    c, t, _ = d.req('docente/caso', q={'id': cid}); check(c == 200 and 'Video pendiente' in t, 'caso %d muestra aviso de video pendiente y guion' % cid)
c, t, _ = d.req('docente/vista-previa', q={'caso': 1, 'claves': 1}); check('Solo docente' in t, 'vista previa con claves para el docente')

print('== Grupo y alumnos')
c, t, _ = d.req('docente/grupos'); tok = d.csrf(t)
c, t, _ = d.req('docente/grupos', [('_csrf', tok), ('clave', 'D01-2026B'), ('nombre', 'Derecho Penal I D01'), ('ciclo', '2026B')]); check('Grupo creado' in t, 'grupo creado')
gid = sql("SELECT id FROM grupos WHERE clave='D01-2026B'")
lista = "codigo,nombre,correo\n219000001,Ana López Ruiz,ana@example.com\n219000002;Carlos Peña Díaz\n219000003,=HYPERLINK(\"http://x\"),\nmal codigo,Sin Nombre"
c, t, _ = d.req('docente/alumnos', [('_csrf', tok), ('accion', 'importar'), ('grupo', gid), ('lista', lista)])
creds = dict(re.findall(r'<td><code>(\d+)</code></td><td>.*?</td><td>.*?</td><td><code class="clave-temp">([^<]+)</code>', t))
check(len(creds) == 3 and 'Línea 4' in t, 'importación: 3 creados y 1 línea con error reportada')
c, t2, _ = d.req('docente/credenciales'); check('No hay contraseñas por mostrar' in t2, 'contraseñas temporales solo se muestran una vez')
check(sql("SELECT COUNT(*) FROM usuarios WHERE rol='alumno' AND debe_cambiar_clave=1") == '3', 'alumnos con cambio de contraseña obligatorio')

print('== Actividad')
aid = '1'
c, t, _ = d.req('docente/actividad-editar', q={'id': aid}); tok = d.csrf(t)
c, t, _ = d.req('docente/actividad-editar', [('_csrf', tok), ('id', aid), ('caso_id', '1'), ('titulo', 'Arrebato en el malecón'), ('instrucciones', 'Responde con cuidado.'), ('grupos[]', gid), ('abre_en', ''), ('cierra_en', '2026-12-15T23:59'), ('duracion_min', ''), ('intentos_max', '2'), ('criterio_final', 'mejor'), ('publicacion', 'al_entregar'), ('mostrar_claves', '1')], q={'id': aid})
check('Actividad actualizada' in t, 'actividad configurada con grupo y cierre')
c, t, _ = d.req('docente/actividad-estado', [('_csrf', tok), ('id', aid), ('accion', 'publicar')]); check('sin validar' in t and sql("SELECT estado FROM actividades WHERE id=1") == 'borrador', 'publicar exige confirmar fundamentos sin validar')
c, t, _ = d.req('docente/actividad-estado', [('_csrf', tok), ('id', aid), ('accion', 'publicar'), ('confirmo_fundamentos', '1')]); check(sql("SELECT estado FROM actividades WHERE id=1") == 'publicada', 'actividad publicada con confirmación')
fid = sql("SELECT id FROM fundamentos WHERE caso_id=1 AND articulo='Art. 233'")
c, t, _ = d.req('docente/fundamento-validar', [('_csrf', tok), ('id', fid)]); check(sql("SELECT estado FROM fundamentos WHERE id=%s" % fid) == 'verificado', 'docente valida un fundamento')

print('== Alumno')
a = Cliente(); cod = '219000001'
c, t, _ = a.req('login'); tok = a.csrf(t)
c, t, _ = a.req('login', [('_csrf', tok), ('codigo', cod), ('clave', creds[cod])]); check('contraseña temporal' in t, 'primer ingreso obliga a cambiar contraseña')
c, t, _ = a.req('alumno'); check('Cambiar contraseña' in t, 'no puede ver el panel sin cambiar la contraseña')
tok = a.csrf(t)
c, t, _ = a.req('cambiar-clave', [('_csrf', tok), ('actual', creds[cod]), ('nueva', 'Alumna-2026b'), ('nueva2', 'Alumna-2026b')]); check('Mis actividades' in t and 'Arrebato en el malec' in t, 'cambia contraseña y ve la actividad asignada')
c, t, _ = a.req('docente'); check(c == 403, 'alumno no accede al panel docente (403)')
c, t, _ = a.req('alumno/actividad', q={'id': aid}); tok = a.csrf(t)
c, t, _ = a.req('alumno/iniciar', [('_csrf', tok), ('actividad', aid)]); iid = sql("SELECT id FROM intentos WHERE actividad_id=1 AND alumno_id=(SELECT id FROM usuarios WHERE codigo='%s')" % cod)
check(bool(iid) and 'form-intento' in t, 'intento iniciado')
check('El art. 233 del CPJ dispone' not in t and 'respuesta_sugerida' not in t and '"correcta"' not in t and 'Sujeto B: robo (art. 233' not in t and 'Solo docente' not in t, 'el HTML del intento no contiene claves, explicaciones ni respuesta sugerida')
datos = json.loads(html.unescape(re.search(r'id="cv-datos">(.*?)</script>', t, re.S).group(1)))
check('clave' not in json.dumps(datos) and datos.get('rev') == 0, 'datos para JavaScript sin claves')
ks = re.findall(r'data-k="(q\d+)" data-tipo="([a-z_]+)"', t)
check(len(ks) == 5, 'se muestran las 5 preguntas')
qm = {k: tp for k, tp in ks}
sel_items = re.findall(r'name="r\[(q\d+)\]\[v\]\[(i\d+)\]"', t)
op_rel = re.findall(r'<option value="(o\d+)"', t)
# Respuestas: relación con primera opción, OM b, SM a+b, ins en la 4a, abierta con texto
snap = json.loads(sql("SELECT snapshot FROM intentos WHERE id=%s" % iid))
pq = snap['preguntas']
resp = [('_csrf', tok), ('intento', iid), ('rev', '0')]
for it in pq[0]['datos']['items']:
    resp.append(('r[%s][v][%s]' % (pq[0]['k'], it['id']), pq[0]['clave']['pares'][it['id']]))
resp.append(('r[%s][v]' % pq[1]['k'], 'b'))
resp += [('r[%s][v][]' % pq[2]['k'], 'a'), ('r[%s][v][]' % pq[2]['k'], 'b')]
resp += [('r[%s][ins]' % pq[3]['k'], '1'), ('r[%s][falta]' % pq[3]['k'], 'Falta el avalúo pericial del celular.')]
resp.append(('r[%s][v]' % pq[4]['k'], 'Sujeto B cometió robo consumado (art. 233 CPJ) con violencia y vehículo (art. 236 fr. I y XIX). La intervención de A es una inferencia.'))
c, t, _ = a.req('alumno/guardar', resp, headers={'Accept': 'application/json', 'X-CSRF-Token': tok}); j = json.loads(t)
check(j.get('ok') and j.get('rev') == 1 and j.get('respondidas') == 5, 'guardado automático (JSON) con 5 respondidas')
c, t, _ = a.req('alumno/guardar', [x for x in resp if x[0] != '_csrf'], headers={'Accept': 'application/json'}); check(c == 400 and json.loads(t)['codigo'] == 'csrf', 'guardado sin CSRF rechazado')
anon = Cliente(); c, t, _ = anon.req('alumno/guardar', resp, headers={'Accept': 'application/json'}); check(c == 401 and json.loads(t)['codigo'] == 'sesion', 'guardado sin sesión devuelve 401 JSON')
c, t, _ = a.req('alumno/intento', q={'id': iid}); check('Falta el avalúo pericial' in t and 'checked' in t, 'la página recupera lo guardado')

b = Cliente(); cod2 = '219000002'
c, t, _ = b.req('login'); tok2 = b.csrf(t); b.req('login', [('_csrf', tok2), ('codigo', cod2), ('clave', creds[cod2])])
c, t, _ = b.req('cambiar-clave'); tok2 = b.csrf(t); b.req('cambiar-clave', [('_csrf', tok2), ('actual', creds[cod2]), ('nueva', 'Carlos-2026x'), ('nueva2', 'Carlos-2026x')])
c, t, _ = b.req('alumno/intento', q={'id': iid}); check(c == 404, 'otro alumno no puede ver un intento ajeno (404)')
c, t, _ = b.req('alumno/guardar', [('_csrf', b.csrf(b.req('alumno')[1])), ('intento', iid)], headers={'Accept': 'application/json'}); check(c == 404, 'otro alumno no puede guardar en un intento ajeno')

print('== Entrega')
c, t, _ = a.req('alumno/intento', q={'id': iid}); tok = a.csrf(t)
ent = [x for x in resp if x[0] not in ('_csrf', 'rev')] + [('_csrf', tok), ('accion', 'entregar')]
c, t, _ = a.req('alumno/entregar', ent); check('marca la casilla' in t and sql("SELECT estado FROM intentos WHERE id=%s" % iid) == 'en_curso', 'entregar sin confirmación no entrega')
c, t, _ = a.req('alumno/entregar', ent + [('confirmo', '1')])
check('Comprobante de entrega' in t and re.search(r'CV\d{6}-0+\d+', t) is not None, 'entrega con comprobante y folio')
check(sql("SELECT CONCAT(estado,'|',entregado_por,'|',estado_revision,'|',puntaje_auto,'/',puntaje_auto_max,'|',abiertas_pendientes) FROM intentos WHERE id=%s" % iid) == 'entregado|alumno|pendiente|6.00/6.00|1', 'calificación automática 6/6 y abierta pendiente')
c, t, _ = a.req('alumno/entregar', ent + [('confirmo', '1')]); check('Comprobante' in t and sql("SELECT COUNT(*) FROM intentos WHERE alumno_id=(SELECT id FROM usuarios WHERE codigo='%s')" % cod) == '1', 'reenvío no duplica ni modifica la entrega')
c, t, _ = a.req('alumno/resultado', q={'id': iid})
check('Puntaje automático' in t and 'Pendiente' in t and 'En espera' in t, 'resultados: automático, abierta pendiente y final en espera')
check('Respuesta esperada' not in t and 'El art. 233 del CPJ dispone' not in t, 'claves ocultas mientras queden intentos')

print('== Revisión docente')
c, t, _ = d.req('docente/revisiones'); check('Juan' in t or cod in t or 'Ana' in t, 'la entrega aparece en revisión pendiente')
c, t, _ = d.req('docente/revisar', q={'id': iid}); tok = d.csrf(t)
check('íntegra' in t, 'huella del comprobante verificada como íntegra')
k5 = pq[4]['k']
rev = [('_csrf', tok), ('id', iid), ('observaciones', 'Buen análisis de la consumación.'), ('com[%s]' % k5, 'Falta desarrollar el art. 19.'), ('siguiente', '0')]
c, t, _ = d.req('docente/revisar', rev + [('c[%s][c1]' % k5, '2')]); check('todos los criterios' in t, 'rúbrica incompleta no se guarda')
rev += [('c[%s][c%d]' % (k5, i), v) for i, v in zip(range(1, 6), ['2', '2', '1', '1', '2'])]
c, t, _ = d.req('docente/revisar', rev)
check(sql("SELECT CONCAT(estado_revision,'|',puntaje_revision,'|',calificacion_final) FROM intentos WHERE id=%s" % iid) == 'revisado|3.20|92.00', 'rúbrica 8/10 → 3.2/4 pts; final 92/100')
c, t, _ = a.req('alumno/resultado', q={'id': iid}); check('92' in t and 'Falta desarrollar el art. 19' in t and 'Buen análisis' in t, 'el alumno ve calificación final, comentario y observaciones')

print('== Resultados y CSV')
c, t, _ = d.req('docente/resultados', q={'actividad': aid}); check('92' in t and 'Sin entrega' in t, 'resultados por actividad con alumnos sin entrega')
c, t, _ = d.req('docente/exportar', q={'actividad': aid})
check(t.startswith('\ufeff') and 'Calificación final (0-100)' in t and ',92,' in t and '\r\n' in t, 'CSV con BOM, CRLF, encabezados y calificación')
check("'=HYPERLINK" in t, 'CSV neutraliza fórmulas en nombres')
c, t, _ = d.req('docente/exportar', q={'actividad': aid, 'sep': 'pyc'}); check(';92;' in t, 'CSV con punto y coma opcional')
c, t, _ = d.req('docente/resultados', q={'materia': 'penal'}); check(c == 200 and '92' in t, 'filtro por materia')

print('== Versionado, tiempo y claves')
qid = sql("SELECT id FROM preguntas WHERE caso_id=1 ORDER BY orden LIMIT 1 OFFSET 1")
sql("UPDATE preguntas SET enunciado='ENUNCIADO MODIFICADO' WHERE id=%s" % qid)
check('ENUNCIADO MODIFICADO' not in sql("SELECT snapshot FROM intentos WHERE id=%s" % iid), 'el intento conserva la versión congelada de las preguntas')
c, t, _ = a.req('alumno/actividad', q={'id': aid}); tok = a.csrf(t)
c, t, _ = a.req('alumno/iniciar', [('_csrf', tok), ('actividad', aid)]); iid2 = sql("SELECT MAX(id) FROM intentos")
check('ENUNCIADO MODIFICADO' in t, 'el nuevo intento usa la versión actualizada')
c, t, _ = a.req('alumno/resultado', q={'id': iid}); check('Respuesta esperada' not in t, 'con otro intento en curso las claves siguen ocultas')
hace10 = subprocess.run(['php', '-r', 'date_default_timezone_set("America/Mexico_City"); echo date("Y-m-d H:i:s", time()-600);'], capture_output=True, text=True).stdout
sql("UPDATE intentos SET vence_en = '%s' WHERE id=%s" % (hace10, iid2))
c, t, _ = a.req('alumno'); check(sql("SELECT CONCAT(estado,'|',entregado_por) FROM intentos WHERE id=%s" % iid2) == 'entregado|tiempo', 'intento vencido se entrega automáticamente por tiempo')
c, t, _ = a.req('alumno/resultado', q={'id': iid}); check('Respuesta esperada' in t and 'El art. 233 del CPJ dispone' in t, 'claves visibles al agotar los intentos')
check('Fundamentos del caso' in t and 'Art. 233' in t and 'Art. 236 Bis' not in t, 'solo fundamentos verificados se muestran al alumno')
sql("UPDATE preguntas SET enunciado=(SELECT enunciado FROM (SELECT enunciado FROM preguntas WHERE id=%s) x) WHERE id=%s" % (qid, qid))

print('== Medios')
c, t, _ = d.req('docente/videos'); tok = d.csrf(t)
c, t, _ = d.req('docente/videos', [('_csrf', tok), ('accion', 'subir_video')], files={'video': ('falso.mp4', b'<?php echo 1; ?> esto no es video', 'video/mp4')})
check('no es un video MP4 válido' in t, 'MP4 falso rechazado')
mp4 = open(__import__('os').path.join(__import__('os').path.dirname(__import__('os').path.abspath(__file__)), 'min.mp4'), 'rb').read()
c, t, _ = d.req('docente/videos', [('_csrf', tok), ('accion', 'subir_video')], files={'video': ('Mi video.mp4', mp4, 'video/mp4')})
nombre = re.search(r'(video-\d{8}-\d{6}-[0-9a-f]{8}\.mp4)', t)
check(nombre is not None, 'MP4 válido aceptado con nombre aleatorio')
vtt = b'WEBVTT\n\n00:00.000 --> 00:02.000\nLucia camina por el malecon.\n'
c, t, _ = d.req('docente/videos', [('_csrf', tok), ('accion', 'subir_vtt')], files={'subtitulos': ('subs.vtt', vtt, 'text/vtt')})
vn = re.search(r'(subtitulos-\d{8}-\d{6}-[0-9a-f]{8}\.vtt)', t); check(vn is not None, 'subtítulos VTT aceptados')
c, t, _ = d.req('docente/videos', [('_csrf', tok), ('accion', 'subir_vtt')], files={'subtitulos': ('x.vtt', b'<script>alert(1)</script>', 'text/vtt')}); check('no es WebVTT' in t, 'VTT inválido rechazado')
if nombre:
    r = urllib.request.urlopen('http://localhost/cucosta/casosvideo/media/videos/' + nombre.group(1)); check(r.status == 200 and r.headers['Content-Type'].startswith('video/mp4'), 'el video se sirve como video/mp4')
    c, t, _ = d.req('docente/caso-editar', q={'id': 1}); tok = d.csrf(t)
    campos = [('_csrf', tok), ('id', '1'), ('titulo', 'Arrebato en el malecón'), ('dificultad', '1'), ('video_tipo', 'mp4'), ('video_existente', nombre.group(1)), ('subtitulos_existente', vn.group(1) if vn else '')]
    for k in ['objetivo', 'lugar', 'guion', 'hechos_complementarios', 'notas_docente', 'transcripcion', 'descripcion_accesible']:
        campos.append((k, sql("SELECT %s FROM casos WHERE id=1" % k)))
    c, t, _ = d.req('docente/caso-editar', campos, q={'id': 1}, files={})
    check('<video id="cv-video"' in t and 'kind="subtitles"' in t, 'caso con video MP4 y subtítulos')
    campos2 = [x for x in campos if x[0] not in ('video_tipo',)] + [('video_tipo', 'youtube'), ('youtube_url', 'https://youtu.be/dQw4w9WgXcQ?t=3')]
    c, t, _ = d.req('docente/caso-editar', campos2, q={'id': 1}, files={}); check('youtube-nocookie.com/embed/dQw4w9WgXcQ' in t, 'enlace de YouTube validado y embebido sin cookies')
    campos3 = [x for x in campos if x[0] != 'video_tipo'] + [('video_tipo', 'youtube'), ('youtube_url', 'https://evil.example/watch?v=dQw4w9WgXcQ')]
    c, t, _ = d.req('docente/caso-editar', campos3, q={'id': 1}, files={}); check('no es válida' in t, 'enlace de otro dominio rechazado')
open('/tmp/x', 'w')
for u, esperado in [('media/videos/prueba.php', 403), ('media/videos/a.php.mp4', 403), ('app/config.sample.php', 403), ('app/lib/db.php', 403), ('app/logs/', 403), ('media/videos/', 403), ('app/views/layout.php', 403), ('.htaccess', 403)]:
    try:
        cc = urllib.request.urlopen('http://localhost/cucosta/casosvideo/' + u).status
    except urllib.error.HTTPError as e:
        cc = e.code
    check(cc == esperado, 'acceso directo bloqueado: %s (%d)' % (u, cc))

print('\nRESUMEN: %d correctas, %d fallidas' % (len(OK), len(FAIL)))
for f in FAIL: print('  - ' + f)
