#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
================================================================================
  Targeting Humans — RF capture helper (PSD + envelope + spectrogram)
================================================================================

  ZWECK / PURPOSE
  ----------------
  Dieses EINZEL-Skript ersetzt zwei frühere Varianten und führt sie zusammen:

    (A) Kurze FFT über IQ → grobe Leistungsdichte (PSD) um die Mittenfrequenz
    (B) Betrag der IQ-Samples (= instantane Amplitude / Hüllkurve) → FFT davon
        → zeigt, welche *Modulationsfrequenzen* im Niederfrequenzbereich im Signal
            stecken (z. B. kHz-Bereich), sofern sie im erfassten Zeitfenster
            stabil genug sind.

  WICHTIG / IMPORTANT
  -------------------
  • Nur empfangen, wenn ihr dazu berechtigt seid (Genehmigungen, Ortsrecht).
  • RF-Sicherheit: keine ungeeigneten Antennen an Leistungs-PEP anschließen.
  • Das Skript ist LEHR-/DEMONSTRATIONSZWECK — keine Garantie auf Messgenauigkeit.

  ABHÄNGIGKEITEN / DEPENDENCIES
  ------------------------------
    pip install numpy matplotlib SoapySDR

  Zusätzlich: passender SoapySDR-Modul-Treiber für euer Gerät, z. B.:
    • HackRF: SoapyHackRF + libhackrf
    • Andere SDRs: jeweils deren Soapy-Plugin installieren und --driver anpassen

  BEISPIEL / EXAMPLE
  ------------------
    python targeting-humans_rf_analysis.py --freq-hz 780e6 --rate 2e6 --mode all

    python targeting-humans_rf_analysis.py --freq-hz 2.44e9 --rate 10e6 --mode envelope --no-plot

