"""Simulazione cinematica editoriale per "L'angolo non è un posto".

Ogni curva e ogni annotazione delle tre figure deriva dagli stati geometrici
simulati.  Le costanti raccolte nella sezione ASSUNZIONI sono intenzionalmente
facili da modificare e non sono misure sperimentali.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import math

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.lines import Line2D


OUTPUT_DIR = Path(__file__).resolve().parent

# ---------------------------------------------------------------------------
# ASSUNZIONI EDITORIALI — nessuna di queste costanti è misurata o validata.
# ---------------------------------------------------------------------------
DT = 0.005  # s; risoluzione temporale scelta per la simulazione.
HORIZON = 2.0  # s; orizzonte osservato.
T_EXIT = 0.30  # s; inizio del pivot.
EPSILON_DEG = 12.0  # gradi; tolleranza dichiarata prima dell'analisi.
D0 = 1.65  # m; distanza iniziale tra i centri dei pugili.
L_A = 0.90  # m; allungo funzionale assunto per A.
THETA_STEP_DEG = 17.0  # gradi; ampiezza del pivot.
T_MOVE = 0.35  # s; durata del pivot.
TANGENTIAL_SPEED = D0 * math.radians(THETA_STEP_DEG) / T_MOVE  # m/s.
OMEGA_REFERENCE_DEG_S = 180.0  # gradi/s; riferimento editoriale per il busto.

A_INITIAL = np.array([0.0, 0.0], dtype=float)
B_INITIAL = np.array([D0, 0.0], dtype=float)
A_INITIAL_HEADING_RAD = 0.0
B_INITIAL_HEADING_RAD = math.pi


@dataclass(frozen=True)
class Scenario:
    key: str
    label: str
    latency_s: float | None
    omega_max_deg_s: float
    commitment_s: float = 0.0
    forward_speed_m_s: float = 0.0
    linestyle: str | tuple = "-"
    marker: str = "o"
    gray: str = "#111111"


SCENARIOS = (
    Scenario(
        key="statico",
        label="STATICO",
        latency_s=None,
        omega_max_deg_s=0.0,
        linestyle="-",
        marker="o",
        gray="#111111",
    ),
    Scenario(
        key="reattivo",
        label="REATTIVO",
        latency_s=0.12,
        omega_max_deg_s=220.0,
        linestyle=(0, (2.0, 2.0)),
        marker="s",
        gray="#555555",
    ),
    Scenario(
        key="impegnato",
        label="IMPEGNATO",
        latency_s=0.35,
        omega_max_deg_s=140.0,
        commitment_s=0.35,
        forward_speed_m_s=1.10,
        linestyle=(0, (7.0, 2.5)),
        marker="^",
        gray="#888888",
    ),
)


@dataclass
class SimulationResult:
    scenario: Scenario
    t: np.ndarray
    a_position: np.ndarray
    b_position: np.ndarray
    a_heading_rad: np.ndarray
    b_heading_rad: np.ndarray
    distance_m: np.ndarray
    rho_a: np.ndarray
    beta_a_deg: np.ndarray
    beta_b_deg: np.ndarray
    g_deg: np.ndarray
    t_realign_s: float | None
    tau_s: float | None
    alignment_status: str


def unit_from_angle(angle_rad: float) -> np.ndarray:
    return np.array([math.cos(angle_rad), math.sin(angle_rad)], dtype=float)


def wrap_angle_rad(angle_rad: float) -> float:
    """Normalizza un angolo in [-pi, pi)."""
    return (angle_rad + math.pi) % (2.0 * math.pi) - math.pi


def signed_angle_deg(source: np.ndarray, target: np.ndarray) -> float:
    """Angolo firmato antiorario da ``source`` a ``target`` in [-180, 180].

    La scala dei vettori non entra nel calcolo. Vettori nulli, quasi nulli o non
    finiti non definiscono una direzione e producono un errore esplicito.
    """
    source = np.asarray(source, dtype=float)
    target = np.asarray(target, dtype=float)
    if source.shape != (2,) or target.shape != (2,):
        raise ValueError("signed_angle_deg richiede due vettori bidimensionali")
    if not np.all(np.isfinite(source)) or not np.all(np.isfinite(target)):
        raise ValueError("l'angolo non è definito per vettori non finiti")
    source_norm = math.hypot(float(source[0]), float(source[1]))
    target_norm = math.hypot(float(target[0]), float(target[1]))
    zero_tolerance = 1e-12
    if source_norm <= zero_tolerance or target_norm <= zero_tolerance:
        raise ValueError("l'angolo non è definito per un vettore nullo o quasi nullo")
    determinant = float(source[0] * target[1] - source[1] * target[0])
    dot_product = float(source[0] * target[0] + source[1] * target[1])
    return math.degrees(math.atan2(determinant, dot_product))


def a_trajectory(t: np.ndarray) -> np.ndarray:
    """Arco circolare di A attorno alla posizione di B al tempo di uscita."""
    progress = np.clip((t - T_EXIT) / T_MOVE, 0.0, 1.0)
    sweep_rad = math.radians(THETA_STEP_DEG) * progress
    polar_angle = math.pi - sweep_rad
    return B_INITIAL + D0 * np.column_stack(
        (np.cos(polar_angle), np.sin(polar_angle))
    )


def b_trajectory(t: np.ndarray, scenario: Scenario) -> np.ndarray:
    """B è fermo salvo l'avanzamento assunto nello scenario IMPEGNATO."""
    advance_time = np.clip(t - T_EXIT, 0.0, scenario.commitment_s)
    old_forward = unit_from_angle(B_INITIAL_HEADING_RAD)
    return B_INITIAL + np.outer(
        advance_time * scenario.forward_speed_m_s, old_forward
    )


