"""
Lab024B. Усреднённый спектр Pluto+ методом Уэлча.

Схема подключения:
    антенна 40–860 МГц -> RX1

Передатчики TX1 и TX2 не используются.
"""

from pathlib import Path
import csv

import adi
import matplotlib.pyplot as plt
import numpy as np
from scipy.signal import find_peaks, welch


# ---------------------------------------------------------------------
# Настройки Pluto+
# ---------------------------------------------------------------------

PLUTO_URI = "ip:192.168.2.1"

CENTER_FREQUENCY_HZ = 100_000_000
SAMPLE_RATE_HZ = 2_400_000
RX_BANDWIDTH_HZ = 2_000_000

RX_BUFFER_SIZE = 65_536
BUFFER_COUNT = 16
DISCARD_BUFFER_COUNT = 2


# ---------------------------------------------------------------------
# Настройки спектрального анализа
# ---------------------------------------------------------------------

WELCH_SEGMENT_LENGTH = 8_192
WELCH_OVERLAP_LENGTH = 4_096

SMOOTHING_BANDWIDTH_HZ = 40_000

MINIMUM_PEAK_DISTANCE_HZ = 120_000
MINIMUM_PEAK_PROMINENCE_DB = 4.0
MINIMUM_PEAK_HEIGHT_DB = -25.0

DC_EXCLUSION_HALF_WIDTH_HZ = 40_000

MAXIMUM_CANDIDATE_COUNT = 12


# ---------------------------------------------------------------------
# Выходные файлы
# ---------------------------------------------------------------------

OUTPUT_DIRECTORY = Path("data/processed/lab024b")

SPECTRUM_CSV_PATH = OUTPUT_DIRECTORY / "lab024b_welch_spectrum.csv"
CANDIDATES_CSV_PATH = OUTPUT_DIRECTORY / "lab024b_fm_candidates.csv"
GRAPH_PATH = OUTPUT_DIRECTORY / "lab024b_welch_spectrum.png"
REPORT_PATH = OUTPUT_DIRECTORY / "lab024b_report.txt"


def configure_receiver() -> adi.Pluto:
    """
    Подключается к Pluto+ и настраивает приёмник RX1.
    """

    print("Подключение к Pluto+...")

    sdr = adi.Pluto(uri=PLUTO_URI)

    # Используем первый приёмный канал.
    sdr.rx_enabled_channels = [0]

    sdr.sample_rate = SAMPLE_RATE_HZ
    sdr.rx_lo = CENTER_FREQUENCY_HZ
    sdr.rx_rf_bandwidth = RX_BANDWIDTH_HZ

    # Автоматическая регулировка усиления.
    sdr.gain_control_mode_chan0 = "slow_attack"

    sdr.rx_buffer_size = RX_BUFFER_SIZE

    return sdr


def receive_samples(sdr: adi.Pluto) -> np.ndarray:
    """
    Получает несколько последовательных IQ-буферов.

    Первые буферы отбрасываются, поскольку сразу после настройки
    приёмника могут наблюдаться переходные процессы АРУ и фильтров.
    """

    print()
    print("Отбрасывание переходных буферов...")

    for _ in range(DISCARD_BUFFER_COUNT):
        _ = sdr.rx()

    received_buffers: list[np.ndarray] = []

    print("Получение рабочих IQ-буферов...")

    for buffer_number in range(1, BUFFER_COUNT + 1):
        samples = np.asarray(sdr.rx(), dtype=np.complex64)
        received_buffers.append(samples)

        print(
            f"  Буфер {buffer_number:02d}/{BUFFER_COUNT}: "
            f"{len(samples)} отсчётов"
        )

    combined_samples = np.concatenate(received_buffers)

    if len(combined_samples) == 0:
        raise RuntimeError("Pluto+ не вернул IQ-сэмплы.")

    if not np.all(np.isfinite(combined_samples)):
        raise RuntimeError("В IQ-буфере обнаружены NaN или Inf.")

    return combined_samples


