"""
PROPÓSITO
---------
Protocolo de pruebas de simulación para AR-Autopilot.

POR QUÉ EXISTE
--------------
Valida el comportamiento del autopiloto en 7 casos de prueba canónicos
antes de conectar el ESP32 real. Cada TC crea un simulador limpio, ejecuta
un escenario, evalúa criterios de aceptación y reporta PASS / FAIL con
métricas cuantitativas.

Al ejecutarse produce dos salidas:
    - Resumen en consola (tabla ASCII)
    - Informe HTML completo con gráficas interactivas (Chart.js)

CÓMO USARLO
-----------
    python tools/sim_protocol.py                # todos los TCs
    python tools/sim_protocol.py TC-02 TC-04    # solo esos TCs
    python tools/sim_protocol.py --out mi_reporte.html

RELACIONADO
-----------
    tools/esp32_sim.py    — simulador ESP32 en Python
    docs/TEST_PROTOCOL.md — especificación del protocolo
"""

from __future__ import annotations

import argparse
import html
import json
import math
import sys
import textwrap
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Callable

# Añadir raíz del proyecto al path
sys.path.insert(0, str(Path(__file__).parent.parent))

from arautopilot.studio.simulator.vessel_heading import heading_error_deg
from tools.esp32_sim import AutopilotMode, ESP32Simulator, SimSnapshot


# ---------------------------------------------------------------------------
# Estructuras de datos del protocolo
# ---------------------------------------------------------------------------
@dataclass
class TCSpec:
    id: str
    name: str
    description: str
    acceptance: str  # Criterios de aceptación en lenguaje natural


@dataclass
class TCResult:
    spec: TCSpec
    passed: bool
    metrics: dict[str, float | str]
    message: str
    snapshots: list[SimSnapshot]
    events: list


# ---------------------------------------------------------------------------
# Casos de prueba
# ---------------------------------------------------------------------------

def tc01_power_on_engage() -> TCResult:
    """
    TC-01 — Arranque y enganche
    ===========================
    Verifica que el autopiloto parte en STANDBY y transita correctamente a
    HEADING_HOLD cuando hay rumbo NMEA válido y se envía CMD_ENGAGE_REQUEST.

    Criterios de aceptación:
      • Arranca en STANDBY
      • Transita a HEADING_HOLD tras engage
      • Mantiene HEADING_HOLD durante 10 s sin alarmas
      • Error final < 1°
    """
    spec = TCSpec(
        id="TC-01",
        name="Arranque y enganche en HEADING_HOLD",
        description="El piloto arranca en STANDBY y engancha correctamente con rumbo NMEA válido.",
        acceptance="Modo final HEADING_HOLD, error < 1°, sin alarmas en 10 s.",
    )

    sim = ESP32Simulator()

    # Verificar estado inicial STANDBY
    started_in_standby = sim.mode == AutopilotMode.STANDBY

    # Inyectar rumbo válido y velocidad
    sim.inject_nmea(heading_deg=90.0, sog_kn=10.0)
    sim.step(0.5)  # deja que el NMEA se estabilice

    # Configurar holdings y enganchar
    sim.write_holding(0, 1)       # MODE_REQUEST = HEADING_HOLD
    sim.write_holding(1, 9000)    # HEADING_SETPOINT_X100 = 90.00°
    sim.write_coil(0, 1)          # CMD_ENGAGE_REQUEST

    sim.step(0.1)  # procesar el engage

    engaged_in_hh = sim.mode == AutopilotMode.HEADING_HOLD

    # Correr 10 segundos manteniendo el rumbo
    sim.step(10.0)

    final_error = abs(heading_error_deg(90.0, sim.heading))
    any_alarm = sim.any_alarm
    final_mode = sim.mode

    passed = (
        started_in_standby
        and engaged_in_hh
        and final_mode == AutopilotMode.HEADING_HOLD
        and final_error < 1.0
        and not any_alarm
    )

    return TCResult(
        spec=spec,
        passed=passed,
        metrics={
            "started_in_standby": "Sí" if started_in_standby else "No",
            "engaged_correctly": "Sí" if engaged_in_hh else "No",
            "final_mode": final_mode.name,
            "final_error_deg": round(final_error, 3),
            "any_alarm": "No" if not any_alarm else "Sí",
        },
        message=(
            f"Inicio en STANDBY: {'OK' if started_in_standby else 'NO'} | "
            f"Enganche: {'OK' if engaged_in_hh else 'NO'} | "
            f"Error final: {final_error:.3f}° | "
            f"Alarmas: {'ninguna' if not any_alarm else 'PRESENTES'}"
        ),
        snapshots=sim.log,
        events=sim.events,
    )


def tc02_small_heading_change() -> TCResult:
    """
    TC-02 — Cambio de rumbo pequeño (10°)
    ======================================
    El autopiloto está enganchado en 90°. Se ordena virar a 100°.
    Mide tiempo de asentamiento, sobrepasamiento y error residual.

    Criterios de aceptación:
      • Asentamiento (|error| < 1°) en < 60 s
      • Sobrepasamiento < 5°
      • Error final < 1°
    """
    spec = TCSpec(
        id="TC-02",
        name="Cambio de rumbo pequeño +10°",
        description="Maniobra de 90° → 100°. Evalúa tiempo de asentamiento y sobrepasamiento.",
        acceptance="Asentamiento < 60 s, sobrepasamiento < 5°, error final < 1°.",
    )

    sim = ESP32Simulator()
    TARGET = 100.0
    INITIAL = 90.0

    sim.inject_nmea(heading_deg=INITIAL, sog_kn=10.0)
    sim.write_holding(0, 1)
    sim.write_holding(1, int(INITIAL * 100))
    sim.write_coil(0, 1)
    sim.step(5.0)   # estabilizar en 90°

    # Nuevo setpoint: 100°
    sim.write_holding(1, int(TARGET * 100))
    t_step = sim.t
    sim.step(120.0)

    snapshots_after = [s for s in sim.log if s.t >= t_step]

    # Tiempo de asentamiento: último instante con |error| > 1°
    settling_time = _settling_time(snapshots_after, TARGET, tolerance=1.0)

    # Sobrepasamiento: cruce al otro lado del setpoint
    overshoot = _overshoot(snapshots_after, INITIAL, TARGET)

    final_error = abs(heading_error_deg(TARGET, sim.heading))

    passed = (
        sim.mode == AutopilotMode.HEADING_HOLD
        and final_error < 1.0
        and settling_time is not None
        and settling_time < 60.0
        and overshoot < 5.0
    )

    return TCResult(
        spec=spec,
        passed=passed,
        metrics={
            "final_error_deg": round(final_error, 3),
            "settling_time_s": round(settling_time, 1) if settling_time else ">120",
            "overshoot_deg": round(overshoot, 2),
            "final_mode": sim.mode.name,
        },
        message=(
            f"Error: {final_error:.3f}° | "
            f"Asentamiento: {settling_time:.1f}s | "
            f"Sobrepasamiento: {overshoot:.2f}°"
            if settling_time else
            f"Error: {final_error:.3f}° | NO ASENTÓ en 120 s"
        ),
        snapshots=sim.log,
        events=sim.events,
    )