def measure_realign_time(
    t: np.ndarray, abs_beta_b_deg: np.ndarray
) -> tuple[float | None, float | None, str]:
    """Misura il primo rientro sotto epsilon dopo un'effettiva uscita.

    Se B non supera mai la tolleranza, la finestra è nulla. Se la supera ma non
    rientra nell'orizzonte, il risultato resta censurato e non viene inventato.
    """
    after_exit = np.flatnonzero(t >= T_EXIT - 1e-12)
    above = after_exit[abs_beta_b_deg[after_exit] > EPSILON_DEG]
    if above.size == 0:
        return T_EXIT, 0.0, "sempre entro epsilon"

    first_above = int(above[0])
    for i in range(first_above + 1, len(t)):
        if abs_beta_b_deg[i - 1] > EPSILON_DEG >= abs_beta_b_deg[i]:
            y0 = float(abs_beta_b_deg[i - 1])
            y1 = float(abs_beta_b_deg[i])
            fraction = (EPSILON_DEG - y0) / (y1 - y0)
            crossing = float(t[i - 1] + fraction * (t[i] - t[i - 1]))
            return crossing, crossing - T_EXIT, "rientro interpolato"
    return None, None, "> horizon"


def simulate(scenario: Scenario) -> SimulationResult:
    t = np.arange(0.0, HORIZON + DT / 2.0, DT)
    a_position = a_trajectory(t)
    b_position = b_trajectory(t, scenario)

    # A guarda sempre la posizione corrente di B; beta_A viene comunque
    # ricalcolato dalla geometria, non assegnato a zero.
    r_ab = b_position - a_position
    a_heading_rad = np.arctan2(r_ab[:, 1], r_ab[:, 0])

    b_heading_rad = np.full_like(t, B_INITIAL_HEADING_RAD)
    if scenario.latency_s is not None and scenario.omega_max_deg_s > 0.0:
        turn_start = T_EXIT + scenario.latency_s
        omega_max_rad_s = math.radians(scenario.omega_max_deg_s)
        for i in range(1, len(t)):
            b_heading_rad[i] = b_heading_rad[i - 1]
            active_dt = max(0.0, float(t[i] - max(t[i - 1], turn_start)))
            if active_dt == 0.0:
                continue
            r_ba_now = a_position[i] - b_position[i]
            desired = math.atan2(float(r_ba_now[1]), float(r_ba_now[0]))
            error = wrap_angle_rad(desired - float(b_heading_rad[i - 1]))
            max_step = omega_max_rad_s * active_dt
            b_heading_rad[i] = b_heading_rad[i - 1] + float(
                np.clip(error, -max_step, max_step)
            )

    distance_m = np.linalg.norm(r_ab, axis=1)
    rho_a = distance_m / L_A
    beta_a_deg = np.empty_like(t)
    beta_b_deg = np.empty_like(t)
    for i in range(len(t)):
        beta_a_deg[i] = signed_angle_deg(
            unit_from_angle(float(a_heading_rad[i])), r_ab[i]
        )
        beta_b_deg[i] = signed_angle_deg(
            unit_from_angle(float(b_heading_rad[i])), -r_ab[i]
        )
    beta_a_deg[np.abs(beta_a_deg) < 1e-12] = 0.0
    beta_b_deg[np.abs(beta_b_deg) < 1e-12] = 0.0
    g_deg = np.abs(beta_b_deg) - np.abs(beta_a_deg)
    g_deg[np.abs(g_deg) < 1e-12] = 0.0
    t_realign_s, tau_s, alignment_status = measure_realign_time(
        t, np.abs(beta_b_deg)
    )
    return SimulationResult(
        scenario=scenario,
        t=t,
        a_position=a_position,
        b_position=b_position,
        a_heading_rad=a_heading_rad,
        b_heading_rad=b_heading_rad,
        distance_m=distance_m,
        rho_a=rho_a,
        beta_a_deg=beta_a_deg,
        beta_b_deg=beta_b_deg,
        g_deg=g_deg,
        t_realign_s=t_realign_s,
        tau_s=tau_s,
        alignment_status=alignment_status,
    )


def configure_style() -> None:
    plt.rcParams.update(
        {
            "figure.facecolor": "#FFFFFF",
            "axes.facecolor": "#FFFFFF",
            "savefig.facecolor": "#FFFFFF",
            "font.family": "sans-serif",
            "font.sans-serif": ["DejaVu Sans", "Arial", "Liberation Sans"],
            "font.size": 13,
            "axes.labelsize": 15,
            "xtick.labelsize": 12,
            "ytick.labelsize": 12,
            "legend.fontsize": 12,
            "text.color": "#111111",
            "axes.labelcolor": "#111111",
            "axes.edgecolor": "#111111",
            "xtick.color": "#111111",
            "ytick.color": "#111111",
            "axes.linewidth": 1.0,
            "grid.color": "#D2D2D2",
            "grid.linewidth": 0.7,
            "grid.alpha": 0.75,
            "svg.fonttype": "none",
        }
    )