================================================================================
"""

from __future__ import annotations

import argparse
import json
import sys
import traceback
from typing import Any, Optional, Tuple

# -----------------------------------------------------------------------------
# Optionale Abhängigkeiten — klare Fehlermeldungen, falls etwas fehlt
# -----------------------------------------------------------------------------
try:
    import numpy as np
except ImportError as exc:  # pragma: no cover
    print("FEHLER: numpy fehlt. Install: pip install numpy", file=sys.stderr)
    raise SystemExit(1) from exc

try:
    import matplotlib.pyplot as plt
except ImportError as exc:  # pragma: no cover
    print("FEHLER: matplotlib fehlt. Install: pip install matplotlib", file=sys.stderr)
    raise SystemExit(1) from exc

def _require_soapy() -> Any:
    """Importiert SoapySDR nur bei Hardware-Capture-Pfaden."""
    try:
        import SoapySDR  # type: ignore
    except ImportError as exc:  # pragma: no cover
        print(
            "FEHLER: SoapySDR (Python-Binding) fehlt.\n"
            "  Install (Beispiel): pip install SoapySDR\n"
            "  Außerdem müssen die nativen Soapy-Module + Gerätetreiber installiert sein.",
            file=sys.stderr,
        )
        raise SystemExit(1) from exc
    return SoapySDR


# =============================================================================
# Hilfsfunktionen / helpers
# =============================================================================


def _parse_device_args(raw: str) -> str:
    """
    Soapy akzeptiert oft JSON-Strings oder key=value-Ketten.
    Wir erwarten hier JSON wie: {"driver":"hackrf"}
    """
    raw = raw.strip()
    if not raw:
        return json.dumps({"driver": "hackrf"})
    # Wenn der Nutzer schon gültiges JSON liefert, durchreichen
    try:
        json.loads(raw)
        return raw
    except json.JSONDecodeError:
        # Fallback: als driver-String interpretieren
        return json.dumps({"driver": raw})


def open_device(soapy: Any, device_args: str) -> Any:
    """Öffnet genau ein Soapy-Gerät; bei Fehler: lesbare Meldung."""
    try:
        return soapy.Device(device_args)
    except Exception as exc:  # noqa: BLE001 — Soapy wirft diverse Typen
        print(
            "\n=== GERÄT KONNTE NICHT GEÖFFNET WERDEN ===\n"
            f"device_args = {device_args!r}\n"
            "Tipps:\n"
            "  • Ist das Gerät per USB eingesteckt?\n"
            "  • Läuft eine andere App (SDR++, GQRX), die den Dongle blockiert?\n"
            "  • Stimmt der Treiber? Test: SoapySDRUtil --find\n",
            file=sys.stderr,
        )
        traceback.print_exc()
        raise SystemExit(2) from exc


def configure_rx(
    soapy: Any,
    sdr: Any,
    sample_rate: float,
    center_hz: float,
    gain_db: Optional[float],
) -> None:
    """Grundlegende RX-Konfiguration (Kanal 0)."""
    # Sample-Rate setzen — manche Geräte quantisieren; Soapy snapped ggf. automatisch
    sdr.setSampleRate(soapy.SOAPY_SDR_RX, 0, float(sample_rate))
    sr_actual = sdr.getSampleRate(soapy.SOAPY_SDR_RX, 0)
    if abs(sr_actual - sample_rate) / max(sample_rate, 1.0) > 0.05:
        print(
            f"HINWEIS: angeforderte Sample-Rate {sample_rate:.0f} Hz, "
            f"Gerät nutzt {sr_actual:.0f} Hz.",
            file=sys.stderr,
        )

    sdr.setFrequency(soapy.SOAPY_SDR_RX, 0, float(center_hz))

    if gain_db is not None:
        # Viele Geräte: overall gain; falls nicht unterstützt, ignoriert Soapy ggf. still —
        # dann manuell im SDR-Utility prüfen.
        try:
            sdr.setGain(soapy.SOAPY_SDR_RX, 0, float(gain_db))
        except Exception:  # noqa: BLE001
            print(
                "WARNUNG: setGain schlug fehl — Gain manuell im Treiber prüfen.",
                file=sys.stderr,
            )


def _read_stream_result(ret) -> int:
    """Soapy Python-Binding: Rückgabe ist meist Objekt mit .ret, ältere APIs ggf. int."""
    if hasattr(ret, "ret"):
        return int(ret.ret)
    return int(ret)


def capture_iq_burst(
    soapy: Any,
    sdr: Any,
    num_samps: int,
    timeout_us: int,
    max_retries: int,
) -> Tuple[np.ndarray, float]:
    """
    Nimmt einen IQ-Block auf (komplex float32).

    Returns
    -------
    samples : (N,) complex64
    sample_rate : float — tatsächliche Rate nach Geräte-Snap
    """
    rx_stream = sdr.setupStream(soapy.SOAPY_SDR_RX, soapy.SOAPY_SDR_CF32)
    sdr.activateStream(rx_stream)

    buff = np.zeros(int(num_samps), dtype=np.complex64)
    sr_actual = float(sdr.getSampleRate(soapy.SOAPY_SDR_RX, 0))

    try:
        total = 0
        for attempt in range(max_retries):
            ret = sdr.readStream(
                rx_stream,
                [buff[total:]],
                len(buff) - total,
                timeoutUs=int(timeout_us),
            )
            nread = _read_stream_result(ret)
            if nread > 0:
                total += nread
            elif nread == soapy.SOAPY_SDR_TIMEOUT:
                print(
                    f"WARNUNG: Timeout bei readStream (Versuch {attempt + 1}/{max_retries}).",
                    file=sys.stderr,
                )
            elif nread == soapy.SOAPY_SDR_OVERFLOW:
                print("WARNUNG: Overflow — Sample-Rate oder Host-Last zu hoch.", file=sys.stderr)
            else:
                print(f"WARNUNG: readStream ret={nread}", file=sys.stderr)

            if total >= len(buff):
                break

        if total < len(buff):
            print(
                f"WARNUNG: nur {total} von {len(buff)} Samples erhalten — "
                "Plots können verrauscht sein.",
                file=sys.stderr,
            )
            # Rest bleibt Null — besser als Abbruch für Demozwecke
        return buff, sr_actual
    finally:
        sdr.deactivateStream(rx_stream)
        sdr.closeStream(rx_stream)


def psd_from_iq(iq: np.ndarray, sample_rate: float, window: bool) -> Tuple[np.ndarray, np.ndarray]:
    """
    Einfache PSD-Schätzung aus einem IQ-Block (nicht Welch — nur Demo).

    window=True → Hann-Fenster reduziert spektrales Leck (Leakage).
    """
    x = iq.astype(np.complex64, copy=False)
    n = len(x)
    if n < 8:
        raise ValueError("Zu wenige Samples für FFT.")

    if window:
        w = np.hanning(n).astype(np.float32)
        x = x * w
        # Coherent gain Kompensation (approx)
        scale = float(np.sum(w**2) / n)
    else:
        scale = 1.0

    spec = np.fft.fftshift(np.fft.fft(x))
    psd = (np.abs(spec) ** 2) / (n * max(scale, 1e-12))
    freqs = np.fft.fftshift(np.fft.fftfreq(n, d=1.0 / float(sample_rate)))
    return freqs, psd


def envelope_modulation_spectrum(
    iq: np.ndarray,
    sample_rate: float,
    max_mod_hz: float,
) -> Tuple[np.ndarray, np.ndarray]:
    """
    |IQ| → reellwertiges Signal → rFFT → Magnitude.

    Das zeigt Energie in *Modulationsfrequenzen* bis max_mod_hz (theoretisch bis fs/2,
    wir schneiden für Lesbarkeit oft bei einigen 10 kHz / 50 kHz ab).
    """
    env = np.abs(iq.astype(np.complex64, copy=False))
    env = env - float(np.mean(env))  # DC-Anteil im Envelope reduzieren (vereinfacht)
    n = len(env)
    spec = np.fft.rfft(env)
    freqs = np.fft.rfftfreq(n, d=1.0 / float(sample_rate))
    mag = np.abs(spec)

    mask = freqs <= float(max_mod_hz)
    return freqs[mask], mag[mask]


def spectrogram_from_iq(
    iq: np.ndarray,
    sample_rate: float,
    nfft: int,
    noverlap: int,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
    """
    Zeit-Frequenz-Darstellung aus I-Komponente (Basisband).

    Hinweis: Für die Website-Demo reicht ein robuster, einfacher Spectrogramm-Blick.
    """
    x = np.real(iq.astype(np.complex64, copy=False))
    if nfft < 64:
        nfft = 64
    if noverlap >= nfft:
        noverlap = nfft // 2
    # Matplotlib liefert f (Hz), t (s), Sxx.
    sxx, freqs, times, _ = plt.specgram(
        x,
        NFFT=int(nfft),
        Fs=float(sample_rate),
        noverlap=int(noverlap),
        mode="magnitude",
        scale="dB",
    )
    plt.clf()  # temporäre Figure aus specgram-Helferaufruf bereinigen
    return freqs, times, sxx


def synthetic_iq_burst(
    num_samps: int,
    sample_rate: float,
    mod_hz: float,
    noise_std: float,
) -> Tuple[np.ndarray, float]:
    """
    Simulierter IQ-Block für hardwarelosen Selbsttest.
    Enthält:
      - konstante Träger-Amplitude
      - sinusförmige Amplitudenmodulation bei mod_hz
      - komplexes weißes Rauschen
    """
    n = int(num_samps)
    t = np.arange(n, dtype=np.float32) / float(sample_rate)
    carrier = np.exp(1j * np.zeros(n, dtype=np.float32))
    envelope = 1.0 + 0.25 * np.sin(2.0 * np.pi * float(mod_hz) * t)
    iq = (envelope.astype(np.float32) * carrier).astype(np.complex64)
    if noise_std > 0:
        noise = (np.random.normal(0.0, noise_std, n) + 1j * np.random.normal(0.0, noise_std, n)).astype(
            np.complex64
        )
        iq = iq + noise
    return iq.astype(np.complex64), float(sample_rate)


def build_arg_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="Unified PSD + envelope/modulation spectrum capture (SoapySDR).",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    p.add_argument(
        "--device-args",
        type=str,
        default='{"driver":"hackrf"}',
        help='Soapy device args as JSON string, e.g. \'{"driver":"hackrf"}\'',
    )
    p.add_argument("--freq-hz", type=float, required=True, help="Center frequency in Hz (e.g. 780e6).")
    p.add_argument(
        "--rate",
        type=float,
        default=2e6,
        help="Sample rate (Hz). Lower rates are easier on USB/CPU.",
    )
    p.add_argument("--fft-size", type=int, default=4096, help="Number of IQ samples per capture.")
    p.add_argument(
        "--gain-db",
        type=float,
        default=None,
        help="Optional RX gain in dB (if supported by the device).",
    )
    p.add_argument(
        "--mode",
        choices=("psd", "envelope", "spectrogram", "both", "all"),
        default="all",
        help="Which plot(s) to generate.",
    )
    p.add_argument(
        "--max-mod-hz",
        type=float,
        default=50e3,
        help="Max modulation frequency to show in envelope spectrum (Hz).",
    )
    p.add_argument(
        "--no-hann",
        action="store_true",
        help="Disable Hann window on IQ before PSD FFT (more leakage, sharper transients).",
    )
    p.add_argument(
        "--timeout-us",
        type=int,
        default=500_000,
        help="readStream timeout per call (microseconds).",
    )
    p.add_argument(
        "--retries",
        type=int,
        default=5,
        help="How many readStream attempts to fill the buffer.",
    )
    p.add_argument(
        "--save",
        type=str,
        default=None,
        help="If set, save figure to this path (PNG) instead of showing a window.",
    )
    p.add_argument(
        "--no-plot",
        action="store_true",
        help="Do not display or save plots (capture self-test only).",
    )
    p.add_argument(
        "--selftest-sim",
        action="store_true",
        help="Use synthetic IQ data instead of SDR hardware (CI/offline self-test).",
    )
    p.add_argument(
        "--selftest-mod-hz",
        type=float,
        default=9.25e3,
        help="Modulation tone for synthetic IQ self-test (Hz).",
    )
    p.add_argument(
        "--selftest-noise-std",
        type=float,
        default=0.02,
        help="Noise std-dev for synthetic IQ self-test.",
    )
    p.add_argument(
        "--spec-nfft",
        type=int,
        default=256,
        help="Spectrogram FFT size (samples per window).",
    )
    p.add_argument(
        "--spec-overlap",
        type=int,
        default=192,
        help="Spectrogram overlap (samples).",
    )
    return p


def main() -> int:
    args = build_arg_parser().parse_args()
    device_args = _parse_device_args(args.device_args)

    if args.fft_size < 256:
        print("FEHLER: --fft-size zu klein (min 256 empfohlen).", file=sys.stderr)
        return 3

    if args.selftest_sim:
        iq, sr = synthetic_iq_burst(
            num_samps=args.fft_size,
            sample_rate=args.rate,
            mod_hz=args.selftest_mod_hz,
            noise_std=args.selftest_noise_std,
        )
    else:
        soapy = _require_soapy()
        sdr = open_device(soapy, device_args)
        try:
            configure_rx(soapy, sdr, sample_rate=args.rate, center_hz=args.freq_hz, gain_db=args.gain_db)
            iq, sr = capture_iq_burst(
                soapy,
                sdr,
                num_samps=args.fft_size,
                timeout_us=args.timeout_us,
                max_retries=args.retries,
            )
        finally:
            # Device-Destruktor; explizit del in neueren Soapy-Versionen nicht nötig
            del sdr

    if args.no_plot:
        source = "simulated IQ" if args.selftest_sim else "SDR capture"
        print(f"OK: {len(iq)} Samples @ {sr:.0f} Hz ({source}) complete.")
        return 0

    modes = []
    if args.mode in ("psd", "both", "all"):
        modes.append("psd")
    if args.mode in ("envelope", "both", "all"):
        modes.append("env")
    if args.mode in ("spectrogram", "all"):
        modes.append("spec")

    nplots = len(modes)
    fig, axes = plt.subplots(nplots, 1, figsize=(10, 3.5 * nplots), squeeze=False)
    ax_list = axes.ravel()

    plot_idx = 0
    if "psd" in modes:
        ax = ax_list[plot_idx]
        plot_idx += 1
        freqs, psd = psd_from_iq(iq, sr, window=not args.no_hann)
        ax.semilogy(freqs / 1e6, psd + 1e-20)
        ax.set_title("PSD (single FFT snapshot — educational)")
        ax.set_xlabel("Offset frequency (MHz) relative to IQ baseband centre")
        ax.set_ylabel("Relative power (linear, arbitrary units)")
        ax.grid(True, which="both", ls="--", alpha=0.35)

    if "env" in modes:
        ax = ax_list[plot_idx]
        plot_idx += 1
        freqs_e, mag_e = envelope_modulation_spectrum(iq, sr, max_mod_hz=args.max_mod_hz)
        ax.plot(freqs_e / 1e3, mag_e + 1e-20)
        ax.set_title("Envelope spectrum (|IQ| → rFFT) — low-rate modulation view")
        ax.set_xlabel("Modulation frequency (kHz)")
        ax.set_ylabel("Magnitude (arbitrary units)")
        ax.grid(True, which="both", ls="--", alpha=0.35)

    if "spec" in modes:
        ax = ax_list[plot_idx]
        plot_idx += 1
        freqs_s, times_s, sxx = spectrogram_from_iq(
            iq,
            sr,
            nfft=args.spec_nfft,
            noverlap=args.spec_overlap,
        )
        extent = [times_s.min(), times_s.max(), freqs_s.min() / 1e3, freqs_s.max() / 1e3]
        im = ax.imshow(
            20.0 * np.log10(np.maximum(sxx, 1e-20)),
            origin="lower",
            aspect="auto",
            extent=extent,
            interpolation="nearest",
            cmap="viridis",
        )
        ax.set_title("IQ spectrogram (I-channel) — time/frequency activity view")
        ax.set_xlabel("Time (s)")
        ax.set_ylabel("Frequency (kHz, baseband)")
        fig.colorbar(im, ax=ax, label="Magnitude (dB, relative)")

    src_label = "SIM" if args.selftest_sim else f"fc≈{args.freq_hz/1e6:.6f} MHz"
    fig.suptitle(
        f"Targeting Humans RF helper | {src_label} | fs={sr/1e6:.3f} MHz",
        fontsize=11,
    )
    fig.tight_layout()

    if args.save:
        fig.savefig(args.save, dpi=150)
        print(f"Figure saved: {args.save}")
    else:
        # Interaktiv nur, wenn ein Display existiert — sonst save empfehlen
        try:
            plt.show()
        except Exception:  # noqa: BLE001
            print(
                "HINWEIS: plt.show() fehlgeschlagen (kein Display?). "
                "Nutze z. B. --save out.png oder exportiere MPLBACKEND=Agg.",
                file=sys.stderr,
            )
            traceback.print_exc()
            return 4

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