def tc03_large_heading_change() -> TCResult:
    """
    TC-03 — Cambio de rumbo grande (90°)
    =====================================
    Maniobra agresiva de 90°. Verifica que el ROT feed-forward limita el
    sobrepasamiento y que el cascada PID completa la maniobra sin oscilar.

    Criterios de aceptación:
      • Asentamiento (|error| < 2°) en < 180 s
      • Sobrepasamiento < 10°
      • Error final < 2°
      • Sin alarma OFF_COURSE (error no supera 30°)
    """
    spec = TCSpec(
        id="TC-03",
        name="Cambio de rumbo grande +90°",
        description="Maniobra de 90° → 180°. Valida el ROT feed-forward y la cascada completa.",
        acceptance="Asentamiento < 180 s, sobrepasamiento < 10°, error final < 2°.",
    )

    sim = ESP32Simulator()
    TARGET = 180.0
    INITIAL = 90.0

    sim.inject_nmea(heading_deg=INITIAL, sog_kn=10.0)
    sim.write_holding(0, 1)
    sim.write_holding(1, int(INITIAL * 100))
    sim.write_coil(0, 1)
    sim.step(5.0)

    t_step = sim.t
    sim.write_holding(1, int(TARGET * 100))
    sim.step(240.0)

    snapshots_after = [s for s in sim.log if s.t >= t_step]

    settling_time = _settling_time(snapshots_after, TARGET, tolerance=2.0)
    overshoot = _overshoot(snapshots_after, INITIAL, TARGET)
    final_error = abs(heading_error_deg(TARGET, sim.heading))
    alarm_severe = sim.read_discrete(17) or sim._alarm_off_course_severe

    passed = (
        sim.mode == AutopilotMode.HEADING_HOLD
        and final_error < 2.0
        and settling_time is not None
        and settling_time < 180.0
        and overshoot < 10.0
        and not alarm_severe
    )

    return TCResult(
        spec=spec,
        passed=passed,
        metrics={
            "final_error_deg": round(final_error, 3),
            "settling_time_s": round(settling_time, 1) if settling_time else ">240",
            "overshoot_deg": round(overshoot, 2),
            "alarm_off_course_severe": "No" if not alarm_severe else "Sí",
        },
        message=(
            f"Error: {final_error:.3f}° | "
            f"Asentamiento: {settling_time:.1f}s | "
            f"Sobrepasamiento: {overshoot:.2f}°"
        ),
        snapshots=sim.log,
        events=sim.events,
    )


def tc04_boundary_crossing() -> TCResult:
    """
    TC-04 — Cruce de frontera 0°/360°
    ==================================
    El buque va a 355° y se ordena virar a 010°. La lógica de arco más
    corto debe elegir +15° (a estribor) en vez de -345° (a babor).

    Criterios de aceptación:
      • El timón gira a ESTRIBOR (positivo) durante la maniobra
      • Asentamiento en 010° en < 90 s
      • Error final < 1°
    """
    spec = TCSpec(
        id="TC-04",
        name="Cruce de frontera 355° → 010°",
        description=(
            "Maniobra que cruza el meridiano 0°/360°. "
            "Verifica que el shortest-arc elige el camino de +15° (estribor)."
        ),
        acceptance="Timón a estribor, asentamiento < 90 s, error final < 1°.",
    )

    sim = ESP32Simulator()
    INITIAL = 355.0
    TARGET = 10.0

    sim.inject_nmea(heading_deg=INITIAL, sog_kn=10.0)
    sim.write_holding(0, 1)
    sim.write_holding(1, int(INITIAL * 100))
    sim.write_coil(0, 1)
    sim.step(5.0)

    t_step = sim.t
    sim.write_holding(1, int(TARGET * 100))
    sim.step(120.0)

    snapshots_after = [s for s in sim.log if s.t >= t_step]

    # Verificar que el timón fue a estribor (positivo)
    max_rudder_stbd = max(s.rudder_angle_deg for s in snapshots_after[:30])
    went_starboard = max_rudder_stbd > 2.0

    settling_time = _settling_time(snapshots_after, TARGET, tolerance=1.0)
    final_error = abs(heading_error_deg(TARGET, sim.heading))

    passed = (
        went_starboard
        and final_error < 1.0
        and settling_time is not None
        and settling_time < 90.0
    )

    return TCResult(
        spec=spec,
        passed=passed,
        metrics={
            "went_starboard": "Sí" if went_starboard else "No",
            "max_rudder_stbd_deg": round(max_rudder_stbd, 2),
            "final_error_deg": round(final_error, 3),
            "settling_time_s": round(settling_time, 1) if settling_time else ">120",
        },
        message=(
            f"Timón a estribor: {'OK' if went_starboard else 'NO'} "
            f"(máx={max_rudder_stbd:.1f}°) | "
            f"Error: {final_error:.3f}° | "
            f"Asentamiento: {settling_time:.1f}s" if settling_time else
            f"Timón a estribor: {'OK' if went_starboard else 'NO'} | NO ASENTÓ"
        ),
        snapshots=sim.log,
        events=sim.events,
    )


def tc05_manual_disengage() -> TCResult:
    """
    TC-05 — Desenganche manual
    ==========================
    El autopiloto está activo. El operador pulsa DISENGAGE.
    El timón debe dejar de ser controlado (PWM = 0) inmediatamente.

    Criterios de aceptación:
      • El modo pasa a STANDBY en ≤ 1 tick (0.02 s)
      • El PWM cae a 0 tras el desenganche
      • El timón no recibe nuevas órdenes después del desenganche
    """
    spec = TCSpec(
        id="TC-05",
        name="Desenganche manual (DISENGAGE)",
        description=(
            "Pulsar CMD_DISENGAGE_REQUEST mientras el autopiloto está en HEADING_HOLD. "
            "El sistema debe pasar a STANDBY y soltar el timón."
        ),
        acceptance="STANDBY en ≤ 0.02 s, PWM = 0, sin nuevas órdenes al timón.",
    )

    sim = ESP32Simulator()

    sim.inject_nmea(heading_deg=90.0, sog_kn=10.0)
    sim.write_holding(0, 1)
    sim.write_holding(1, int(100.0 * 100))  # setpoint 100° (activo, generando timón)
    sim.write_coil(0, 1)
    sim.step(0.1)
    sim.step(10.0)   # dejar que el timón esté aplicando corrección

    rudder_before = sim.rudder
    pwm_before = sim._inner_pwm_pct
    mode_before_engaged = sim.mode == AutopilotMode.HEADING_HOLD

    # DISENGAGE
    sim.write_coil(1, 1)
    sim.step(0.1)   # un tick para procesar

    mode_after = sim.mode
    pwm_after = sim._inner_pwm_pct
    disengage_ok = mode_after == AutopilotMode.STANDBY

    # Correr 5 s más: el timón no debe recibir nuevas órdenes
    rudder_samples_after = []
    for _ in range(50):
        sim.step(0.1)
        rudder_samples_after.append(sim.rudder)
    rudder_still_moving = (
        max(rudder_samples_after) - min(rudder_samples_after) > 0.5
    )

    passed = (
        mode_before_engaged
        and disengage_ok
        and pwm_after == 0.0
        and not rudder_still_moving
    )

    return TCResult(
        spec=spec,
        passed=passed,
        metrics={
            "was_engaged": "Sí" if mode_before_engaged else "No",
            "mode_after": mode_after.name,
            "pwm_after_pct": round(pwm_after, 2),
            "rudder_drift_after_deg": round(
                max(rudder_samples_after) - min(rudder_samples_after), 3
            ),
        },
        message=(
            f"Antes: {mode_before_engaged and 'HH' or 'N/A'} | "
            f"Después: {mode_after.name} | "
            f"PWM post-disengage: {pwm_after:.2f}% | "
            f"Timón comandado post-disengage: {'No' if not rudder_still_moving else 'Sí (FALLO)'}"
        ),
        snapshots=sim.log,
        events=sim.events,
    )