def save_both(fig: plt.Figure, stem: str) -> None:
    fig.savefig(OUTPUT_DIR / f"{stem}.png", dpi=200)
    fig.savefig(OUTPUT_DIR / f"{stem}.svg")
    plt.close(fig)


def draw_boxer(
    ax: plt.Axes,
    position: np.ndarray,
    heading_rad: float,
    label: str,
    *,
    gray: str,
    alpha: float = 1.0,
    linestyle: str | tuple = "-",
    label_offset: tuple[float, float] = (0.0, 13.0),
    hollow: bool = False,
) -> None:
    orientation = unit_from_angle(heading_rad)
    shoulder = np.array([-orientation[1], orientation[0]]) * 0.22
    ax.plot(
        [position[0] - shoulder[0], position[0] + shoulder[0]],
        [position[1] - shoulder[1], position[1] + shoulder[1]],
        color=gray,
        linewidth=3.2,
        linestyle=linestyle,
        alpha=alpha,
        solid_capstyle="round",
        zorder=5,
    )
    ax.scatter(
        [position[0]],
        [position[1]],
        s=72,
        facecolors="#FFFFFF" if hollow else gray,
        edgecolors=gray,
        linewidths=2.0,
        alpha=alpha,
        zorder=6,
    )
    arrow_end = position + 0.50 * orientation
    ax.annotate(
        "",
        xy=arrow_end,
        xytext=position,
        arrowprops={
            "arrowstyle": "-|>",
            "color": gray,
            "lw": 2.0,
            "mutation_scale": 15,
            "linestyle": linestyle,
            "alpha": alpha,
        },
        zorder=4,
    )
    ax.annotate(
        label,
        xy=position,
        xytext=label_offset,
        textcoords="offset points",
        color=gray,
        alpha=alpha,
        # Ancoraggio derivato dallo scostamento su ciascun asse in modo
        # indipendente: evita che il testo finisca sulla linea delle spalle.
        ha="center" if label_offset[0] == 0 else ("left" if label_offset[0] > 0 else "right"),
        va="center" if label_offset[1] == 0 else ("bottom" if label_offset[1] > 0 else "top"),
        fontsize=11,
        weight="bold",
        zorder=8,
    )


def draw_signed_arc(
    ax: plt.Axes,
    center: np.ndarray,
    start_heading_rad: float,
    beta_deg: float,
    radius: float = 0.30,
    label: str | None = None,
) -> None:
    """Disegna soltanto il piccolo arco firmato tra i due vettori."""
    start_deg = math.degrees(start_heading_rad)
    if abs(beta_deg) < 1e-10:
        point = center + radius * unit_from_angle(start_heading_rad)
        ax.scatter([point[0]], [point[1]], s=16, color="#111111", zorder=7)
        if label:
            ax.annotate(
                label + r"$=0^\circ$",
                xy=point,
                xytext=(0, 9),
                textcoords="offset points",
                ha="center",
                va="bottom",
                fontsize=9.5,
                color="#333333",
            )
        return
    angles = np.radians(
        np.linspace(start_deg, start_deg + beta_deg, max(8, int(abs(beta_deg)) + 2))
    )
    arc = center + radius * np.column_stack((np.cos(angles), np.sin(angles)))
    ax.plot(arc[:, 0], arc[:, 1], color="#111111", linewidth=1.7, zorder=7)
    if label:
        mid_angle = math.radians(start_deg + beta_deg / 2.0)
        label_point = center + (radius + 0.09) * unit_from_angle(mid_angle)
        ax.text(
            label_point[0],
            label_point[1],
            label,
            ha="center",
            va="center",
            fontsize=10,
            color="#333333",
        )