def calculate_welch_spectrum(
    samples: np.ndarray,
    sample_rate_hz: float,
    center_frequency_hz: float,
) -> tuple[np.ndarray, np.ndarray]:
    """
    Рассчитывает спектральную плотность мощности методом Уэлча.

    Возвращает:
        absolute_frequencies_hz — абсолютные радиочастоты;
        power_density           — мощность в линейном масштабе.
    """

    baseband_frequencies_hz, power_density = welch(
        samples,
        fs=sample_rate_hz,
        window="hann",
        nperseg=WELCH_SEGMENT_LENGTH,
        noverlap=WELCH_OVERLAP_LENGTH,
        detrend=False,
        return_onesided=False,
        scaling="density",
    )

    baseband_frequencies_hz = np.fft.fftshift(
        baseband_frequencies_hz
    )

    power_density = np.fft.fftshift(power_density)

    absolute_frequencies_hz = (
        center_frequency_hz + baseband_frequencies_hz
    )

    return absolute_frequencies_hz, power_density


def smooth_power_spectrum(
    power_density: np.ndarray,
    frequency_step_hz: float,
) -> np.ndarray:
    """
    Сглаживает спектр скользящим средним в линейном масштабе.

    Усреднять следует мощность, а не значения в децибелах.
    """

    smoothing_bin_count = int(
        round(SMOOTHING_BANDWIDTH_HZ / frequency_step_hz)
    )

    smoothing_bin_count = max(1, smoothing_bin_count)

    # Нечётная длина ядра обеспечивает симметрию относительно центра.
    if smoothing_bin_count % 2 == 0:
        smoothing_bin_count += 1

    kernel = (
        np.ones(smoothing_bin_count, dtype=np.float64)
        / smoothing_bin_count
    )

    smoothed_power = np.convolve(
        power_density,
        kernel,
        mode="same",
    )

    return smoothed_power