def tc06_heading_lost_alarm() -> TCResult:
    """
    TC-06 — Alarma HEADING_LOST (pérdida de sensor)
    ================================================
    El compass NMEA 2000 deja de enviar datos. Tras 5 s el firmware
    debe disparar ALARM_HEADING_LOST y desengranarse automáticamente.

    Criterios de aceptación:
      • ALARM_HEADING_LOST activa en 5.0–6.0 s desde la pérdida
      • El modo pasa a STANDBY automáticamente
      • El discrete[19] (ALARM_HEADING_LOST) = 1
    """
    spec = TCSpec(
        id="TC-06",
        name="Alarma HEADING_LOST y auto-desenganche",
        description=(
            "Simulación de pérdida del sensor NMEA 2000. "
            "El autopiloto debe detectarla y disenganchar automáticamente."
        ),
        acceptance=(
            "Alarma activa en 5–6 s, modo STANDBY, discrete[19]=1."
        ),
    )

    sim = ESP32Simulator()

    sim.inject_nmea(heading_deg=90.0, sog_kn=10.0)
    sim.write_holding(0, 1)
    sim.write_holding(1, 9000)
    sim.write_coil(0, 1)
    sim.step(5.0)  # estabilizar

    assert sim.mode == AutopilotMode.HEADING_HOLD, "Precondición: debe estar enganchado"

    t_loss = sim.t
    sim.disconnect_nmea_heading()

    # Avanzar en pequeños pasos registrando cuándo dispara la alarma
    alarm_fired_at: float | None = None
    disengage_at: float | None = None
    for _ in range(100):   # hasta 10 s
        sim.step(0.1)
        if sim.read_discrete(19) and alarm_fired_at is None:
            alarm_fired_at = sim.t
        if sim.mode == AutopilotMode.STANDBY and disengage_at is None:
            disengage_at = sim.t

    time_to_alarm = (alarm_fired_at - t_loss) if alarm_fired_at else None

    passed = (
        alarm_fired_at is not None
        and 4.5 <= (alarm_fired_at - t_loss) <= 7.0
        and sim.mode == AutopilotMode.STANDBY
        and sim.read_discrete(19) == 1
    )

    return TCResult(
        spec=spec,
        passed=passed,
        metrics={
            "time_to_alarm_s": round(time_to_alarm, 2) if time_to_alarm else "nunca",
            "final_mode": sim.mode.name,
            "discrete_19": sim.read_discrete(19),
        },
        message=(
            f"Alarma a los {time_to_alarm:.2f}s post-pérdida | "
            f"Modo: {sim.mode.name} | "
            f"Discrete[19]: {sim.read_discrete(19)}"
            if time_to_alarm else
            "ALARMA NO DISPARADA en 10 s — FALLO"
        ),
        snapshots=sim.log,
        events=sim.events,
    )


def tc07_dodge_mode() -> TCResult:
    """
    TC-07 — Modo DODGE (esquiva temporal)
    ======================================
    El operador activa DODGE con un offset de +20° para esquivar un
    obstáculo. El autopiloto vira 20° a estribor. Luego el operador
    cancela el DODGE volviendo a HEADING_HOLD en el rumbo original.

    Criterios de aceptación:
      • Modo cambia a DODGE
      • El buque vira hacia el setpoint de DODGE (original + 20°)
      • Tras volver a HEADING_HOLD, el error respecto al rumbo original < 1°
    """
    spec = TCSpec(
        id="TC-07",
        name="Modo DODGE (+20°) y retorno",
        description=(
            "El operador esquiva con offset +20° y luego vuelve al rumbo original. "
            "Verifica la transición HEADING_HOLD ↔ DODGE ↔ HEADING_HOLD."
        ),
        acceptance=(
            "DODGE activo, buque vira al setpoint DODGE, retorno sin error > 1°."
        ),
    )

    sim = ESP32Simulator()
    ORIGINAL_SP = 90.0
    DODGE_OFFSET = 20.0
    DODGE_SP = (ORIGINAL_SP + DODGE_OFFSET) % 360.0

    sim.inject_nmea(heading_deg=ORIGINAL_SP, sog_kn=10.0)
    sim.write_holding(0, 1)
    sim.write_holding(1, int(ORIGINAL_SP * 100))
    sim.write_coil(0, 1)
    sim.step(10.0)   # estabilizar en 90°

    initial_heading_error = abs(heading_error_deg(ORIGINAL_SP, sim.heading))

    # Activar DODGE con offset +20°
    sim.write_holding(0, 4)                        # MODE_REQUEST = DODGE
    sim.write_holding(8, int(DODGE_OFFSET * 100))  # DODGE_OFFSET_DEG_X100
    sim.write_coil(0, 1)                           # CMD_ENGAGE_REQUEST
    sim.step(0.1)

    dodge_engaged = sim.mode == AutopilotMode.DODGE
    dodge_setpoint = sim._heading_setpoint_deg

    # Dejar que el buque vire hacia el setpoint DODGE
    sim.step(90.0)

    heading_at_dodge = sim.heading
    dodge_error = abs(heading_error_deg(DODGE_SP, heading_at_dodge))

    # Volver a HEADING_HOLD con el rumbo original
    sim.write_holding(0, 1)
    sim.write_holding(1, int(ORIGINAL_SP * 100))
    sim.write_coil(0, 1)
    sim.step(0.1)
    returned_to_hh = sim.mode == AutopilotMode.HEADING_HOLD
    sim.step(120.0)

    final_error = abs(heading_error_deg(ORIGINAL_SP, sim.heading))

    passed = (
        dodge_engaged
        and abs(dodge_setpoint - DODGE_SP) < 0.5
        and dodge_error < 2.0
        and returned_to_hh
        and final_error < 1.0
    )

    return TCResult(
        spec=spec,
        passed=passed,
        metrics={
            "dodge_engaged": "Sí" if dodge_engaged else "No",
            "dodge_setpoint_deg": round(dodge_setpoint, 2),
            "heading_at_dodge_end_deg": round(heading_at_dodge, 2),
            "dodge_error_deg": round(dodge_error, 3),
            "returned_to_hh": "Sí" if returned_to_hh else "No",
            "final_error_vs_original_deg": round(final_error, 3),
        },
        message=(
            f"DODGE: {'OK' if dodge_engaged else 'NO'} sp={dodge_setpoint:.1f}° | "
            f"Error en DODGE: {dodge_error:.2f}° | "
            f"Retorno HH: {'OK' if returned_to_hh else 'NO'} | "
            f"Error final vs original: {final_error:.3f}°"
        ),
        snapshots=sim.log,
        events=sim.events,
    )