def create_orientation_figure(results: dict[str, SimulationResult]) -> None:
    # La scena e' ruotata di +90 gradi solo per il disegno: A in basso, B in alto.
    # E' una rotazione rigida, non una riflessione, quindi il verso degli archi
    # firmati e la relazione destra-sinistra restano quelli della simulazione.
    # Serve a riempire un pannello alto e stretto invece di uno basso e largo.
    rot = np.array([[0.0, -1.0], [1.0, 0.0]])
    quarter_turn = math.pi / 2.0

    start_index = int(round(T_EXIT / DT))
    fig, axes = plt.subplots(1, 3, figsize=(12.8, 8.0), dpi=200)
    fig.subplots_adjust(left=0.02, right=0.99, bottom=0.11, top=0.92, wspace=0.05)

    for ax, scenario in zip(axes, SCENARIOS):
        result = results[scenario.key]
        peak_index = int(np.argmax(result.g_deg))
        a_start = rot @ result.a_position[start_index]
        b_start = rot @ result.b_position[start_index]
        a_peak = rot @ result.a_position[peak_index]
        b_peak = rot @ result.b_position[peak_index]
        a_heading = float(result.a_heading_rad[peak_index]) + quarter_turn
        b_heading = float(result.b_heading_rad[peak_index]) + quarter_turn
        beta_a = float(result.beta_a_deg[peak_index])
        beta_b = float(result.beta_b_deg[peak_index])

        ax.set_aspect("equal", adjustable="box")
        ax.set_xlim(-0.90, 0.90)
        ax.set_ylim(-0.42, 2.35)
        ax.axis("off")
        ax.set_title(scenario.label, fontsize=16, weight="bold", pad=10)

        ax.plot(
            [a_start[0], b_start[0]],
            [a_start[1], b_start[1]],
            color="#C8C8C8",
            linewidth=1.3,
            linestyle=(0, (1, 3)),
            zorder=1,
        )
        ax.plot(
            [a_peak[0], b_peak[0]],
            [a_peak[1], b_peak[1]],
            color="#555555",
            linewidth=1.4,
            linestyle=(0, (5, 4)),
            zorder=2,
        )

        path = result.a_position[start_index : peak_index + 1] @ rot.T
        ax.plot(path[:, 0], path[:, 1], color="#777777", linewidth=2.0, zorder=3)
        if len(path) > 5:
            arrow_from = len(path) // 2
            arrow_to = min(arrow_from + 3, len(path) - 1)
            ax.annotate(
                "",
                xy=path[arrow_to],
                xytext=path[arrow_from],
                arrowprops={"arrowstyle": "-|>", "color": "#777777", "lw": 1.6},
                zorder=4,
            )

        ax.scatter(
            [a_start[0]], [a_start[1]], s=74, facecolors="#FFFFFF",
            edgecolors="#777777", linewidths=1.8, zorder=6,
        )
        ax.annotate(
            "A prima", xy=a_start, xytext=(14, -10), textcoords="offset points",
            ha="left", va="top", fontsize=10.5, color="#666666", weight="bold",
        )

        if np.linalg.norm(b_peak - b_start) > 1e-10:
            ax.scatter(
                [b_start[0]], [b_start[1]], s=74, facecolors="#FFFFFF",
                edgecolors="#777777", linewidths=1.8, zorder=6,
            )
            ax.annotate(
                "B prima", xy=b_start, xytext=(15, 0), textcoords="offset points",
                ha="left", va="center", fontsize=10.5, color="#666666", weight="bold",
            )

        draw_boxer(
            ax,
            a_peak,
            a_heading,
            "A dopo",
            gray="#111111",
            label_offset=(-14, 12),
        )
        draw_boxer(
            ax,
            b_peak,
            b_heading,
            "B al picco",
            gray="#111111",
            label_offset=(0, 18),
        )

        if abs(beta_a) >= 1e-9:
            draw_signed_arc(ax, a_peak, a_heading, beta_a)
        draw_signed_arc(ax, b_peak, b_heading, beta_b)

        ax.text(
            0.035,
            0.985,
            f"t = {result.t[peak_index]:.3f} s\n"
            rf"$\beta_A={beta_a:.1f}^\circ$   $\beta_B={beta_b:.1f}^\circ$" "\n"
            rf"$G={result.g_deg[peak_index]:.1f}^\circ$",
            transform=ax.transAxes,
            ha="left",
            va="top",
            fontsize=10.5,
            linespacing=1.35,
            bbox={"boxstyle": "round,pad=0.35", "fc": "#FFFFFF", "ec": "#D2D2D2"},
            zorder=10,
        )

    # Nota valida per tutti e tre i pannelli: una volta sola, non ripetuta.
    fig.text(
        0.5,
        0.085,
        r"In tutti gli scenari $\beta_A = 0.0^\circ$: A resta orientato su B per costruzione, "
        r"quindi qui $G = |\beta_B|$.",
        ha="center",
        va="bottom",
        fontsize=11,
        color="#555555",
    )

    fig.legend(
        handles=[
            Line2D([0], [0], color="#777777", linewidth=2.0, label="arco percorso da A"),
            Line2D(
                [0], [0], color="#555555", linewidth=1.4,
                linestyle=(0, (5, 4)), label="linea A–B al picco",
            ),
            Line2D(
                [0], [0], color="#C8C8C8", linewidth=1.3,
                linestyle=(0, (1, 3)), label="vecchia linea A–B",
            ),
        ],
        loc="lower center",
        bbox_to_anchor=(0.5, 0.025),
        ncol=3,
        frameon=False,
        fontsize=11,
    )
    save_both(fig, "figura_1_orientamento")


