"""Signal quality assessment - SNR, clipping, saturation, and quality metrics.

Provides the ``assess_signal_quality`` public API and supporting helpers for
evaluating signal quality indicators that affect measurement accuracy.

References:
    IEEE 1057-2017: Standard for Digitizing Waveform Recorders
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

import numpy as np
from numpy.typing import NDArray

from oscura.inference.signal_intelligence.classification import classify_signal
from oscura.inference.signal_intelligence.detection import _estimate_noise_level

if TYPE_CHECKING:
    from oscura.core.types import WaveformTrace


def _create_empty_quality_result(warnings: list[str]) -> dict[str, Any]:
    """Create quality result dict for insufficient data case.

    Args:
        warnings: List of warning messages.

    Returns:
        Quality result dictionary with null values.
    """
    return {
        "snr": None,
        "noise_level": 0.0,
        "clipping": False,
        "saturation": False,
        "warnings": warnings,
        "dynamic_range": None,
        "crest_factor": None,
    }


def _calculate_basic_stats(data: NDArray[np.floating[Any]]) -> dict[str, float]:
    """Calculate basic signal statistics.

    Args:
        data: Signal data array.

    Returns:
        Dict with min, max, mean, rms, amplitude values.
    """
    return {
        "min": float(np.min(data)),
        "max": float(np.max(data)),
        "mean": float(np.mean(data)),
        "rms": float(np.sqrt(np.mean(data**2))),
        "amplitude": float(np.max(data) - np.min(data)),
    }


def _detect_clipping(
    data: NDArray[np.floating[Any]], n: int, stats: dict[str, float]
) -> tuple[bool, list[str]]:
    """Detect signal clipping at extremes.

    Args:
        data: Signal data array.
        n: Number of samples.
        stats: Basic statistics dict.

    Returns:
        Tuple of (clipping_detected, warning_messages).
    """
    warnings: list[str] = []
    amplitude = stats["amplitude"]

    if amplitude <= 1e-9:
        return False, warnings

    tolerance = amplitude * 0.01
    at_min = data <= (stats["min"] + tolerance)
    at_max = data >= (stats["max"] - tolerance)
    min_run_length = max(int(n * 0.15), 100)

    max_min_run, max_max_run = _find_max_consecutive_runs(at_min, at_max, n)

    clipping = False
    if max_min_run >= min_run_length:
        clipping = True
        warnings.append(f"Signal clipping detected at minimum ({max_min_run} consecutive samples)")
    if max_max_run >= min_run_length:
        clipping = True
        warnings.append(f"Signal clipping detected at maximum ({max_max_run} consecutive samples)")

    return clipping, warnings


def _find_max_consecutive_runs(
    at_min: NDArray[np.bool_], at_max: NDArray[np.bool_], n: int
) -> tuple[int, int]:
    """Find maximum consecutive run lengths at min and max extremes.

    Args:
        at_min: Boolean array indicating samples at minimum.
        at_max: Boolean array indicating samples at maximum.
        n: Number of samples.

    Returns:
        Tuple of (max_min_run, max_max_run).
    """
    max_min_run = 0
    max_max_run = 0
    current_min_run = 0
    current_max_run = 0

    for i in range(n):
        if at_min[i]:
            current_min_run += 1
            max_min_run = max(max_min_run, current_min_run)
        else:
            current_min_run = 0

        if at_max[i]:
            current_max_run += 1
            max_max_run = max(max_max_run, current_max_run)
        else:
            current_max_run = 0

    return max_min_run, max_max_run


def _detect_saturation(
    data: NDArray[np.floating[Any]], trace: WaveformTrace, n: int
) -> tuple[bool, str | None]:
    """Detect signal saturation (stuck at one level).

    Args:
        data: Signal data array.
        trace: Waveform trace for classification.
        n: Number of samples.

    Returns:
        Tuple of (saturation_detected, warning_message).
    """
    unique_values = len(np.unique(data))
    classification = classify_signal(trace)

    if classification["type"] == "digital":
        if unique_values < 2:
            return True, (f"Signal saturation detected (only {unique_values} unique value)")
    else:
        if unique_values < max(10, n // 1000):
            return True, (f"Signal saturation detected (only {unique_values} unique values)")

    return False, None


def _calculate_snr(stats: dict[str, float], noise_level: float) -> float | None:
    """Calculate signal-to-noise ratio.

    Args:
        stats: Basic statistics dict.
        noise_level: Estimated noise level.

    Returns:
        SNR in dB or None if not calculable.
    """
    amplitude = stats["amplitude"]

    if amplitude <= noise_level * 10:
        return None

    signal_power = amplitude**2 / 8
    noise_power = noise_level**2

    if noise_power > 1e-20:
        return float(10 * np.log10(signal_power / noise_power))

    return float("inf")


def _calculate_dynamic_range(stats: dict[str, float]) -> float | None:
    """Calculate signal dynamic range.

    Args:
        stats: Basic statistics dict.

    Returns:
        Dynamic range in dB or None.
    """
    min_val = stats["min"]
    max_val = stats["max"]

    if min_val != 0 and max_val != 0 and max_val > 1e-20:
        with np.errstate(invalid="ignore", divide="ignore"):
            ratio = max_val / (abs(min_val) + 1e-20)
            if ratio > 0 and np.isfinite(ratio):
                return float(20 * np.log10(ratio))

    return None


def _calculate_crest_factor(stats: dict[str, float]) -> float | None:
    """Calculate crest factor (peak-to-RMS ratio).

    Args:
        stats: Basic statistics dict.

    Returns:
        Crest factor or None.
    """
    rms_val = stats["rms"]
    if rms_val > 1e-12:
        return float(max(abs(stats["max"]), abs(stats["min"])) / rms_val)
    return None


def _check_quantization(
    data: NDArray[np.floating[Any]], n: int, stats: dict[str, float]
) -> str | None:
    """Check for quantization issues.

    Args:
        data: Signal data array.
        n: Number of samples.
        stats: Basic statistics dict.

    Returns:
        Warning message or None.
    """
    if n <= 100:
        return None

    sorted_data = np.sort(data)
    diffs = np.diff(sorted_data)
    diffs = diffs[diffs > 1e-15]

    if len(diffs) > 10:
        min_step = float(np.min(diffs))
        amplitude = stats["amplitude"]
        if amplitude / min_step < 256:
            return (
                f"Low resolution detected ({int(amplitude / min_step)} "
                "levels), may affect measurement accuracy"
            )

    return None


def _check_quality_sample_rate(trace: WaveformTrace) -> list[str]:
    """Check if sample rate is adequate for signal frequency.

    Uses both frequency estimation (when available) and spectral energy
    analysis near Nyquist to detect undersampling even when frequency
    detection fails due to aliasing.

    Args:
        trace: Waveform trace with metadata.

    Returns:
        List of warning messages.
    """
    warnings: list[str] = []
    classification = classify_signal(trace)
    sample_rate = trace.metadata.sample_rate

    if classification["frequency_estimate"] is not None:
        freq = classification["frequency_estimate"]
        nyquist_rate = 2 * freq

        if sample_rate < nyquist_rate * 10:
            warnings.append(
                f"Sample rate ({sample_rate:.3e} Hz) may be insufficient "
                f"for signal frequency ({freq:.3e} Hz). Recommend at least "
                "10x oversampling"
            )

        samples_per_period = sample_rate / freq
        if samples_per_period < 10 and "sample rate" not in "".join(warnings).lower():
            warnings.append(
                f"Very low oversampling detected "
                f"({samples_per_period:.1f} samples per period). "
                "Signal may be undersampled or frequency detection may be "
                "inaccurate. Recommend at least 10 samples per period"
            )
    else:
        # Frequency estimation failed - check if signal has significant
        # energy near Nyquist which suggests undersampling/aliasing
        data = trace.data
        n = len(data)
        if n >= 64:
            data_ac = data - np.mean(data)
            data_std = float(np.std(data_ac))
            if data_std > 1e-12:
                fft_result = np.fft.rfft(data_ac)
                power = np.abs(fft_result) ** 2
                total_power = float(np.sum(power[1:]))

                if total_power > 1e-20:
                    # Check energy in top 30% of frequency range
                    # (near Nyquist)
                    n_bins = len(power) - 1  # exclude DC
                    high_freq_start = max(1, int(n_bins * 0.7))
                    high_freq_power = float(np.sum(power[high_freq_start:]))
                    high_freq_ratio = high_freq_power / total_power

                    # If >40% of energy is near Nyquist, likely
                    # undersampled
                    if high_freq_ratio > 0.4:
                        warnings.append(
                            f"Sample rate ({sample_rate:.3e} Hz) may be "
                            "insufficient - significant spectral energy "
                            "detected near Nyquist frequency. Signal may "
                            "be undersampled or aliased. Recommend "
                            "increasing sample rate"
                        )

    return warnings


def assess_signal_quality(
    trace: WaveformTrace,
) -> dict[str, Any]:
    """Assess signal quality metrics.

    Analyzes signal quality including SNR, noise level, clipping, saturation,
    and other quality indicators that affect measurement accuracy.

    Args:
        trace: Input waveform trace to assess.

    Returns:
        Dictionary containing:
        - snr: Signal-to-noise ratio in dB (or None if not applicable)
        - noise_level: RMS noise level in signal units
        - clipping: True if signal shows clipping
        - saturation: True if signal appears saturated
        - warnings: List of quality warning strings
        - dynamic_range: Signal dynamic range in dB
        - crest_factor: Peak-to-RMS ratio

    Example:
        >>> trace = osc.load('noisy_sine.wfm')
        >>> quality = osc.assess_signal_quality(trace)
        >>> print(f"SNR: {quality['snr']:.1f} dB")
        SNR: 42.3 dB
        >>> if quality['warnings']:
        ...     print(f"Warnings: {quality['warnings']}")

    References:
        IEEE 1057-2017: ADC quality metrics
    """
    data = trace.data
    n = len(data)
    warnings: list[str] = []

    if n < 10:
        warnings.append("Insufficient data for quality assessment")
        return _create_empty_quality_result(warnings)

    stats = _calculate_basic_stats(data)
    clipping, clipping_warnings = _detect_clipping(data, n, stats)
    warnings.extend(clipping_warnings)

    saturation, saturation_warning = _detect_saturation(data, trace, n)
    if saturation_warning:
        warnings.append(saturation_warning)

    noise_level = _estimate_noise_level(data)
    snr = _calculate_snr(stats, noise_level)
    dynamic_range = _calculate_dynamic_range(stats)
    crest_factor = _calculate_crest_factor(stats)

    quantization_warning = _check_quantization(data, n, stats)
    if quantization_warning:
        warnings.append(quantization_warning)

    sample_rate_warnings = _check_quality_sample_rate(trace)
    warnings.extend(sample_rate_warnings)

    return {
        "snr": float(snr) if snr is not None else None,
        "noise_level": float(noise_level),
        "clipping": bool(clipping),
        "saturation": bool(saturation),
        "warnings": warnings,
        "dynamic_range": (float(dynamic_range) if dynamic_range is not None else None),
        "crest_factor": (float(crest_factor) if crest_factor is not None else None),
    }