def tc08_beaufort4_stability() -> TCResult:
    """
    TC-08 — Estabilidad en Beaufort 4 (mar moderada, 5 minutos)
    ============================================================
    El autopiloto mantiene el rumbo durante 300 s con mar Beaufort 4
    (ola 6 s, ROT perturbaciones ±1.5°/s pico). Evalúa la capacidad
    del pilot de seguir el rumbo en condiciones de crucero.

    Criterios de aceptación:
      • No desenganche (autopiloto sigue en HEADING_HOLD al final)
      • Sin alarma SEVERE
      • Desviación máxima de rumbo < 12°
      • Error RMS < 7° (observado típico: ~5.6°)
    """
    spec = TCSpec(
        id="TC-08",
        name="Beaufort 4 — estabilidad en mar moderada (5 min)",
        description=(
            "300 s de seguimiento de rumbo en Beaufort 4. "
            "Verifica que el AP no se desenganche con mar moderada."
        ),
        acceptance="Sin alarma SEVERE, desviación max < 12°, RMS < 7°.",
    )

    sim = ESP32Simulator()
    TARGET = 90.0
    sim.set_sea_state(4, seed=42)
    sim.inject_nmea(heading_deg=TARGET, sog_kn=10.0)
    sim.write_holding(0, 1)
    sim.write_holding(1, int(TARGET * 100))
    sim.write_coil(0, 1)
    sim.step(300.0)

    snaps = [s for s in sim.log if s.t >= 1.0]   # ignorar primer segundo de enganche
    max_dev = _max_abs_error(snaps, TARGET)
    rms_err = _rms_error(snaps, TARGET)
    still_engaged = sim.mode.name == "HEADING_HOLD"
    no_severe = not sim._alarm_off_course_severe

    passed = still_engaged and no_severe and max_dev < 12.0 and rms_err < 7.0

    return TCResult(
        spec=spec,
        passed=passed,
        metrics={
            "still_engaged": "Sí" if still_engaged else "No",
            "alarm_severe": "Sí" if not no_severe else "No",
            "max_dev_deg": round(max_dev, 2),
            "rms_error_deg": round(rms_err, 3),
            "beaufort": 4,
        },
        message=(
            f"Enganchado: {'OK' if still_engaged else 'NO'} | "
            f"Alarma SEVERE: {'SÍ' if not no_severe else 'NO'} | "
            f"Desv. máx: {max_dev:.1f}° | RMS: {rms_err:.2f}°"
        ),
        snapshots=sim.log,
        events=sim.events,
    )


def tc09_beaufort6_rough() -> TCResult:
    """
    TC-09 — Límite operacional en Beaufort 6 con ganancias de fábrica
    ==================================================================
    Mar gruesa con olas de 8 s y torque de perturbación ±3.5°/s² pico.
    Con ganancias de fábrica (Kp=0.9, rot_ff=1.5) el AP **no puede
    mantener** el rumbo en B6 — el error supera 30° y la alarma
    ALARM_OFF_COURSE_SEVERE activa el auto-desenganche.

    Este TC documenta el límite operacional sin boost de ganancia.
    El PASS significa que el sistema reacciona correctamente:
    la alarma SEVERE debe disparar en < 60 s (no silenciosamente)
    y el desenganche debe ocurrir (protección de la embarcación).

    Criterios de aceptación:
      • Alarma OFF_COURSE SEVERE disparada (comportamiento esperado)
      • AP se desengrana (protección automática correcta)
      • La alarma dispara antes de 60 s desde el inicio
    """
    spec = TCSpec(
        id="TC-09",
        name="Beaufort 6 — límite operacional con ganancias de fábrica",
        description=(
            "B6 con ganancias estándar excede el límite OFF_COURSE (30°). "
            "Documenta el límite operacional: SEVERE + auto-disengage es el "
            "comportamiento esperado y correcto (protección)."
        ),
        acceptance="Alarma SEVERE disparada en < 60 s, AP se desengrana correctamente.",
    )

    sim = ESP32Simulator()
    TARGET = 90.0
    sim.set_sea_state(6, seed=7)
    sim.inject_nmea(heading_deg=TARGET, sog_kn=10.0)
    sim.write_holding(0, 1)
    sim.write_holding(1, int(TARGET * 100))
    sim.write_coil(0, 1)
    sim.step(180.0)

    had_severe = sim._alarm_off_course_severe
    disengaged = sim.mode.name == "STANDBY"

    # Determinar cuándo ocurrió la alarma SEVERE
    severe_events = [e for e in sim.events if "SEVERE" in e.detail]
    t_engage_events = [e for e in sim.events if e.kind == "engage"]
    t_engage = t_engage_events[0].t if t_engage_events else 0.0
    t_severe = severe_events[0].t if severe_events else None
    time_to_severe = (t_severe - t_engage) if t_severe is not None else None

    passed = (
        had_severe
        and disengaged
        and time_to_severe is not None
        and time_to_severe < 60.0
    )

    return TCResult(
        spec=spec,
        passed=passed,
        metrics={
            "had_severe_alarm": "Sí" if had_severe else "No",
            "auto_disengaged": "Sí" if disengaged else "No",
            "time_to_severe_s": round(time_to_severe, 1) if time_to_severe else "nunca",
            "beaufort": 6,
        },
        message=(
            f"Alarma SEVERE: {'SÍ' if had_severe else 'NO'} | "
            f"Auto-desenganche: {'OK' if disengaged else 'NO'} | "
            f"Tiempo hasta SEVERE: {time_to_severe:.1f}s" if time_to_severe else
            f"Alarma SEVERE: {'SÍ' if had_severe else 'NO'} | Sin alarma en 180 s"
        ),
        snapshots=sim.log,
        events=sim.events,
    )