def create_realignment_figure(results: dict[str, SimulationResult]) -> None:
    fig, ax = plt.subplots(figsize=(12.8, 8.0), dpi=200, constrained_layout=True)
    peak_all = max(float(np.max(r.g_deg)) for r in results.values())
    ax.set_xlim(0.0, HORIZON)
    ax.set_ylim(-2.5, peak_all + 5.5)
    ax.axvline(
        T_EXIT,
        color="#111111",
        linewidth=1.3,
        linestyle=(0, (4, 3)),
        zorder=1,
    )
    ax.axhline(
        EPSILON_DEG,
        color="#777777",
        linewidth=1.3,
        linestyle=(0, (2, 2)),
        zorder=1,
    )

    marker_offsets = {"statico": 0, "reattivo": 10, "impegnato": 20}
    for result in results.values():
        scenario = result.scenario
        ax.plot(
            result.t,
            result.g_deg,
            color=scenario.gray,
            linewidth=2.2,
            linestyle=scenario.linestyle,
            marker=scenario.marker,
            markersize=5.2,
            markerfacecolor="#FFFFFF",
            markeredgewidth=1.2,
            markevery=(marker_offsets[scenario.key], 40),
            label=scenario.label,
            zorder=3,
        )

    ax.text(
        T_EXIT + 0.015,
        peak_all + 4.8,
        rf"uscita  $t={T_EXIT:.2f}\,\mathrm{{s}}$",
        ha="left",
        va="top",
        fontsize=12,
    )
    ax.text(
        1.98,
        EPSILON_DEG + 0.35,
        rf"$\varepsilon={EPSILON_DEG:.0f}^\circ$ su $|\beta_B|$ "
        r"(qui $G\simeq|\beta_B|$)",
        ha="right",
        va="bottom",
        color="#555555",
        fontsize=12,
    )

    reactive = results["reattivo"]
    committed = results["impegnato"]
    if reactive.tau_s != 0.0:
        raise RuntimeError("REATTIVO deve restare sempre entro epsilon")
    if committed.tau_s is None or committed.tau_s <= 0.0:
        raise RuntimeError("IMPEGNATO deve avere una tau misurabile")

    band_end = T_EXIT + committed.tau_s
    ax.fill_between(
        [T_EXIT, band_end],
        -2.5,
        -1.25,
        facecolor="#BDBDBD",
        edgecolor="#666666",
        alpha=0.38,
        linewidth=1.0,
        zorder=1,
    )
    ax.text(
        0.62,
        2.25,
        rf"IMPEGNATO: $\tau={committed.tau_s:.3f}\,\mathrm{{s}}$",
        fontsize=11,
        ha="center",
        va="center",
        color="#333333",
        zorder=6,
    )

    note_box = {"boxstyle": "round,pad=0.28", "fc": "#FFFFFF", "ec": "#D2D2D2"}
    ax.text(
        0.985,
        0.845,
        r"STATICO: $\tau$ non definita (> orizzonte)",
        transform=ax.transAxes,
        ha="right",
        va="top",
        fontsize=10.5,
        bbox=note_box,
        zorder=8,
    )
    ax.text(
        0.985,
        0.785,
        r"REATTIVO: $\tau=0$ s (mai oltre $\varepsilon$)",
        transform=ax.transAxes,
        ha="right",
        va="top",
        fontsize=10.5,
        color="#555555",
        bbox=note_box,
        zorder=8,
    )

    ax.set_xlabel("Tempo (s)")
    ax.set_ylabel(r"Asimmetria geometrica  $G(t)$ (gradi)")
    ax.grid(True)
    ax.set_yticks(np.arange(0.0, math.ceil(peak_all / 5.0) * 5.0 + 0.1, 5.0))
    ax.legend(
        loc="upper right",
        ncol=3,
        frameon=True,
        facecolor="#FFFFFF",
        edgecolor="#D2D2D2",
        handlelength=3.0,
    )
    for spine in ("top", "right"):
        ax.spines[spine].set_visible(False)
    save_both(fig, "figura_2_finestra_riallineamento")


def create_distance_figure(results: dict[str, SimulationResult]) -> None:
    fig, ax = plt.subplots(figsize=(12.8, 8.0), dpi=200, constrained_layout=True)
    marker_offsets = {"statico": 0, "reattivo": 10, "impegnato": 20}

    # Disegnare prima la curva coincidente reattiva rende visibili entrambi i
    # codici (linea/marker) senza alterare i dati.
    order = (results["reattivo"], results["statico"], results["impegnato"])
    for result in order:
        scenario = result.scenario
        reactive_underlay = scenario.key == "reattivo"
        ax.plot(
            result.t,
            result.rho_a,
            color="#B5B5B5" if reactive_underlay else scenario.gray,
            linewidth=4.6 if reactive_underlay else 2.2,
            linestyle=scenario.linestyle,
            marker=scenario.marker,
            markersize=6.8 if reactive_underlay else 5.2,
            markerfacecolor="#FFFFFF",
            markeredgecolor="#888888" if reactive_underlay else scenario.gray,
            markeredgewidth=1.4 if reactive_underlay else 1.2,
            markevery=(marker_offsets[scenario.key], 40),
            label=scenario.label,
            zorder=1 if reactive_underlay else 2,
        )
    ax.axvline(T_EXIT, color="#777777", linewidth=1.2, linestyle=(0, (4, 3)))
    ax.text(
        T_EXIT + 0.015,
        max(float(np.max(r.rho_a)) for r in results.values()) - 0.004,
        "uscita",
        ha="left",
        va="top",
        fontsize=12,
        color="#555555",
    )
    ax.text(
        1.10,
        float(results["statico"].rho_a[-1]) - 0.075,
        "STATICO e REATTIVO coincidono: il pivot puro conserva d",
        fontsize=11,
        color="#555555",
        ha="center",
        va="top",
        bbox={"boxstyle": "round,pad=0.25", "fc": "#FFFFFF", "ec": "#D2D2D2"},
    )
    ax.set_xlim(0.0, HORIZON)
    rho_min = min(float(np.min(r.rho_a)) for r in results.values())
    rho_max = max(float(np.max(r.rho_a)) for r in results.values())
    padding = max(0.025, 0.12 * (rho_max - rho_min))
    ax.set_ylim(min(0.95, rho_min - padding), rho_max + padding)
    ax.axhline(1.0, color="#777777", linewidth=1.2, linestyle=(0, (5, 3)))
    ax.text(0.045, 1.008, r"$\rho_A=1$", ha="left", va="bottom", fontsize=12)
    ax.set_xlabel("Tempo (s)")
    ax.set_ylabel(r"Distanza normalizzata  $\rho_A=d/L_A$")
    ax.grid(True)
    handles, labels = ax.get_legend_handles_labels()
    by_label = dict(zip(labels, handles))
    ax.legend(
        [by_label[s.label] for s in SCENARIOS],
        [s.label for s in SCENARIOS],
        loc="lower right",
        frameon=False,
        handlelength=3.3,
    )
    for spine in ("top", "right"):
        ax.spines[spine].set_visible(False)
    save_both(fig, "figura_3_distanza_normalizzata")


