# ruff: noqa: T201
"""Visualizations for FDOA geolocation Monte Carlo statistical validation.

Plots the normalized miss distance distribution against the theoretical
chi-squared(3) distribution to verify covariance calibration.

FDOA (Frequency Difference of Arrival) requires relative motion between
emitter and collectors. This module demonstrates two primary use cases:

1. **Fixed Velocity Mode** (target velocity known, estimate position):
   - Stationary target (velocity = 0) with moving LEO collectors
   - Moving collectors create Doppler differences that enable position estimation
   - Common for ground-based emitters observed by LEO satellites

2. **Fixed Position Mode** (target position known, estimate velocity):
   - Known position (e.g., from TDOA) with moving collectors
   - Enables precise velocity estimation (cm/s accuracy)
   - Useful for target motion analysis after initial position fix

Collector (LEO) movement is modeled with various orbital inclinations to
provide diverse angular velocity directions and good 3D observability.

Run with:
    python -m gri_sandbox.plots.fdoa_monte_carlo
"""

from __future__ import annotations

import numpy as np
import plotly.graph_objects as go
from gri_geosim.geolocation import fdoa_locator
from gri_utils.observables import get_fdoa
from plotly.subplots import make_subplots
from scipy.stats import chi2, kstest

EARTH_RADIUS = 6378137.0
SPEED_OF_LIGHT = 299792458.0
LEO_ALTITUDE = 600_000.0  # 600 km LEO altitude
LEO_VELOCITY = 7500.0  # Approximate LEO orbital velocity (m/s)

# Dark theme template
DARK_TEMPLATE = "plotly_dark"


def _leo_velocity(position: np.ndarray, inclination_deg: float) -> np.ndarray:
    """Compute LEO orbital velocity perpendicular to position vector.

    The velocity is perpendicular to the radial direction and rotated
    according to the orbital inclination to provide diverse angular
    velocity directions for FDOA observability.

    Args:
        position: Satellite position in ECEF meters. Shape (3,).
        inclination_deg: Orbital inclination in degrees.

    Returns:
        Velocity vector in ECEF m/s. Shape (3,).
    """
    r_hat = position / np.linalg.norm(position)
    incl = np.radians(inclination_deg)

    # Find a perpendicular direction
    if abs(r_hat[2]) < 0.9:  # noqa: PLR2004
        perp = np.cross(r_hat, np.array([0.0, 0.0, 1.0]))
    else:
        perp = np.cross(r_hat, np.array([1.0, 0.0, 0.0]))
    perp = perp / np.linalg.norm(perp)

    # Rotate by inclination
    v_hat = perp * np.cos(incl) + np.cross(r_hat, perp) * np.sin(incl)
    return v_hat * LEO_VELOCITY