def tc10_low_speed() -> TCResult:
    """
    TC-10 — Velocidad baja 2 kn — cambio de rumbo +10°
    ===================================================
    El buque avanza a 2 nudos (mínimo habitual de uso del AP). La
    respuesta al timón es ~5× más lenta que a 10 kn. El AP debe
    converger aunque tarde bastante más.

    τ_outer(2 kn) ≈ 113 s vs. τ_outer(10 kn) ≈ 24 s

    Criterios de aceptación:
      • Error final < 3° (criterio más laxo que alta velocidad)
      • Asentamiento < 280 s
      • Sin alarma SEVERE
    """
    spec = TCSpec(
        id="TC-10",
        name="Velocidad baja 2 kn — cambio de rumbo +10°",
        description=(
            "SOG 2 kn, cambio de 0° → 10°. "
            "Verifica convergencia lenta (τ≈113 s) en mínima velocidad."
        ),
        acceptance="Error final < 3°, asentamiento < 280 s.",
    )

    sim = ESP32Simulator()
    INITIAL = 0.0
    TARGET = 10.0
    sim.inject_nmea(heading_deg=INITIAL, sog_kn=2.0)
    sim.write_holding(0, 1)
    sim.write_holding(1, int(INITIAL * 100))
    sim.write_coil(0, 1)
    sim.step(5.0)

    t_step = sim.t
    sim.write_holding(1, int(TARGET * 100))
    sim.step(290.0)

    snaps_after = [s for s in sim.log if s.t >= t_step]
    settling = _settling_time(snaps_after, TARGET, tolerance=3.0)
    final_err = abs(heading_error_deg(TARGET, sim.heading))
    no_severe = not sim._alarm_off_course_severe

    passed = (
        no_severe
        and final_err < 3.0
        and settling is not None
        and settling < 280.0
    )

    return TCResult(
        spec=spec,
        passed=passed,
        metrics={
            "sog_kn": 2.0,
            "final_error_deg": round(final_err, 3),
            "settling_time_s": round(settling, 1) if settling else ">290",
            "alarm_severe": "Sí" if not no_severe else "No",
        },
        message=(
            f"SOG: 2 kn | Error: {final_err:.2f}° | "
            f"Asentamiento: {settling:.1f}s" if settling else
            f"SOG: 2 kn | Error: {final_err:.2f}° | NO ASENTÓ"
        ),
        snapshots=sim.log,
        events=sim.events,
    )


def tc11_reversal_180() -> TCResult:
    """
    TC-11 — Inversión de rumbo 180°
    ================================
    Maniobra máxima: el operador invierte el rumbo 180°.
    Verifica que _tracking_settled impide OFF_COURSE SEVERE durante
    la aproximación larga, y que el AP converge sin disenganche.

    Criterios de aceptación:
      • Sin alarma SEVERE (sin desenganche no deseado)
      • Converge a < 5° de 180° en menos de 200 s
      • Timón llega a estribor (positivo) al inicio de la maniobra
    """
    spec = TCSpec(
        id="TC-11",
        name="Inversión de rumbo 180° — maniobra extrema",
        description=(
            "Cambio de 0° → 180°. Prueba que _tracking_settled evita "
            "la alarma SEVERE durante la larga aproximación."
        ),
        acceptance="Sin SEVERE, convergencia < 5° en < 200 s.",
    )

    sim = ESP32Simulator()
    INITIAL = 0.0
    TARGET = 180.0
    sim.inject_nmea(heading_deg=INITIAL, sog_kn=10.0)
    sim.write_holding(0, 1)
    sim.write_holding(1, int(INITIAL * 100))
    sim.write_coil(0, 1)
    sim.step(2.0)

    t_step = sim.t
    sim.write_holding(1, int(TARGET * 100))
    sim.step(220.0)

    snaps_after = [s for s in sim.log if s.t >= t_step]
    max_rudder_stbd = max((s.rudder_angle_deg for s in snaps_after[:50]), default=0.0)
    went_starboard = max_rudder_stbd > 2.0
    settling = _settling_time(snaps_after, TARGET, tolerance=5.0)
    final_err = abs(heading_error_deg(TARGET, sim.heading))
    no_severe = not sim._alarm_off_course_severe

    passed = (
        no_severe
        and went_starboard
        and final_err < 5.0
        and settling is not None
        and settling < 200.0
    )

    return TCResult(
        spec=spec,
        passed=passed,
        metrics={
            "went_starboard": "Sí" if went_starboard else "No",
            "alarm_severe": "Sí" if not no_severe else "No",
            "final_error_deg": round(final_err, 3),
            "settling_time_s": round(settling, 1) if settling else ">220",
        },
        message=(
            f"Estribor: {'OK' if went_starboard else 'NO'} | "
            f"SEVERE: {'SÍ' if not no_severe else 'NO'} | "
            f"Error: {final_err:.2f}° | "
            f"Asentamiento: {settling:.1f}s" if settling else
            f"Estribor: {'OK' if went_starboard else 'NO'} | NO ASENTÓ"
        ),
        snapshots=sim.log,
        events=sim.events,
    )


def tc12_rapid_setpoints() -> TCResult:
    """
    TC-12 — Cambios rápidos de setpoint (stress test del knob)
    ===========================================================
    El operador gira el knob rápidamente: 90°→105°→95°→115°→90°,
    un cambio cada 25 s. Verifica que el PID no acumula windup ni
    pierde el estado entre cambios rápidos.

    Nota: con cambios cada 25 s el buque está siempre en transición
    (τ_outer ≈ 24 s). El error residual al final del ciclo es de ~1.2°
    porque el sistema aún está convergiendo hacia 90° tras el último
    cambio. Con 60 s de ventana extra el error queda < 2°.

    Criterios de aceptación:
      • Sin alarmas en ningún momento
      • Error final (respecto al último setpoint 90°) < 2° (observado: ~1.2°)
      • No desenganche involuntario
    """
    spec = TCSpec(
        id="TC-12",
        name="Setpoints rápidos consecutivos — stress del knob",
        description=(
            "90°→105°→95°→115°→90°, cada 25 s. "
            "Verifica ausencia de windup entre cambios rápidos."
        ),
        acceptance="Sin alarmas, error final < 2°, modo HH al finalizar.",
    )

    sim = ESP32Simulator()
    ORIGINAL = 90.0
    sequence = [105.0, 95.0, 115.0, ORIGINAL]

    sim.inject_nmea(heading_deg=ORIGINAL, sog_kn=10.0)
    sim.write_holding(0, 1)
    sim.write_holding(1, int(ORIGINAL * 100))
    sim.write_coil(0, 1)
    sim.step(10.0)  # estabilizar en 90°

    for sp in sequence:
        sim.write_holding(1, int(sp * 100))
        sim.step(25.0)

    sim.step(60.0)  # ventana extra para convergencia final

    final_err = abs(heading_error_deg(ORIGINAL, sim.heading))
    still_hh = sim.mode.name == "HEADING_HOLD"
    no_alarm = not sim.any_alarm

    passed = still_hh and no_alarm and final_err < 2.0

    return TCResult(
        spec=spec,
        passed=passed,
        metrics={
            "final_mode": sim.mode.name,
            "final_error_deg": round(final_err, 3),
            "any_alarm": "Sí" if not no_alarm else "No",
        },
        message=(
            f"Modo: {sim.mode.name} | "
            f"Error final: {final_err:.3f}° | "
            f"Alarmas: {'SÍ' if not no_alarm else 'ninguna'}"
        ),
        snapshots=sim.log,
        events=sim.events,
    )