def result_row(result: SimulationResult) -> str:
    peak_index = int(np.argmax(result.g_deg))
    sample_time = T_EXIT + 0.5
    sample_index = int(round(sample_time / DT))
    tau = "> horizon" if result.tau_s is None else f"{result.tau_s:.6f} s"
    return (
        f"| {result.scenario.label} | {result.g_deg[peak_index]:.6f}° | "
        f"{result.t[peak_index]:.6f} s | {tau} | "
        f"{result.g_deg[sample_index]:.6f}° | "
        f"{np.min(result.rho_a):.6f} | {np.max(result.rho_a):.6f} |"
    )


def manual_sanity_section(result: SimulationResult) -> str:
    lines = [
        "## 4. Controllo indipendente a tre istanti",
        "",
        (
            "Controllo sullo scenario IMPEGNATO. Qui i valori sono ricalcolati "
            "direttamente come `atan2(o_x r_y - o_y r_x, o_x r_x + o_y r_y)`, "
            "senza chiamare `signed_angle_deg`."
        ),
        "",
    ]
    for timestamp in (0.300, 0.500, 0.750):
        i = int(round(timestamp / DT))
        r_ab = result.b_position[i] - result.a_position[i]
        r_ba = -r_ab
        o_a = unit_from_angle(float(result.a_heading_rad[i]))
        o_b = unit_from_angle(float(result.b_heading_rad[i]))
        det_a = float(o_a[0] * r_ab[1] - o_a[1] * r_ab[0])
        dot_a = float(o_a[0] * r_ab[0] + o_a[1] * r_ab[1])
        det_b = float(o_b[0] * r_ba[1] - o_b[1] * r_ba[0])
        dot_b = float(o_b[0] * r_ba[0] + o_b[1] * r_ba[1])
        beta_a_manual = math.degrees(math.atan2(det_a, dot_a))
        beta_b_manual = math.degrees(math.atan2(det_b, dot_b))
        g_manual = abs(beta_b_manual) - abs(beta_a_manual)
        lines.extend(
            [
                f"### t = {timestamp:.3f} s",
                "",
                (
                    f"- A: `o_A=({o_a[0]:.9f}, {o_a[1]:.9f})`, "
                    f"`r_AB=({r_ab[0]:.9f}, {r_ab[1]:.9f})`; "
                    f"det = `{det_a:.9f}`, dot = `{dot_a:.9f}`; "
                    f"beta_A = atan2({det_a:.9f}, {dot_a:.9f}) = "
                    f"`{beta_a_manual:.6f}°`."
                ),
                (
                    f"- B: `o_B=({o_b[0]:.9f}, {o_b[1]:.9f})`, "
                    f"`r_BA=({r_ba[0]:.9f}, {r_ba[1]:.9f})`; "
                    f"det = `{det_b:.9f}`, dot = `{dot_b:.9f}`; "
                    f"beta_B = atan2({det_b:.9f}, {dot_b:.9f}) = "
                    f"`{beta_b_manual:.6f}°`."
                ),
                (
                    f"- `G = |{beta_b_manual:.6f}| - |{beta_a_manual:.6f}| "
                    f"= {g_manual:.6f}°`. Simulazione: "
                    f"beta_A `{result.beta_a_deg[i]:.6f}°`, "
                    f"beta_B `{result.beta_b_deg[i]:.6f}°`, "
                    f"G `{result.g_deg[i]:.6f}°`: corrispondenza entro "
                    f"`{max(abs(beta_a_manual-result.beta_a_deg[i]), abs(beta_b_manual-result.beta_b_deg[i]), abs(g_manual-result.g_deg[i])):.3e}°`."
                ),
                "",
            ]
        )
    return "\n".join(lines)