def _create_leo_geometry(
    n_collectors: int = 6,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Create LEO collector geometry with diverse orbital directions.

    Creates n_collectors LEO satellites with varying orbital inclinations
    and positions, providing good 3D geometry for FDOA. The satellites
    are distributed in 3D (not coplanar) to ensure observability in all
    directions.

    Args:
        n_collectors: Number of satellites (minimum 4 recommended).

    Returns:
        Tuple of (collector_xyz, collector_vel, target_xyz, target_vel).
    """
    r = EARTH_RADIUS + LEO_ALTITUDE

    # Diverse orbital inclinations for varied velocity directions
    inclinations = np.linspace(0.0, 90.0, n_collectors)

    # 3D distributed collector positions (not coplanar)
    # Using Fibonacci sphere-like distribution for uniform spread
    golden_ratio = (1 + np.sqrt(5)) / 2
    collector_xyz = np.zeros((n_collectors, 3))

    for i in range(n_collectors):
        # Latitude-like angle from -45 to +45 degrees
        lat = np.radians(-45 + 90 * (i + 0.5) / n_collectors)
        # Longitude using golden angle for even distribution
        lon = 2 * np.pi * i / golden_ratio

        collector_xyz[i] = np.array(
            [
                r * np.cos(lat) * np.cos(lon),
                r * np.cos(lat) * np.sin(lon),
                r * np.sin(lat),
            ],
        )

    # Compute velocities based on position and inclination
    collector_vel = np.array(
        [
            _leo_velocity(pos, incl)
            for pos, incl in zip(collector_xyz, inclinations, strict=True)
        ],
    )

    # Stationary ground target
    target_xyz = np.array(
        [
            EARTH_RADIUS * np.cos(np.radians(30)),
            0.0,
            EARTH_RADIUS * np.sin(np.radians(30)),
        ],
    )
    target_vel = np.zeros(3)

    return collector_xyz, collector_vel, target_xyz, target_vel


def _make_pairs(
    collector_xyz: np.ndarray,
    collector_vel: np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Create all unique collector pairs for FDOA measurements.

    Args:
        collector_xyz: Collector positions. Shape (N, 3).
        collector_vel: Collector velocities. Shape (N, 3).

    Returns:
        Tuple of (c1_xyz, c2_xyz, c1_vel, c2_vel) for all pairs.
    """
    n = len(collector_xyz)
    pairs = [(i, j) for i in range(n) for j in range(i + 1, n)]
    c1_xyz = np.array([collector_xyz[i] for i, _ in pairs])
    c2_xyz = np.array([collector_xyz[j] for _, j in pairs])
    c1_vel = np.array([collector_vel[i] for i, _ in pairs])
    c2_vel = np.array([collector_vel[j] for _, j in pairs])
    return c1_xyz, c2_xyz, c1_vel, c2_vel


def _compute_fdoas(  # noqa: PLR0913
    target_xyz: np.ndarray,
    target_vel: np.ndarray,
    c1_xyz: np.ndarray,
    c2_xyz: np.ndarray,
    c1_vel: np.ndarray,
    c2_vel: np.ndarray,
    freq_hz: float,
) -> np.ndarray:
    """Compute FDOA measurements for all collector pairs.

    Args:
        target_xyz: Target position. Shape (3,).
        target_vel: Target velocity. Shape (3,).
        c1_xyz: First collector positions. Shape (N, 3).
        c2_xyz: Second collector positions. Shape (N, 3).
        c1_vel: First collector velocities. Shape (N, 3).
        c2_vel: Second collector velocities. Shape (N, 3).
        freq_hz: Carrier frequency in Hz.

    Returns:
        FDOA measurements in Hz. Shape (N,).
    """
    return np.array(
        [
            get_fdoa(
                target_xyz,
                target_vel,
                c1_xyz[i],
                c2_xyz[i],
                freq_hz,
                collector1_vel=c1_vel[i],
                collector2_vel=c2_vel[i],
            )
            for i in range(len(c1_xyz))
        ],
    )


def run_monte_carlo_fixed_velocity(
    n_trials: int = 1000,
    fdoa_sigma: float = 0.1,  # 0.1 Hz FDOA noise
    freq_hz: float = 1e9,  # 1 GHz carrier
    seed: int = 42,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Run Monte Carlo simulation for FDOA with fixed (zero) target velocity.

    Estimates target position when velocity is known to be zero (stationary
    target). This is the most common use case for passive geolocation of
    ground-based emitters using LEO satellites.

    Args:
        n_trials: Number of Monte Carlo trials.
        fdoa_sigma: FDOA measurement noise (1-sigma, Hz).
        freq_hz: Carrier frequency in Hz.
        seed: Random seed for reproducibility.

    Returns:
        Tuple of (normalized_miss_sq, position_errors, target_true).
    """
    rng = np.random.default_rng(seed)

    collector_xyz, collector_vel, target_true, vel_true = _create_leo_geometry()
    c1_xyz, c2_xyz, c1_vel, c2_vel = _make_pairs(collector_xyz, collector_vel)
    n_pairs = len(c1_xyz)

    true_fdoa = _compute_fdoas(
        target_true,
        vel_true,
        c1_xyz,
        c2_xyz,
        c1_vel,
        c2_vel,
        freq_hz,
    )
    fdoa_variances = np.full(n_pairs, fdoa_sigma**2)

    normalized_miss_sq = []
    position_errors = []

    for _ in range(n_trials):
        noise = rng.standard_normal(n_pairs) * fdoa_sigma
        noisy_fdoa = true_fdoa + noise

        result = fdoa_locator(
            c1_xyz,
            c2_xyz,
            noisy_fdoa,
            fdoa_variances,
            freq_hz,
            collector1_vel=c1_vel,
            collector2_vel=c2_vel,
            fix_velocity=np.zeros(3),  # Stationary target
        )

        delta = result.position - target_true
        cov_inv = np.linalg.inv(result.covariance)
        d_sq = delta @ cov_inv @ delta
        normalized_miss_sq.append(d_sq)
        position_errors.append(delta)

    return np.array(normalized_miss_sq), np.array(position_errors), target_true


def run_monte_carlo_fixed_position(
    n_trials: int = 1000,
    fdoa_sigma: float = 0.1,  # 0.1 Hz FDOA noise
    freq_hz: float = 1e9,  # 1 GHz carrier
    seed: int = 42,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Run Monte Carlo simulation for FDOA with fixed target position.

    Estimates target velocity when position is known (e.g., from TDOA).
    This enables precise velocity estimation for moving targets.

    Args:
        n_trials: Number of Monte Carlo trials.
        fdoa_sigma: FDOA measurement noise (1-sigma, Hz).
        freq_hz: Carrier frequency in Hz.
        seed: Random seed for reproducibility.

    Returns:
        Tuple of (normalized_miss_sq, velocity_errors, velocity_true).
    """
    rng = np.random.default_rng(seed)

    collector_xyz, collector_vel, target_xyz, _ = _create_leo_geometry()
    # Simulate a moving target (e.g., aircraft)
    vel_true = np.array([100.0, 50.0, 10.0])  # m/s

    c1_xyz, c2_xyz, c1_vel, c2_vel = _make_pairs(collector_xyz, collector_vel)
    n_pairs = len(c1_xyz)

    true_fdoa = _compute_fdoas(
        target_xyz,
        vel_true,
        c1_xyz,
        c2_xyz,
        c1_vel,
        c2_vel,
        freq_hz,
    )
    fdoa_variances = np.full(n_pairs, fdoa_sigma**2)

    normalized_miss_sq = []
    velocity_errors = []

    for _ in range(n_trials):
        noise = rng.standard_normal(n_pairs) * fdoa_sigma
        noisy_fdoa = true_fdoa + noise

        result = fdoa_locator(
            c1_xyz,
            c2_xyz,
            noisy_fdoa,
            fdoa_variances,
            freq_hz,
            collector1_vel=c1_vel,
            collector2_vel=c2_vel,
            fix_position=target_xyz,  # Known position
        )

        delta = result.velocity - vel_true
        cov_inv = np.linalg.inv(result.covariance)
        d_sq = delta @ cov_inv @ delta
        normalized_miss_sq.append(d_sq)
        velocity_errors.append(delta)

    return np.array(normalized_miss_sq), np.array(velocity_errors), vel_true


def plot_chi2_histogram(
    normalized_miss_sq: np.ndarray,
    title_suffix: str = "",
) -> go.Figure:
    """Plot histogram of normalized miss distance vs chi-squared(3) PDF.

    Args:
        normalized_miss_sq: Array of normalized miss distance squared values.
        title_suffix: Additional text for title.

    Returns:
        Plotly figure with histogram and theoretical PDF overlay.
    """
    fig = go.Figure()

    # Histogram of empirical data
    fig.add_trace(
        go.Histogram(
            x=normalized_miss_sq,
            nbinsx=50,
            histnorm="probability density",
            name="Empirical",
            marker_color="rgba(99, 110, 250, 0.7)",
            marker_line_color="rgba(99, 110, 250, 1)",
            marker_line_width=1,
        ),
    )

    # Theoretical chi-squared(3) PDF
    x_theory = np.linspace(0, max(15, np.max(normalized_miss_sq)), 200)
    pdf_theory = chi2.pdf(x_theory, df=3)

    fig.add_trace(
        go.Scatter(
            x=x_theory,
            y=pdf_theory,
            mode="lines",
            name="chi-sq(3) Theory",
            line={"color": "#EF553B", "width": 3},
        ),
    )

    # Add vertical lines for key percentiles
    p95_theory = chi2.ppf(0.95, df=3)
    p95_empirical = np.percentile(normalized_miss_sq, 95)

    fig.add_vline(
        x=p95_theory,
        line_dash="dash",
        line_color="#00CC96",
        annotation_text=f"95% theory: {p95_theory:.2f}",
        annotation_position="top right",
    )
    fig.add_vline(
        x=p95_empirical,
        line_dash="dot",
        line_color="#AB63FA",
        annotation_text=f"95% empirical: {p95_empirical:.2f}",
        annotation_position="top left",
    )

    # Statistics annotation
    mean_d_sq = np.mean(normalized_miss_sq)
    var_d_sq = np.var(normalized_miss_sq)
    _, p_value = kstest(normalized_miss_sq, chi2(3).cdf)

    stats_text = (
        f"<b>Statistics:</b><br>"
        f"Mean: {mean_d_sq:.3f} (theory: 3.0)<br>"
        f"Var: {var_d_sq:.3f} (theory: 6.0)<br>"
        f"KS p-value: {p_value:.4f}"
    )

    fig.add_annotation(
        x=0.98,
        y=0.98,
        xref="paper",
        yref="paper",
        text=stats_text,
        showarrow=False,
        font={"size": 12},
        align="left",
        bgcolor="rgba(0,0,0,0.5)",
        bordercolor="rgba(255,255,255,0.3)",
        borderwidth=1,
        borderpad=8,
        xanchor="right",
        yanchor="top",
    )

    title = "FDOA Normalized Miss Distance Distribution vs chi-sq(3)"
    if title_suffix:
        title += f" ({title_suffix})"

    fig.update_layout(
        template=DARK_TEMPLATE,
        title=title,
        xaxis_title="Normalized Miss Distance Squared (d^2)",
        yaxis_title="Probability Density",
        legend={"x": 0.7, "y": 0.5},
        bargap=0.05,
    )

    return fig


def plot_qq(normalized_miss_sq: np.ndarray, title_suffix: str = "") -> go.Figure:
    """Plot Q-Q plot of normalized miss distance vs chi-squared(3).

    Args:
        normalized_miss_sq: Array of normalized miss distance squared values.
        title_suffix: Additional text for title.

    Returns:
        Plotly figure with Q-Q plot.
    """
    fig = go.Figure()

    # Sort empirical data
    sorted_data = np.sort(normalized_miss_sq)
    n = len(sorted_data)

    # Theoretical quantiles
    percentiles = (np.arange(1, n + 1) - 0.5) / n
    theoretical_quantiles = chi2.ppf(percentiles, df=3)

    # Q-Q scatter
    fig.add_trace(
        go.Scatter(
            x=theoretical_quantiles,
            y=sorted_data,
            mode="markers",
            name="Q-Q Points",
            marker={"color": "#636EFA", "size": 4, "opacity": 0.6},
        ),
    )

    # Reference line (y=x)
    max_val = max(theoretical_quantiles[-1], sorted_data[-1])
    fig.add_trace(
        go.Scatter(
            x=[0, max_val],
            y=[0, max_val],
            mode="lines",
            name="Perfect Fit (y=x)",
            line={"color": "#EF553B", "dash": "dash", "width": 2},
        ),
    )

    title = "FDOA Q-Q Plot: Empirical vs chi-sq(3) Distribution"
    if title_suffix:
        title += f" ({title_suffix})"

    fig.update_layout(
        template=DARK_TEMPLATE,
        title=title,
        xaxis_title="Theoretical Quantiles (chi-sq(3))",
        yaxis_title="Empirical Quantiles",
        xaxis={"scaleanchor": "y", "scaleratio": 1},
    )

    return fig


def plot_position_errors(position_errors: np.ndarray) -> go.Figure:
    """Plot 3D scatter of position errors.

    Args:
        position_errors: Array of position error vectors (n_trials x 3).

    Returns:
        Plotly figure with 3D scatter plot.
    """
    fig = go.Figure()

    fig.add_trace(
        go.Scatter3d(
            x=position_errors[:, 0],
            y=position_errors[:, 1],
            z=position_errors[:, 2],
            mode="markers",
            marker={
                "size": 2,
                "color": np.sqrt(np.sum(position_errors**2, axis=1)),
                "colorscale": "Viridis",
                "colorbar": {"title": "Error (m)"},
                "opacity": 0.6,
            },
            name="Position Errors",
        ),
    )

    # Add origin marker
    fig.add_trace(
        go.Scatter3d(
            x=[0],
            y=[0],
            z=[0],
            mode="markers",
            marker={"size": 8, "color": "#EF553B", "symbol": "cross"},
            name="True Position",
        ),
    )

    fig.update_layout(
        template=DARK_TEMPLATE,
        title="FDOA 3D Position Error Distribution (Fixed Velocity Mode)",
        scene={
            "xaxis_title": "X Error (m)",
            "yaxis_title": "Y Error (m)",
            "zaxis_title": "Z Error (m)",
            "aspectmode": "cube",
        },
    )

    return fig


def plot_velocity_errors(velocity_errors: np.ndarray) -> go.Figure:
    """Plot 3D scatter of velocity errors.

    Args:
        velocity_errors: Array of velocity error vectors (n_trials x 3).

    Returns:
        Plotly figure with 3D scatter plot.
    """
    fig = go.Figure()

    fig.add_trace(
        go.Scatter3d(
            x=velocity_errors[:, 0],
            y=velocity_errors[:, 1],
            z=velocity_errors[:, 2],
            mode="markers",
            marker={
                "size": 2,
                "color": np.sqrt(np.sum(velocity_errors**2, axis=1)),
                "colorscale": "Plasma",
                "colorbar": {"title": "Error (m/s)"},
                "opacity": 0.6,
            },
            name="Velocity Errors",
        ),
    )

    # Add origin marker
    fig.add_trace(
        go.Scatter3d(
            x=[0],
            y=[0],
            z=[0],
            mode="markers",
            marker={"size": 8, "color": "#EF553B", "symbol": "cross"},
            name="True Velocity",
        ),
    )

    fig.update_layout(
        template=DARK_TEMPLATE,
        title="FDOA 3D Velocity Error Distribution (Fixed Position Mode)",
        scene={
            "xaxis_title": "Vx Error (m/s)",
            "yaxis_title": "Vy Error (m/s)",
            "zaxis_title": "Vz Error (m/s)",
            "aspectmode": "cube",
        },
    )

    return fig


def plot_noise_scaling_position(
    n_trials: int = 500,
    freq_hz: float = 1e9,
    seed: int = 123,
) -> go.Figure:
    """Plot how position error scales with FDOA measurement noise.

    Uses fixed velocity (zero) mode to estimate position.

    Args:
        n_trials: Number of Monte Carlo trials per noise level.
        freq_hz: Carrier frequency in Hz.
        seed: Random seed for reproducibility.

    Returns:
        Plotly figure showing error vs noise relationship.
    """
    rng = np.random.default_rng(seed)

    collector_xyz, collector_vel, target_true, vel_true = _create_leo_geometry()
    c1_xyz, c2_xyz, c1_vel, c2_vel = _make_pairs(collector_xyz, collector_vel)
    n_pairs = len(c1_xyz)

    true_fdoa = _compute_fdoas(
        target_true,
        vel_true,
        c1_xyz,
        c2_xyz,
        c1_vel,
        c2_vel,
        freq_hz,
    )

    # Noise levels from 0.01 Hz to 1 Hz
    noise_levels = np.array([0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0])
    mean_errors = []
    std_errors = []

    for fdoa_sigma in noise_levels:
        fdoa_variances = np.full(n_pairs, fdoa_sigma**2)

        errors = []
        for _ in range(n_trials):
            noise = rng.standard_normal(n_pairs) * fdoa_sigma
            noisy_fdoa = true_fdoa + noise

            result = fdoa_locator(
                c1_xyz,
                c2_xyz,
                noisy_fdoa,
                fdoa_variances,
                freq_hz,
                collector1_vel=c1_vel,
                collector2_vel=c2_vel,
                fix_velocity=np.zeros(3),
            )
            error = np.linalg.norm(result.position - target_true)
            errors.append(error)

        mean_errors.append(np.mean(errors))
        std_errors.append(np.std(errors))

    mean_errors = np.array(mean_errors)
    std_errors = np.array(std_errors)

    fig = go.Figure()

    # Error bars
    fig.add_trace(
        go.Scatter(
            x=noise_levels,
            y=mean_errors,
            error_y={"type": "data", "array": std_errors, "visible": True},
            mode="markers+lines",
            name="Mean Position Error",
            marker={"size": 10, "color": "#636EFA"},
            line={"width": 2},
        ),
    )

    # Theoretical scaling (linear with noise)
    scale = mean_errors[3] / noise_levels[3]  # Normalize to 0.1 Hz point
    theoretical = noise_levels * scale

    fig.add_trace(
        go.Scatter(
            x=noise_levels,
            y=theoretical,
            mode="lines",
            name="Linear Scaling Reference",
            line={"color": "#EF553B", "dash": "dash", "width": 2},
        ),
    )

    fig.update_layout(
        template=DARK_TEMPLATE,
        title="Position Error vs FDOA Measurement Noise (Fixed Velocity Mode)",
        xaxis_title="FDOA Noise (Hz, 1-sigma)",
        yaxis_title="Position Error (m)",
        xaxis={"type": "log"},
        yaxis={"type": "log"},
    )

    return fig


def plot_noise_scaling_velocity(
    n_trials: int = 500,
    freq_hz: float = 1e9,
    seed: int = 123,
) -> go.Figure:
    """Plot how velocity error scales with FDOA measurement noise.

    Uses fixed position mode to estimate velocity.

    Args:
        n_trials: Number of Monte Carlo trials per noise level.
        freq_hz: Carrier frequency in Hz.
        seed: Random seed for reproducibility.

    Returns:
        Plotly figure showing error vs noise relationship.
    """
    rng = np.random.default_rng(seed)

    collector_xyz, collector_vel, target_xyz, _ = _create_leo_geometry()
    vel_true = np.array([100.0, 50.0, 10.0])

    c1_xyz, c2_xyz, c1_vel, c2_vel = _make_pairs(collector_xyz, collector_vel)
    n_pairs = len(c1_xyz)

    true_fdoa = _compute_fdoas(
        target_xyz,
        vel_true,
        c1_xyz,
        c2_xyz,
        c1_vel,
        c2_vel,
        freq_hz,
    )

    # Noise levels from 0.01 Hz to 1 Hz
    noise_levels = np.array([0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0])
    mean_errors = []
    std_errors = []

    for fdoa_sigma in noise_levels:
        fdoa_variances = np.full(n_pairs, fdoa_sigma**2)

        errors = []
        for _ in range(n_trials):
            noise = rng.standard_normal(n_pairs) * fdoa_sigma
            noisy_fdoa = true_fdoa + noise

            result = fdoa_locator(
                c1_xyz,
                c2_xyz,
                noisy_fdoa,
                fdoa_variances,
                freq_hz,
                collector1_vel=c1_vel,
                collector2_vel=c2_vel,
                fix_position=target_xyz,
            )
            error = np.linalg.norm(result.velocity - vel_true)
            errors.append(error)

        mean_errors.append(np.mean(errors))
        std_errors.append(np.std(errors))

    mean_errors = np.array(mean_errors)
    std_errors = np.array(std_errors)

    fig = go.Figure()

    # Error bars
    fig.add_trace(
        go.Scatter(
            x=noise_levels,
            y=mean_errors,
            error_y={"type": "data", "array": std_errors, "visible": True},
            mode="markers+lines",
            name="Mean Velocity Error",
            marker={"size": 10, "color": "#AB63FA"},
            line={"width": 2},
        ),
    )

    # Theoretical scaling (linear with noise)
    scale = mean_errors[3] / noise_levels[3]
    theoretical = noise_levels * scale

    fig.add_trace(
        go.Scatter(
            x=noise_levels,
            y=theoretical,
            mode="lines",
            name="Linear Scaling Reference",
            line={"color": "#EF553B", "dash": "dash", "width": 2},
        ),
    )

    fig.update_layout(
        template=DARK_TEMPLATE,
        title="Velocity Error vs FDOA Measurement Noise (Fixed Position Mode)",
        xaxis_title="FDOA Noise (Hz, 1-sigma)",
        yaxis_title="Velocity Error (m/s)",
        xaxis={"type": "log"},
        yaxis={"type": "log"},
    )

    return fig


def _create_leo_constellation(
    n_collectors: int,
    target_xyz: np.ndarray,  # noqa: ARG001
) -> tuple[np.ndarray, np.ndarray]:
    """Create LEO constellation with varied orbital inclinations.

    Uses Fibonacci sphere distribution for positions and varied inclinations
    for velocity directions, ensuring good 3D FDOA observability.

    Args:
        n_collectors: Number of satellites.
        target_xyz: Target position (unused, for API consistency).

    Returns:
        Tuple of (collector_xyz, collector_vel).
    """
    r = EARTH_RADIUS + LEO_ALTITUDE
    golden_ratio = (1 + np.sqrt(5)) / 2

    collector_xyz = np.zeros((n_collectors, 3))
    inclinations = np.linspace(0.0, 90.0, n_collectors)

    for i in range(n_collectors):
        lat = np.radians(-45 + 90 * (i + 0.5) / n_collectors)
        lon = 2 * np.pi * i / golden_ratio

        collector_xyz[i] = np.array(
            [
                r * np.cos(lat) * np.cos(lon),
                r * np.cos(lat) * np.sin(lon),
                r * np.sin(lat),
            ],
        )

    collector_vel = np.array(
        [
            _leo_velocity(pos, incl)
            for pos, incl in zip(collector_xyz, inclinations, strict=True)
        ],
    )

    return collector_xyz, collector_vel


def plot_collector_count_effect(mode: str = "position") -> go.Figure:
    """Plot how covariance trace decreases with more LEO collectors.

    Args:
        mode: Either "position" (fixed velocity) or "velocity" (fixed position).

    Returns:
        Plotly figure showing trace vs collector count.
    """
    fdoa_sigma = 0.1  # Hz
    freq_hz = 1e9

    collector_counts = [4, 5, 6, 7, 8, 10, 12]
    traces = []

    # Target on Earth surface
    target_xyz = np.array(
        [
            EARTH_RADIUS * np.cos(np.radians(30)),
            0.0,
            EARTH_RADIUS * np.sin(np.radians(30)),
        ],
    )
    target_vel = np.zeros(3) if mode == "position" else np.array([100.0, 50.0, 10.0])

    for n_collectors in collector_counts:
        collector_xyz, collector_vel = _create_leo_constellation(
            n_collectors,
            target_xyz,
        )
        c1_xyz, c2_xyz, c1_vel, c2_vel = _make_pairs(collector_xyz, collector_vel)
        n_pairs = len(c1_xyz)

        fdoa_measurements = _compute_fdoas(
            target_xyz,
            target_vel,
            c1_xyz,
            c2_xyz,
            c1_vel,
            c2_vel,
            freq_hz,
        )
        fdoa_variances = np.full(n_pairs, fdoa_sigma**2)

        if mode == "position":
            result = fdoa_locator(
                c1_xyz,
                c2_xyz,
                fdoa_measurements,
                fdoa_variances,
                freq_hz,
                collector1_vel=c1_vel,
                collector2_vel=c2_vel,
                fix_velocity=np.zeros(3),
            )
        else:
            result = fdoa_locator(
                c1_xyz,
                c2_xyz,
                fdoa_measurements,
                fdoa_variances,
                freq_hz,
                collector1_vel=c1_vel,
                collector2_vel=c2_vel,
                fix_position=target_xyz,
            )
        traces.append(np.trace(result.covariance))

    traces = np.array(traces)

    fig = go.Figure()

    unit = "m" if mode == "position" else "m/s"
    label = "Position" if mode == "position" else "Velocity"

    fig.add_trace(
        go.Scatter(
            x=collector_counts,
            y=np.sqrt(traces),
            mode="markers+lines",
            name=f"RMS {label} Uncertainty",
            marker={"size": 10, "color": "#636EFA"},
            line={"width": 2},
            hovertemplate=f"%{{x}} collectors<br>RMS: %{{y:.2f}} {unit}<extra></extra>",
        ),
    )

    # Theoretical 1/sqrt(n) scaling
    ref_idx = 0
    scale = np.sqrt(traces[ref_idx]) * np.sqrt(collector_counts[ref_idx])
    theoretical = scale / np.sqrt(collector_counts)

    fig.add_trace(
        go.Scatter(
            x=collector_counts,
            y=theoretical,
            mode="lines",
            name="1/sqrt(n) Scaling Reference",
            line={"color": "#EF553B", "dash": "dash", "width": 2},
        ),
    )

    mode_desc = "Fixed Velocity" if mode == "position" else "Fixed Position"
    fig.update_layout(
        template=DARK_TEMPLATE,
        title=f"{label} Uncertainty vs Number of Collectors ({mode_desc} Mode)",
        xaxis_title="Number of Collectors",
        yaxis_title=f"RMS {label} Uncertainty ({unit})",
        xaxis={"type": "log"},
        yaxis={"type": "log"},
    )

    return fig


def plot_inclination_effect(
    n_trials: int = 500,
    seed: int = 456,
) -> go.Figure:
    """Plot how orbital inclination diversity affects FDOA accuracy.

    Compares LEO constellations with different inclination spreads to show
    the importance of diverse velocity directions for FDOA observability.

    Args:
        n_trials: Number of Monte Carlo trials.
        seed: Random seed for reproducibility.

    Returns:
        Plotly figure showing error vs inclination diversity.
    """
    rng = np.random.default_rng(seed)
    fdoa_sigma = 0.1
    freq_hz = 1e9
    n_collectors = 6

    target_xyz = np.array(
        [
            EARTH_RADIUS * np.cos(np.radians(30)),
            0.0,
            EARTH_RADIUS * np.sin(np.radians(30)),
        ],
    )
    target_vel = np.zeros(3)

    # Different inclination spreads
    inclination_spreads = [10, 30, 50, 70, 90]  # degrees
    mean_errors = []
    std_errors = []

    r = EARTH_RADIUS + LEO_ALTITUDE
    golden_ratio = (1 + np.sqrt(5)) / 2

    for spread in inclination_spreads:
        # Create collectors with limited inclination range
        inclinations = np.linspace(45 - spread / 2, 45 + spread / 2, n_collectors)

        collector_xyz = np.zeros((n_collectors, 3))
        for i in range(n_collectors):
            lat = np.radians(-45 + 90 * (i + 0.5) / n_collectors)
            lon = 2 * np.pi * i / golden_ratio
            collector_xyz[i] = np.array(
                [
                    r * np.cos(lat) * np.cos(lon),
                    r * np.cos(lat) * np.sin(lon),
                    r * np.sin(lat),
                ],
            )

        collector_vel = np.array(
            [
                _leo_velocity(pos, incl)
                for pos, incl in zip(collector_xyz, inclinations, strict=True)
            ],
        )

        c1_xyz, c2_xyz, c1_vel, c2_vel = _make_pairs(collector_xyz, collector_vel)
        n_pairs = len(c1_xyz)

        true_fdoa = _compute_fdoas(
            target_xyz,
            target_vel,
            c1_xyz,
            c2_xyz,
            c1_vel,
            c2_vel,
            freq_hz,
        )
        fdoa_variances = np.full(n_pairs, fdoa_sigma**2)

        errors = []
        for _ in range(n_trials):
            noise = rng.standard_normal(n_pairs) * fdoa_sigma
            noisy_fdoa = true_fdoa + noise

            result = fdoa_locator(
                c1_xyz,
                c2_xyz,
                noisy_fdoa,
                fdoa_variances,
                freq_hz,
                collector1_vel=c1_vel,
                collector2_vel=c2_vel,
                fix_velocity=np.zeros(3),
            )
            errors.append(np.linalg.norm(result.position - target_xyz))

        mean_errors.append(np.mean(errors))
        std_errors.append(np.std(errors))

    fig = go.Figure()

    fig.add_trace(
        go.Scatter(
            x=inclination_spreads,
            y=mean_errors,
            error_y={"type": "data", "array": std_errors, "visible": True},
            mode="markers+lines",
            name="Mean Position Error",
            marker={"size": 10, "color": "#00CC96"},
            line={"width": 2},
        ),
    )

    fig.update_layout(
        template=DARK_TEMPLATE,
        title="Position Error vs Orbital Inclination Diversity",
        xaxis_title="Inclination Spread (degrees)",
        yaxis_title="Position Error (m)",
    )

    return fig


def plot_dashboard(n_trials: int = 1000) -> go.Figure:
    """Create a comprehensive dashboard with all Monte Carlo visualizations.

    Args:
        n_trials: Number of Monte Carlo trials.

    Returns:
        Plotly figure with subplots.
    """
    # Run Monte Carlo for both modes
    nms_pos, pos_errors, _ = run_monte_carlo_fixed_velocity(n_trials=n_trials)
    nms_vel, vel_errors, _ = run_monte_carlo_fixed_position(n_trials=n_trials)

    # Create subplots
    fig = make_subplots(
        rows=2,
        cols=2,
        subplot_titles=[
            "chi-sq Validation (Fixed Velocity)",
            "chi-sq Validation (Fixed Position)",
            "Position Errors YZ (Fixed Velocity)",
            "Velocity Errors YZ (Fixed Position)",
        ],
        specs=[
            [{"type": "xy"}, {"type": "xy"}],
            [{"type": "xy"}, {"type": "xy"}],
        ],
        vertical_spacing=0.12,
        horizontal_spacing=0.1,
    )

    # --- Subplot 1: Fixed velocity histogram ---
    x_theory = np.linspace(0, 15, 200)
    pdf_theory = chi2.pdf(x_theory, df=3)

    fig.add_trace(
        go.Histogram(
            x=nms_pos,
            nbinsx=40,
            histnorm="probability density",
            name="Empirical (Pos)",
            marker_color="rgba(99, 110, 250, 0.7)",
            showlegend=True,
        ),
        row=1,
        col=1,
    )
    fig.add_trace(
        go.Scatter(
            x=x_theory,
            y=pdf_theory,
            mode="lines",
            name="chi-sq(3)",
            line={"color": "#EF553B", "width": 2},
        ),
        row=1,
        col=1,
    )

    # --- Subplot 2: Fixed position histogram ---
    fig.add_trace(
        go.Histogram(
            x=nms_vel,
            nbinsx=40,
            histnorm="probability density",
            name="Empirical (Vel)",
            marker_color="rgba(171, 99, 250, 0.7)",
            showlegend=True,
        ),
        row=1,
        col=2,
    )
    fig.add_trace(
        go.Scatter(
            x=x_theory,
            y=pdf_theory,
            mode="lines",
            name="chi-sq(3)",
            line={"color": "#EF553B", "width": 2},
            showlegend=False,
        ),
        row=1,
        col=2,
    )

    # --- Subplot 3: Position errors YZ view ---
    fig.add_trace(
        go.Scatter(
            x=pos_errors[:, 1],
            y=pos_errors[:, 2],
            mode="markers",
            name="Pos Errors",
            marker={
                "color": np.sqrt(np.sum(pos_errors**2, axis=1)),
                "colorscale": "Viridis",
                "size": 3,
                "opacity": 0.5,
            },
            showlegend=False,
        ),
        row=2,
        col=1,
    )
    fig.add_trace(
        go.Scatter(
            x=[0],
            y=[0],
            mode="markers",
            marker={"color": "#EF553B", "size": 10, "symbol": "cross"},
            showlegend=False,
        ),
        row=2,
        col=1,
    )

    # --- Subplot 4: Velocity errors YZ view ---
    fig.add_trace(
        go.Scatter(
            x=vel_errors[:, 1],
            y=vel_errors[:, 2],
            mode="markers",
            name="Vel Errors",
            marker={
                "color": np.sqrt(np.sum(vel_errors**2, axis=1)),
                "colorscale": "Plasma",
                "size": 3,
                "opacity": 0.5,
            },
            showlegend=False,
        ),
        row=2,
        col=2,
    )
    fig.add_trace(
        go.Scatter(
            x=[0],
            y=[0],
            mode="markers",
            marker={"color": "#EF553B", "size": 10, "symbol": "cross"},
            showlegend=False,
        ),
        row=2,
        col=2,
    )

    # Statistics text
    mean_pos = np.mean(nms_pos)
    _, p_pos = kstest(nms_pos, chi2(3).cdf)
    mean_vel = np.mean(nms_vel)
    _, p_vel = kstest(nms_vel, chi2(3).cdf)

    fig.add_annotation(
        x=0.5,
        y=0.52,
        xref="paper",
        yref="paper",
        text=(
            f"<b>Position Mode:</b> mu={mean_pos:.2f}, KS p={p_pos:.3f} | "
            f"<b>Velocity Mode:</b> mu={mean_vel:.2f}, KS p={p_vel:.3f}"
        ),
        showarrow=False,
        font={"size": 11},
        bgcolor="rgba(0,0,0,0.5)",
    )

    # Update axes labels
    fig.update_xaxes(title_text="d^2", row=1, col=1)
    fig.update_yaxes(title_text="Density", row=1, col=1)
    fig.update_xaxes(title_text="d^2", row=1, col=2)
    fig.update_yaxes(title_text="Density", row=1, col=2)
    fig.update_xaxes(title_text="Y Error (m)", row=2, col=1)
    fig.update_yaxes(title_text="Z Error (m)", row=2, col=1)
    fig.update_xaxes(title_text="Vy Error (m/s)", row=2, col=2)
    fig.update_yaxes(title_text="Vz Error (m/s)", row=2, col=2)

    fig.update_layout(
        template=DARK_TEMPLATE,
        title=f"FDOA Monte Carlo Validation Dashboard (n={n_trials})",
        height=800,
        showlegend=True,
        legend={"x": 0.4, "y": 0.55},
    )

    return fig


def main() -> None:
    """Run all visualizations."""
    print("=" * 60)
    print("FDOA Monte Carlo Validation")
    print("=" * 60)

    # Fixed velocity mode (position estimation)
    print("\n1. FIXED VELOCITY MODE (estimate position)")
    print("-" * 40)
    nms_pos, pos_errors, _ = run_monte_carlo_fixed_velocity(n_trials=1000)

    mean_d_sq = np.mean(nms_pos)
    _, p_value = kstest(nms_pos, chi2(3).cdf)
    mean_error = np.mean(np.linalg.norm(pos_errors, axis=1))

    print(f"  Mean d^2: {mean_d_sq:.3f} (theory: 3.0)")
    print(f"  KS p-value: {p_value:.4f}")
    print(f"  Mean position error: {mean_error:.1f} m")

    # Fixed position mode (velocity estimation)
    print("\n2. FIXED POSITION MODE (estimate velocity)")
    print("-" * 40)
    nms_vel, vel_errors, _ = run_monte_carlo_fixed_position(n_trials=1000)

    mean_d_sq = np.mean(nms_vel)
    _, p_value = kstest(nms_vel, chi2(3).cdf)
    mean_error = np.mean(np.linalg.norm(vel_errors, axis=1))

    print(f"  Mean d^2: {mean_d_sq:.3f} (theory: 3.0)")
    print(f"  KS p-value: {p_value:.4f}")
    print(f"  Mean velocity error: {mean_error:.4f} m/s")

    print("\nGenerating plots...")

    # Individual plots - Fixed velocity mode
    fig1 = plot_chi2_histogram(nms_pos, "Fixed Velocity")
    fig1.show()

    fig2 = plot_qq(nms_pos, "Fixed Velocity")
    fig2.show()

    fig3 = plot_position_errors(pos_errors)
    fig3.show()

    # Individual plots - Fixed position mode
    fig4 = plot_chi2_histogram(nms_vel, "Fixed Position")
    fig4.show()

    fig5 = plot_qq(nms_vel, "Fixed Position")
    fig5.show()

    fig6 = plot_velocity_errors(vel_errors)
    fig6.show()

    # Noise scaling
    fig7 = plot_noise_scaling_position()
    fig7.show()

    fig8 = plot_noise_scaling_velocity()
    fig8.show()

    # Collector count effect
    fig9 = plot_collector_count_effect(mode="position")
    fig9.show()

    fig10 = plot_collector_count_effect(mode="velocity")
    fig10.show()

    # Inclination diversity effect
    fig11 = plot_inclination_effect()
    fig11.show()

    # Dashboard
    fig_dashboard = plot_dashboard(n_trials=1000)
    fig_dashboard.show()


if __name__ == "__main__":
    main()