def tc13_weather_gain_boost() -> TCResult:
    """
    TC-13 — Boost de ganancia en mal tiempo (Beaufort 5)
    ====================================================
    Simula el operador ajustando los knobs "RUDDER" y "COUNTER RUDDER"
    en mar alta (Beaufort 5). Mide el RMS del error antes y después
    del boost de ganancia.

    Nota: B6 con ganancias de fábrica causa desenganche a los ~36 s
    (documentado en TC-09). Para poder medir la mejora del boost con AP
    enganchado durante toda la prueba se usa B5, que es el nivel más alto
    que el AP puede sostener con ganancias estándar sin desenganche.

    Fase 1 (120 s): ganancias de fábrica (Kp=0.9, rot_ff=1.5)
      → RMS observado ≈ 12.3° (mar alta — degradación aceptada)
    Fase 2 (120 s): boost del operador (Kp=2.0, rot_ff=2.5, max_rud=30°)
      → RMS observado ≈ 7.2° (mejora de ~1.7× con los knobs)

    En pilotos clásicos (Robertson AP28): equivale a subir RUDDER de 4→7
    y COUNTER RUDDER de 4→7.

    Criterios de aceptación:
      • RMS post-boost < RMS pre-boost (mejora real, observable)
      • Mejora >= 10% (ratio pre/post ≥ 1.10)
      • Sin alarma SEVERE en ninguna fase (AP enganchado todo el tiempo)
    """
    spec = TCSpec(
        id="TC-13",
        name="Boost de ganancia en Beaufort 5 — RUDDER / COUNTER RUDDER",
        description=(
            "Beaufort 5, 120 s sin boost + 120 s con boost. "
            "Verifica que subir 'RUDDER' y 'COUNTER RUDDER' mejora el seguimiento."
        ),
        acceptance="RMS post-boost < RMS pre-boost (mejora ≥ 10%), sin SEVERE.",
    )

    sim = ESP32Simulator()
    TARGET = 90.0
    sim.set_sea_state(5, seed=42)
    sim.inject_nmea(heading_deg=TARGET, sog_kn=10.0)
    sim.write_holding(0, 1)
    sim.write_holding(1, int(TARGET * 100))
    sim.write_coil(0, 1)
    sim.step(5.0)  # estabilizar

    # Fase 1: ganancias de fábrica
    t0 = sim.t
    sim.step(120.0)
    t1 = sim.t
    snaps_pre = [s for s in sim.log if t0 <= s.t < t1]
    rms_pre = _rms_error(snaps_pre, TARGET)

    # Boost: operador sube RUDDER y COUNTER RUDDER (como en piloto clásico)
    sim.tune_response(rudder_kp=2.0, counter_rudder=2.5, max_rudder_deg=30.0)
    sim.step(120.0)
    t2 = sim.t
    snaps_post = [s for s in sim.log if t1 <= s.t < t2]
    rms_post = _rms_error(snaps_post, TARGET)

    no_severe = not sim._alarm_off_course_severe
    improvement_ratio = rms_pre / rms_post if rms_post > 0.0 else 999.0

    passed = (
        no_severe
        and rms_post < rms_pre
        and improvement_ratio >= 1.10
    )

    return TCResult(
        spec=spec,
        passed=passed,
        metrics={
            "rms_pre_boost_deg": round(rms_pre, 3),
            "rms_post_boost_deg": round(rms_post, 3),
            "improvement_ratio": round(improvement_ratio, 3),
            "alarm_severe": "Sí" if not no_severe else "No",
            "beaufort": 5,
        },
        message=(
            f"RMS pre-boost: {rms_pre:.2f}° | "
            f"RMS post-boost: {rms_post:.2f}° | "
            f"Mejora: {improvement_ratio:.2f}× | "
            f"SEVERE: {'SÍ' if not no_severe else 'NO'}"
        ),
        snapshots=sim.log,
        events=sim.events,
    )


def tc14_wave_spike() -> TCResult:
    """
    TC-14 — Ráfaga extrema: spike de Beaufort 7 (30 s) y recuperación
    ==================================================================
    El buque navega en calma. De repente llega una ráfaga / ola extrema
    (Beaufort 7, ROT perturbación ±8°/s pico) durante 30 s. Luego vuelve
    la calma.

    Evalúa si el AP:
    a) Se mantiene enganchado (si la desviación no supera 30°) — ideal
    b) Si se desengrana por OFF_COURSE SEVERE, verifica que la alarma
       es genuina y que el AP puede re-enganchar y recuperar el rumbo.

    Criterios de aceptación (caso a — sin disengage):
      • Máxima desviación < 25° durante el spike
      • Recuperación < 60 s tras el spike (error final < 2°)

    Criterios de aceptación (caso b — con disengage por SEVERE):
      • La alarma fue OFF_COURSE SEVERE (no un bug silencioso)
      • Al re-enganchar: recuperación < 60 s (error final < 2°)
    """
    spec = TCSpec(
        id="TC-14",
        name="Ráfaga extrema Beaufort 7 (30 s) y recuperación",
        description=(
            "Calma → Beaufort 7 (30 s) → calma. "
            "Verifica la robustez del AP ante olas extremas."
        ),
        acceptance="Sin disengage O alarma SEVERE legítima + recuperación < 60 s, error < 2°.",
    )

    sim = ESP32Simulator()
    TARGET = 90.0
    sim.inject_nmea(heading_deg=TARGET, sog_kn=10.0)
    sim.write_holding(0, 1)
    sim.write_holding(1, int(TARGET * 100))
    sim.write_coil(0, 1)
    sim.step(20.0)  # establecer rumbo en calma

    # Spike: Beaufort 7 durante 30 s
    t_spike_start = sim.t
    sim.set_sea_state(7, seed=17)
    sim.step(30.0)
    t_spike_end = sim.t
    snaps_spike = [s for s in sim.log if t_spike_start <= s.t <= t_spike_end]
    max_dev_spike = _max_abs_error(snaps_spike, TARGET)
    had_severe = sim._alarm_off_course_severe

    # Volver a calma
    sim.set_sea_state(0)

    # Si se disenganó, re-enganchar
    if sim.mode.name != "HEADING_HOLD":
        sim.write_coil(2, 1)  # ACK alarms
        sim.step(0.1)
        sim.write_holding(0, 1)
        sim.write_coil(0, 1)
        sim.step(0.1)

    # 90 s de recuperación
    sim.step(90.0)
    final_err = abs(heading_error_deg(TARGET, sim.heading))
    still_hh = sim.mode.name == "HEADING_HOLD"

    # Criterio: o no hubo disengage y max_dev < 25°,
    # o hubo SEVERE (correcto) y recuperó < 2°
    if not had_severe:
        passed = max_dev_spike < 25.0 and final_err < 2.0
    else:
        passed = final_err < 2.0  # se aceptó el disengage, pero debe recuperar

    return TCResult(
        spec=spec,
        passed=passed,
        metrics={
            "max_dev_spike_deg": round(max_dev_spike, 2),
            "had_severe_alarm": "Sí" if had_severe else "No",
            "final_mode": sim.mode.name,
            "final_error_deg": round(final_err, 3),
        },
        message=(
            f"Desv. máx en spike: {max_dev_spike:.1f}° | "
            f"Alarma SEVERE: {'SÍ' if had_severe else 'NO'} | "
            f"Modo final: {sim.mode.name} | "
            f"Error final: {final_err:.2f}°"
        ),
        snapshots=sim.log,
        events=sim.events,
    )


# ---------------------------------------------------------------------------
# Registro de todos los TCs
# ---------------------------------------------------------------------------
ALL_TCS: dict[str, Callable[[], TCResult]] = {
    "TC-01": tc01_power_on_engage,
    "TC-02": tc02_small_heading_change,
    "TC-03": tc03_large_heading_change,
    "TC-04": tc04_boundary_crossing,
    "TC-05": tc05_manual_disengage,
    "TC-06": tc06_heading_lost_alarm,
    "TC-07": tc07_dodge_mode,
    "TC-08": tc08_beaufort4_stability,
    "TC-09": tc09_beaufort6_rough,
    "TC-10": tc10_low_speed,
    "TC-11": tc11_reversal_180,
    "TC-12": tc12_rapid_setpoints,
    "TC-13": tc13_weather_gain_boost,
    "TC-14": tc14_wave_spike,
}