def build_report(results: dict[str, SimulationResult]) -> str:
    speed = TANGENTIAL_SPEED
    assumptions = f"""## 2. ASSUMPTIONS — tutte assunte, nessuna misurata

| Costante/comportamento | Valore | Stato e giustificazione |
|---|---:|---|
| Passo temporale `dt` | {DT:.3f} s | Assunto; risoluzione numerica. |
| Orizzonte | {HORIZON:.3f} s | Assunto; finestra editoriale osservata. |
| Uscita `t_exit` | {T_EXIT:.3f} s | Assunta; inizio comune del pivot. |
| Tolleranza `epsilon` | {EPSILON_DEG:.1f}° | Assunta e dichiarata prima del calcolo. |
| Distanza iniziale `d0` | {D0:.3f} m | Assunta; distanza plausibile tra i centri. |
| Stato iniziale | `A=(0,0) m`, heading 0°; `B=(1.65,0) m`, heading 180° | Assunto; i due pugili partono perfettamente frontali. |
| Allungo funzionale `L_A` | {L_A:.3f} m | Assunto; normalizza la distanza, non è antropometria rilevata. |
| Arco `theta_step` | {THETA_STEP_DEG:.3f}° | Assunto; pivot breve e controllabile. |
| Durata pivot `t_move` | {T_MOVE:.3f} s | Assunta. |
| Direzione pivot | oraria vista dall'alto, da angolo polare 180° a 163° | Assunta; il segno non cambia i risultati in valore assoluto. |
| Velocità tangenziale A | {speed:.6f} m/s | Derivata da costanti assunte: `d0 * radians(theta_step) / t_move`; circa 1.4 m/s, non misurata. |
| Centro dell'arco | posizione di B a `t_exit` | Assunto fisso per definire un arco circolare identico nei tre scenari. |
| Orientamento A | sempre verso la posizione corrente di B | Assunto ideale; beta_A è poi ricalcolato dalla geometria. |
| Riferimento rotazione busto | {OMEGA_REFERENCE_DEG_S:.1f}°/s | Assunzione editoriale, non costante biomeccanica; serve solo da centro plausibile per i due rate limit. |
| STATICO | latenza n/a; `omega_max=0`; nessuna traslazione | Assunto; B non ruota mai. |
| REATTIVO | latenza 0.120 s; `omega_max=220°/s`; nessuna traslazione | Assunto; risposta più rapida del riferimento editoriale. |
| IMPEGNATO | impegno 0.350 s; avanzamento 1.100 m/s sulla vecchia linea; poi `omega_max=140°/s` | Assunto; il peso in avanti è rappresentato solo da ritardo, traslazione e rate limit minore. |
| Controllore B | errore angolare più breve, saturato a `omega_max` | Assunto; integrazione a passi di 0.005 s. |
| Misura del rientro | interpolazione lineare fra due campioni che attraversano 12° verso il basso | Scelta numerica dichiarata. Se B non supera mai epsilon, tau=0; se non rientra, `> horizon`. |
"""

    audit = """## 1. Audit dello script originale

- **La curva della figura 2 è fabbricata, non simulata.** `G(t)` è una rampa triangolare imposta con `38 * ...`, poi decorata con `1.4*sin(18*t)`. Non esistono posizioni, orientamenti o un controllore di B in quella funzione. `t_align=1.27 s` è assunto; `tau=0.92 s` è quindi assunto, non misurato. La condizione `|beta_B| <= epsilon` non viene mai calcolata e nessuna epsilon compare nella figura.
- Anche la figura 1 è uno snapshot costruito a mano: le coordinate finali e l'orientamento `-12°` sono letterali, mentre la traiettoria è una freccia Bézier decorativa, non un arco cinematico.
- `signed_angle_deg` usa correttamente `atan2(det(source,target), dot(source,target))`: il segno è positivo per una rotazione antioraria dal vettore sorgente al bersaglio e `atan2` restituisce il ramo principale in `[-180°, 180°]` (a 180° il segno dipende dallo zero numerico). Test indipendenti danno `+90°` per `(1,0)->(0,1)`, `-90°` per `(1,0)->(0,-1)` e `180°` per vettori opposti. Non c'è un errore gradi/radianti in questa funzione. Però normalizza senza necessità entrambi i vettori e non controlla norma zero/quasi zero o valori non finiti: con un vettore nullo o subnormale restituisce concretamente `NaN` e warning NumPy invece di un errore esplicito.
- Controllo indipendente della figura 1 originale: `r_AB=(5.0-3.1, 2.2-3.0)=(1.9,-0.8)` e `o_A=(cos(-12°),sin(-12°))=(0.978147601,-0.207911691)`. Quindi `det_A=0.978147601*(-0.8)-(-0.207911691)*1.9=-0.387485868`, `dot_A=0.978147601*1.9+(-0.207911691)*(-0.8)=2.024809794`, e `beta_A=atan2(-0.387485868,2.024809794)=-10.833654°`. Per B, `o_B=(-1,0)`, `r_BA=(-1.9,0.8)`, `det_B=-0.8`, `dot_B=1.9`, quindi `beta_B=atan2(-0.8,1.9)=-22.833654°`. Infine `G=22.833654-10.833654=12.000000°`. Le annotazioni `-10.8°`, `-22.8°`, `12.0°` sono dunque corrette per quello snapshot arbitrario.
- La griglia temporale originale è `linspace(0,1.8,500)`, passo `0.003607214 s`; non contiene esattamente `0.35`, `0.62` o `1.27 s`. Il picco campionato è perciò `36.594970°` a `0.620441 s`, non i 38° suggeriti dal parametro.
- La sinusoide è mascherata e quindi **non** rende G non nullo fuori da `[t_exit,t_align]`; il massimo valore assoluto fuori dalla maschera è esattamente zero. Dentro la finestra, però, altera senza base geometrica ampiezza e attraversamenti. Vicino alla fine porta G a `-1.049542°` a `1.269739 s`, fa attraversare lo zero tra `1.255311` e `1.258918 s` prima del presunto riallineamento, e poi la curva salta a zero. Analiticamente il termine cosmetico vale `-1.069023°` a `t_align`, quindi il raccordo è discontinuo. Se si applicasse impropriamente epsilon=12° a G, l'attraversamento sarebbe spostato dalla sinusoide; in ogni caso tau va misurato su `|beta_B|`, non su G.
- `sin(18*t)` usa implicitamente 18 rad/s (circa 2.86 Hz); non è una confusione gradi/radianti nel codice Python, ma la frequenza è priva di motivazione e il termine intero è puramente cosmetico.
- `fill_between(..., where=...)` usa campioni booleani senza `interpolate=True`: poiché i confini non sono nella griglia, il riempimento visivo è troncato ai campioni interni e non coincide esattamente con i tempi annotati. Inoltre colora anche il piccolo lobo negativo creato dalla sinusoide.
- Lo script originale dipende dal backend Matplotlib scelto dall'ambiente: in questo sandbox l'invocazione diretta è terminata con codice 134, mentre `MPLBACKEND=Agg ./venv/bin/python ...` è terminata con codice 0. Il nuovo script seleziona esplicitamente il backend non interattivo `Agg`.
- Le figure originali non hanno rapporto 16:10 (`10x7` e `10x5.5`) e `bbox_inches="tight"` ne modifica ulteriormente le dimensioni esportate. Usano inoltre titolo interno, assi senza unità nella figura 1 e stile colore predefinito.
"""

    table_lines = [
        "## 3. Risultati della simulazione",
        "",
        "| Scenario | Picco G | Tempo del picco | tau | G a t_exit+0.5 s | rho_A min | rho_A max |",
        "|---|---:|---:|---:|---:|---:|---:|",
    ]
    table_lines.extend(result_row(results[s.key]) for s in SCENARIOS)
    table_lines.extend(
        [
            "",
            (
                "Nota geometrica: il pivot è un arco circolare attorno a B. "
                "Perciò STATICO e REATTIVO conservano esattamente d=1.65 m e "
                "rho_A=1.833333; la distanza cambia soltanto nello scenario "
                "IMPEGNATO, perché B trasla sulla vecchia linea. La figura 3 "
                "mostra questa conseguenza reale invece di inventare una variazione."
            ),
            (
                "In tutti i campioni beta_A è zero entro la precisione numerica, "
                "perché A guarda la posizione corrente di B. Di conseguenza, in "
                "questa simulazione G=|beta_B| e la linea G=12° della figura 2 "
                "coincide con la soglia di riallineamento, pur restando tau misurata "
                "esplicitamente su |beta_B|."
            ),
            "",
        ]
    )

    limitations = """## 5. Cosa questo non mostra

Questa è una simulazione cinematica giocattolo. Non contiene biomeccanica, piedi, equilibrio, massa, forze, colpi, difesa o vincoli del ring. Non usa una distribuzione dei tempi di reazione tratta dalla letteratura e nessuna costante è stata misurata su pugili. Non è validata contro video, sensori o risultati agonistici e non dimostra un vantaggio causale: mostra soltanto le conseguenze geometriche delle assunzioni dichiarate.
"""

    generated = """## File generati

- `figura_1_orientamento.png` e `.svg`
- `figura_2_finestra_riallineamento.png` e `.svg`
- `figura_3_distanza_normalizzata.png` e `.svg`
"""
    return (
        "# Report simulazione geometria del ring\n\n"
        + audit
        + "\n"
        + assumptions
        + "\n"
        + "\n".join(table_lines)
        + "\n\n"
        + manual_sanity_section(results["impegnato"])
        + "\n"
        + limitations
        + "\n"
        + generated
    )