def convert_power_to_relative_db(
    raw_power: np.ndarray,
    smoothed_power: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
    """
    Переводит мощность в относительные децибелы.

    0 дБ соответствует максимальному значению сглаженного спектра.
    """

    minimum_positive_value = np.finfo(np.float64).tiny

    raw_power_db = 10.0 * np.log10(
        raw_power + minimum_positive_value
    )

    smoothed_power_db = 10.0 * np.log10(
        smoothed_power + minimum_positive_value
    )

    reference_power_db = np.max(smoothed_power_db)

    raw_relative_db = raw_power_db - reference_power_db
    smoothed_relative_db = smoothed_power_db - reference_power_db

    return raw_relative_db, smoothed_relative_db


def detect_signal_candidates(
    frequencies_hz: np.ndarray,
    smoothed_power_db: np.ndarray,
    center_frequency_hz: float,
) -> list[dict[str, float]]:
    """
    Ищет локальные максимумы сглаженного спектра.

    Максимумы сначала рассчитываются по исходному спектру.
    После этого отбрасываются точки из DC-зоны и с краёв диапазона.

    Такой порядок не создаёт искусственных провалов -200 дБ
    и не искажает значение prominence.
    """

    frequency_step_hz = float(
        np.mean(np.diff(frequencies_hz))
    )

    minimum_distance_bins = max(
        1,
        int(
            round(
                MINIMUM_PEAK_DISTANCE_HZ
                / abs(frequency_step_hz)
            )
        ),
    )

    edge_bin_count = max(
        1,
        int(
            round(
                SMOOTHING_BANDWIDTH_HZ
                / abs(frequency_step_hz)
            )
        ),
    )

    # Важно: find_peaks получает настоящий спектр без вставок -200 дБ.
    peak_indices, peak_properties = find_peaks(
        smoothed_power_db,
        height=MINIMUM_PEAK_HEIGHT_DB,
        prominence=MINIMUM_PEAK_PROMINENCE_DB,
        distance=minimum_distance_bins,
    )

    candidates: list[dict[str, float]] = []

    for peak_number, peak_index in enumerate(peak_indices):
        frequency_hz = float(frequencies_hz[peak_index])

        # Отбрасываем возможный аппаратный DC-пик.
        if (
            abs(frequency_hz - center_frequency_hz)
            <= DC_EXCLUSION_HALF_WIDTH_HZ
        ):
            continue

        # Отбрасываем максимумы возле краёв наблюдаемого диапазона.
        if peak_index < edge_bin_count:
            continue

        if peak_index >= len(smoothed_power_db) - edge_bin_count:
            continue

        candidates.append(
            {
                "frequency_hz": frequency_hz,
                "relative_power_db": float(
                    smoothed_power_db[peak_index]
                ),
                "prominence_db": float(
                    peak_properties["prominences"][peak_number]
                ),
            }
        )

    # Оставляем наиболее выраженные сигналы.
    candidates.sort(
        key=lambda item: item["prominence_db"],
        reverse=True,
    )

    candidates = candidates[:MAXIMUM_CANDIDATE_COUNT]

    # В итоговой таблице располагаем сигналы по частоте.
    candidates.sort(
        key=lambda item: item["frequency_hz"]
    )

    return candidates


def save_spectrum_csv(
    frequencies_hz: np.ndarray,
    raw_power_db: np.ndarray,
    smoothed_power_db: np.ndarray,
) -> None:
    """
    Сохраняет полный рассчитанный спектр в CSV-файл.

    Для каждой частотной точки сохраняются:
    - частота в герцах;
    - частота в мегагерцах;
    - исходная относительная мощность;
    - сглаженная относительная мощность.
    """

    with SPECTRUM_CSV_PATH.open(
        "w",
        encoding="utf-8",
        newline="",
    ) as csv_file:
        writer = csv.writer(csv_file)

        writer.writerow(
            [
                "frequency_hz",
                "frequency_mhz",
                "raw_relative_power_db",
                "smoothed_relative_power_db",
            ]
        )

        for frequency_hz, raw_db, smoothed_db in zip(
            frequencies_hz,
            raw_power_db,
            smoothed_power_db,
        ):
            writer.writerow(
                [
                    f"{frequency_hz:.3f}",
                    f"{frequency_hz / 1e6:.6f}",
                    f"{raw_db:.6f}",
                    f"{smoothed_db:.6f}",
                ]
            )


def save_candidates_csv(
    candidates: list[dict[str, float]],
) -> None:
    """
    Сохраняет найденные кандидаты на радиосигналы.
    """

    with CANDIDATES_CSV_PATH.open(
        "w",
        encoding="utf-8",
        newline="",
    ) as csv_file:
        writer = csv.writer(csv_file)

        writer.writerow(
            [
                "frequency_hz",
                "frequency_mhz",
                "relative_power_db",
                "prominence_db",
            ]
        )

        for candidate in candidates:
            writer.writerow(
                [
                    f"{candidate['frequency_hz']:.3f}",
                    f"{candidate['frequency_hz'] / 1e6:.6f}",
                    f"{candidate['relative_power_db']:.3f}",
                    f"{candidate['prominence_db']:.3f}",
                ]
            )


def save_report(
    sample_count: int,
    candidates: list[dict[str, float]],
) -> None:
    """
    Создаёт текстовый отчёт лабораторной.
    """

    report_lines = [
        "Lab024B. Усреднённый спектр Pluto+ методом Уэлча",
        "",
        f"URI: {PLUTO_URI}",
        f"Центральная частота: {CENTER_FREQUENCY_HZ / 1e6:.3f} МГц",
        f"Частота дискретизации: {SAMPLE_RATE_HZ / 1e6:.3f} Мвыб/с",
        f"Полоса RX: {RX_BANDWIDTH_HZ / 1e6:.3f} МГц",
        f"Количество IQ-сэмплов: {sample_count}",
        f"Размер сегмента Уэлча: {WELCH_SEGMENT_LENGTH}",
        f"Перекрытие сегментов: {WELCH_OVERLAP_LENGTH}",
        "",
        f"Найдено кандидатов: {len(candidates)}",
        "",
    ]

    for candidate_number, candidate in enumerate(
        candidates,
        start=1,
    ):
        report_lines.append(
            f"{candidate_number:02d}. "
            f"{candidate['frequency_hz'] / 1e6:.6f} МГц, "
            f"уровень {candidate['relative_power_db']:.2f} дБ, "
            f"выраженность {candidate['prominence_db']:.2f} дБ"
        )

    REPORT_PATH.write_text(
        "\n".join(report_lines),
        encoding="utf-8",
    )


def create_graph(
    frequencies_hz: np.ndarray,
    raw_power_db: np.ndarray,
    smoothed_power_db: np.ndarray,
    candidates: list[dict[str, float]],
) -> None:
    """
    Строит исходный и сглаженный спектры.
    """

    plt.figure(figsize=(13, 7))

    plt.plot(
        frequencies_hz / 1e6,
        raw_power_db,
        linewidth=0.6,
        alpha=0.45,
        label="Спектр Уэлча",
    )

    plt.plot(
        frequencies_hz / 1e6,
        smoothed_power_db,
        linewidth=1.5,
        label="Сглаженный спектр",
    )

    for candidate in candidates:
        frequency_mhz = candidate["frequency_hz"] / 1e6
        power_db = candidate["relative_power_db"]

        plt.scatter(
            [frequency_mhz],
            [power_db],
            marker="o",
        )

        plt.annotate(
            f"{frequency_mhz:.3f}",
            xy=(frequency_mhz, power_db),
            xytext=(0, 10),
            textcoords="offset points",
            ha="center",
            fontsize=8,
            rotation=45,
        )

    plt.axvspan(
        (CENTER_FREQUENCY_HZ - DC_EXCLUSION_HALF_WIDTH_HZ) / 1e6,
        (CENTER_FREQUENCY_HZ + DC_EXCLUSION_HALF_WIDTH_HZ) / 1e6,
        alpha=0.15,
        label="Исключённая DC-зона",
    )

    plt.title(
        "Lab024B. Усреднённый спектр Pluto+ и кандидаты на FM-сигналы"
    )
    plt.xlabel("Частота, МГц")
    plt.ylabel("Относительная мощность, дБ")
    plt.grid(True)
    plt.ylim(-60, 5)
    plt.legend()
    plt.tight_layout()

    plt.savefig(GRAPH_PATH, dpi=160)
    plt.show()


def main() -> None:
    OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)

    sdr = configure_receiver()

    print()
    print("Параметры приёмника:")
    print(f"  URI:                   {PLUTO_URI}")
    print(
        f"  Центральная частота:   "
        f"{sdr.rx_lo / 1e6:.3f} МГц"
    )
    print(
        f"  Частота дискретизации: "
        f"{sdr.sample_rate / 1e6:.3f} Мвыб/с"
    )
    print(
        f"  Полоса RX:             "
        f"{sdr.rx_rf_bandwidth / 1e6:.3f} МГц"
    )
    print(
        f"  Режим усиления:        "
        f"{sdr.gain_control_mode_chan0}"
    )

    samples = receive_samples(sdr)

    print()
    print(f"Всего получено: {len(samples)} IQ-сэмплов")

    frequencies_hz, power_density = calculate_welch_spectrum(
        samples=samples,
        sample_rate_hz=float(sdr.sample_rate),
        center_frequency_hz=float(sdr.rx_lo),
    )

    frequency_step_hz = float(
        np.mean(np.diff(frequencies_hz))
    )

    smoothed_power = smooth_power_spectrum(
        power_density=power_density,
        frequency_step_hz=frequency_step_hz,
    )

    raw_power_db, smoothed_power_db = (
        convert_power_to_relative_db(
            raw_power=power_density,
            smoothed_power=smoothed_power,
        )
    )

    candidates = detect_signal_candidates(
        frequencies_hz=frequencies_hz,
        smoothed_power_db=smoothed_power_db,
        center_frequency_hz=float(sdr.rx_lo),
    )

    save_spectrum_csv(
        frequencies_hz=frequencies_hz,
        raw_power_db=raw_power_db,
        smoothed_power_db=smoothed_power_db,
    )

    save_candidates_csv(candidates)
    save_report(len(samples), candidates)

    print()
    print("Найденные кандидаты на радиосигналы:")
    print()

    if candidates:
        print(
            "  №   Частота, МГц   Уровень, дБ   "
            "Выраженность, дБ"
        )
        print(
            "  --  ------------   -----------   "
            "-----------------"
        )

        for candidate_number, candidate in enumerate(
            candidates,
            start=1,
        ):
            print(
                f"  {candidate_number:2d}  "
                f"{candidate['frequency_hz'] / 1e6:12.6f}   "
                f"{candidate['relative_power_db']:11.2f}   "
                f"{candidate['prominence_db']:17.2f}"
            )
    else:
        print("  Кандидаты не найдены.")

    create_graph(
        frequencies_hz=frequencies_hz,
        raw_power_db=raw_power_db,
        smoothed_power_db=smoothed_power_db,
        candidates=candidates,
    )

    print()
    print("Созданы файлы:")
    print(f"  Спектр:      {SPECTRUM_CSV_PATH}")
    print(f"  Кандидаты:   {CANDIDATES_CSV_PATH}")
    print(f"  График:      {GRAPH_PATH}")
    print(f"  Отчёт:       {REPORT_PATH}")

    print()
    print("Lab024B выполнена успешно.")


if __name__ == "__main__":
    main()