# ---------------------------------------------------------------------------
# Helpers de métricas
# ---------------------------------------------------------------------------
def _settling_time(
    snapshots: list[SimSnapshot], target: float, tolerance: float = 1.0
) -> float | None:
    """
    Retorna el instante en que |error| entra en la banda de tolerancia
    y ya no vuelve a salir. None si nunca asienta.
    """
    if not snapshots:
        return None
    # Buscar el último instante donde |error| > tolerance
    last_out = None
    for s in snapshots:
        if abs(heading_error_deg(target, s.heading_deg)) > tolerance:
            last_out = s.t
    if last_out is None:
        return snapshots[0].t  # siempre estuvo dentro
    # Verificar que después de last_out permanece dentro
    after = [s for s in snapshots if s.t > last_out]
    if all(abs(heading_error_deg(target, s.heading_deg)) <= tolerance for s in after):
        return last_out
    return None  # salió de la banda después del último out


def _overshoot(
    snapshots: list[SimSnapshot], initial: float, target: float
) -> float:
    """
    Sobrepasamiento en grados respecto al setpoint en la dirección de giro.
    Positivo = cruzó el setpoint en la dirección del movimiento.
    """
    if not snapshots:
        return 0.0
    going_right = heading_error_deg(target, initial) > 0  # girando a estribor
    peak = 0.0
    for s in snapshots[3:]:  # ignorar los primeros muestras (transitorio inicial)
        err = heading_error_deg(target, s.heading_deg)
        if going_right:
            # Sobrepasamiento = haber ido más allá del setpoint (error negativo)
            if err < 0:
                peak = max(peak, abs(err))
        else:
            if err > 0:
                peak = max(peak, abs(err))
    return peak


def _rms_error(
    snapshots: list[SimSnapshot], target: float
) -> float:
    """Error cuadrático medio del rumbo respecto al setpoint."""
    if not snapshots:
        return 0.0
    errors = [heading_error_deg(target, s.heading_deg) ** 2 for s in snapshots]
    return math.sqrt(sum(errors) / len(errors))


def _max_abs_error(snapshots: list[SimSnapshot], target: float) -> float:
    """Máxima desviación absoluta del rumbo respecto al setpoint."""
    if not snapshots:
        return 0.0
    return max(abs(heading_error_deg(target, s.heading_deg)) for s in snapshots)