def validate(results: dict[str, SimulationResult]) -> None:
    expected_samples = int(round(HORIZON / DT)) + 1
    for result in results.values():
        assert len(result.t) == expected_samples
        assert np.all(np.isfinite(result.g_deg))
        assert np.all(np.isfinite(result.rho_a))
        assert np.max(np.abs(result.beta_a_deg)) < 1e-9
        assert np.allclose(
            result.g_deg, np.abs(result.beta_b_deg), rtol=0.0, atol=1e-9
        )
    assert results["statico"].tau_s is None
    assert results["reattivo"].tau_s == 0.0
    assert results["impegnato"].tau_s is not None
    assert np.allclose(results["statico"].rho_a, D0 / L_A, atol=1e-12)
    assert np.allclose(results["reattivo"].rho_a, D0 / L_A, atol=1e-12)


def main() -> None:
    configure_style()
    results = {scenario.key: simulate(scenario) for scenario in SCENARIOS}
    validate(results)
    create_orientation_figure(results)
    create_realignment_figure(results)
    create_distance_figure(results)
    report = build_report(results)
    report_path = OUTPUT_DIR / "REPORT_SIMULAZIONE.md"
    report_path.write_text(report + "\n", encoding="utf-8")
    print(report)


if __name__ == "__main__":
    main()