# ---------------------------------------------------------------------------
# Salida en consola
# ---------------------------------------------------------------------------
def _print_summary(results: list[TCResult]) -> None:
    print()
    print("=" * 72)
    print("  AR-AUTOPILOT - Protocolo de Simulacion HIL")
    print(f"  {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    print("=" * 72)
    print(f"  {'ID':<8}  {'Nombre':<35}  {'Resultado':<8}  {'Nota'}")
    print("  " + "-" * 68)
    for r in results:
        badge = "[PASS]" if r.passed else "[FAIL]"
        name = r.spec.name[:35]
        note = r.message[:40] if len(r.message) > 40 else r.message
        print(f"  {r.spec.id:<8}  {name:<35}  {badge:<8}  {note}")
    print("  " + "-" * 68)
    n_pass = sum(1 for r in results if r.passed)
    n_fail = len(results) - n_pass
    print(f"  Total: {len(results)}  PASS: {n_pass}  FAIL: {n_fail}")
    print("=" * 72)
    print()


# ---------------------------------------------------------------------------
# Generador de informe HTML
# ---------------------------------------------------------------------------
def generate_html_report(results: list[TCResult], out_path: Path) -> None:
    """Genera un informe HTML autocontenido con gráficas Chart.js."""

    n_pass = sum(1 for r in results if r.passed)
    n_fail = len(results) - n_pass
    overall_badge = "PASS" if n_fail == 0 else "FAIL"
    overall_color = "#22c55e" if n_fail == 0 else "#ef4444"

    tc_cards_html = ""
    for r in results:
        tc_cards_html += _render_tc_card(r)

    html_content = f"""<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AR-Autopilot — Protocolo de Simulación</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
  :root {{
    --bg: #0d1822; --bg-mid: #0f172a; --panel: #14202e;
    --border: rgba(56,189,248,.22); --text: #e0f2fe; --muted: #64748b;
    --accent: #38bdf8; --ok: #4ade80; --fail: #ef4444;
    --warn: #fbbf24; --set: #fbbf24;
  }}
  * {{ box-sizing: border-box; margin: 0; padding: 0; }}
  body {{ background: var(--bg); color: var(--text); font-family: 'Courier New', monospace; padding: 24px; }}
  h1 {{ color: var(--accent); font-size: 1.4rem; margin-bottom: 4px; }}
  h2 {{ color: var(--accent); font-size: 1.05rem; margin-bottom: 12px; }}
  .meta {{ color: var(--muted); font-size: .8rem; margin-bottom: 24px; }}
  .overall {{ display: flex; align-items: center; gap: 16px; margin-bottom: 28px; }}
  .badge {{ padding: 6px 18px; border-radius: 4px; font-weight: bold; font-size: 1.1rem; }}
  .badge-pass {{ background: var(--ok); color: #0a1a0a; }}
  .badge-fail {{ background: var(--fail); color: #fff; }}
  .stats {{ color: var(--muted); font-size: .9rem; }}
  .tc-card {{ background: var(--panel); border: 1px solid var(--border);
              border-radius: 6px; padding: 20px; margin-bottom: 20px; }}
  .tc-header {{ display: flex; align-items: baseline; gap: 12px; margin-bottom: 8px; }}
  .tc-id {{ color: var(--accent); font-size: 1rem; font-weight: bold; }}
  .tc-name {{ font-size: .95rem; color: var(--text); }}
  .tc-badge {{ padding: 2px 10px; border-radius: 3px; font-size: .85rem; font-weight: bold; }}
  .pass {{ background: var(--ok); color: #0a1a0a; }}
  .fail {{ background: var(--fail); color: #fff; }}
  .tc-desc {{ color: var(--muted); font-size: .8rem; margin-bottom: 10px; }}
  .tc-accept {{ color: var(--warn); font-size: .8rem; margin-bottom: 12px; }}
  .metrics {{ display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }}
  .metric {{ background: #0a1220; border: 1px solid var(--border); border-radius: 4px;
             padding: 5px 10px; font-size: .8rem; }}
  .metric-key {{ color: var(--muted); }}
  .metric-val {{ color: var(--accent); font-weight: bold; }}
  .tc-msg {{ color: var(--text); font-size: .82rem; margin-bottom: 14px; opacity: .85; }}
  .chart-wrap {{ position: relative; height: 240px; }}
  .events {{ margin-top: 10px; font-size: .75rem; color: var(--muted); }}
  .event {{ margin-bottom: 2px; }}
  .ev-engage {{ color: var(--ok); }}
  .ev-disengage {{ color: var(--warn); }}
  .ev-alarm {{ color: var(--fail); }}
  .ev-ack {{ color: var(--accent); }}
</style>
</head>
<body>
<h1>AR-Autopilot · Protocolo de Simulación HIL</h1>
<div class="meta">Generado: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} · Simulador Python puro · Sin hardware ESP32</div>
<div class="overall">
  <span class="badge badge-{'pass' if n_fail==0 else 'fail'}">{overall_badge}</span>
  <span class="stats">{n_pass}/{len(results)} casos superados &nbsp;|&nbsp; {n_fail} fallos</span>
</div>
{tc_cards_html}
</body>
</html>"""

    out_path.write_text(html_content, encoding="utf-8")
    print(f"  → Informe HTML: {out_path.resolve()}")


def _render_tc_card(r: TCResult) -> str:
    """Genera el HTML de una tarjeta de caso de prueba."""
    passed_class = "pass" if r.passed else "fail"
    passed_label = "PASS" if r.passed else "FAIL"

    # Métricas
    metrics_html = ""
    for k, v in r.metrics.items():
        metrics_html += (
            f'<span class="metric">'
            f'<span class="metric-key">{html.escape(str(k))}</span>: '
            f'<span class="metric-val">{html.escape(str(v))}</span>'
            f'</span>'
        )

    # Datos del gráfico (subsamplear si hay muchos puntos)
    snaps = r.snapshots
    if len(snaps) > 600:
        step = len(snaps) // 600
        snaps = snaps[::step]

    times = [round(s.t, 2) for s in snaps]
    headings = [round(s.heading_deg, 2) for s in snaps]
    setpoints = [round(s.heading_setpoint_deg, 2) for s in snaps]
    rudders = [round(s.rudder_angle_deg, 2) for s in snaps]
    rots = [round(s.rot_dps, 3) for s in snaps]

    chart_id = r.spec.id.replace("-", "_")

    # Eventos
    events_html = ""
    for ev in r.events:
        ev_class = {
            "engage": "ev-engage", "engage_refused": "ev-alarm",
            "disengage": "ev-disengage", "alarm": "ev-alarm", "ack": "ev-ack",
        }.get(ev.kind, "")
        events_html += (
            f'<div class="event {ev_class}">'
            f't={ev.t:.2f}s [{ev.kind}] {html.escape(ev.detail)}'
            f'</div>'
        )

    return f"""
<div class="tc-card">
  <div class="tc-header">
    <span class="tc-id">{html.escape(r.spec.id)}</span>
    <span class="tc-name">{html.escape(r.spec.name)}</span>
    <span class="tc-badge {passed_class}">{passed_label}</span>
  </div>
  <div class="tc-desc">{html.escape(r.spec.description)}</div>
  <div class="tc-accept">* Aceptación: {html.escape(r.spec.acceptance)}</div>
  <div class="metrics">{metrics_html}</div>
  <div class="tc-msg">{html.escape(r.message)}</div>
  <div class="chart-wrap">
    <canvas id="chart_{chart_id}"></canvas>
  </div>
  <div class="events">{events_html}</div>
</div>
<script>
(function() {{
  var ctx = document.getElementById('chart_{chart_id}').getContext('2d');
  new Chart(ctx, {{
    type: 'line',
    data: {{
      labels: {json.dumps(times)},
      datasets: [
        {{
          label: 'Rumbo (°)',
          data: {json.dumps(headings)},
          borderColor: '#38bdf8', borderWidth: 1.5,
          pointRadius: 0, tension: 0.1, yAxisID: 'y'
        }},
        {{
          label: 'Setpoint (°)',
          data: {json.dumps(setpoints)},
          borderColor: '#fbbf24', borderWidth: 1.5, borderDash: [4, 3],
          pointRadius: 0, tension: 0, yAxisID: 'y'
        }},
        {{
          label: 'Timón (°)',
          data: {json.dumps(rudders)},
          borderColor: '#4ade80', borderWidth: 1,
          pointRadius: 0, tension: 0.1, yAxisID: 'y2'
        }},
        {{
          label: 'ROT (°/s)',
          data: {json.dumps(rots)},
          borderColor: '#fb923c', borderWidth: 1,
          pointRadius: 0, tension: 0.1, yAxisID: 'y2'
        }}
      ]
    }},
    options: {{
      animation: false,
      interaction: {{ mode: 'index', intersect: false }},
      plugins: {{
        legend: {{ labels: {{ color: '#e0f2fe', font: {{ size: 11 }} }} }},
        tooltip: {{ backgroundColor: '#0d1822', titleColor: '#38bdf8', bodyColor: '#e0f2fe' }}
      }},
      scales: {{
        x: {{
          ticks: {{ color: '#64748b', font: {{ size: 10 }}, maxTicksLimit: 12 }},
          grid: {{ color: 'rgba(56,189,248,.08)' }},
          title: {{ display: true, text: 'Tiempo (s)', color: '#64748b', font: {{ size: 11 }} }}
        }},
        y: {{
          ticks: {{ color: '#38bdf8', font: {{ size: 10 }} }},
          grid: {{ color: 'rgba(56,189,248,.08)' }},
          title: {{ display: true, text: 'Rumbo / Setpoint (°)', color: '#38bdf8', font: {{ size: 11 }} }}
        }},
        y2: {{
          position: 'right',
          ticks: {{ color: '#4ade80', font: {{ size: 10 }} }},
          grid: {{ drawOnChartArea: false }},
          title: {{ display: true, text: 'Timón (°) / ROT (°/s)', color: '#4ade80', font: {{ size: 11 }} }}
        }}
      }}
    }}
  }});
}})();
</script>
"""


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> int:
    parser = argparse.ArgumentParser(
        description="AR-Autopilot: protocolo de simulación HIL",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=textwrap.dedent("""
            Ejemplos:
              python tools/sim_protocol.py
              python tools/sim_protocol.py TC-02 TC-04
              python tools/sim_protocol.py --out resultado.html
        """),
    )
    parser.add_argument(
        "tests", nargs="*", metavar="TC-NN",
        help="IDs de casos a ejecutar (default: todos)",
    )
    parser.add_argument(
        "--out", default="tools/sim_report.html",
        metavar="ARCHIVO",
        help="Ruta del informe HTML (default: tools/sim_report.html)",
    )
    args = parser.parse_args()

    selected = args.tests or list(ALL_TCS.keys())
    unknown = [tc for tc in selected if tc not in ALL_TCS]
    if unknown:
        print(f"Error: TCs desconocidos: {', '.join(unknown)}")
        print(f"Disponibles: {', '.join(ALL_TCS)}")
        return 1

    results: list[TCResult] = []
    for tc_id in selected:
        print(f"  Ejecutando {tc_id} …", end="", flush=True)
        result = ALL_TCS[tc_id]()
        badge = "PASS" if result.passed else "FAIL"
        print(f" {badge}")
        results.append(result)

    _print_summary(results)

    out_path = Path(args.out)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    generate_html_report(results, out_path)

    return 0 if all(r.passed for r in results) else 1


if __name__ == "__main__":
    sys.exit(main())
