#!/usr/bin/env python3
import argparse
import math
import re
import signal
import subprocess
import sys
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Tuple

try:
    import numpy as np
except Exception as exc:
    print(f"numpy import failed: {exc}", file=sys.stderr)
    sys.exit(1)

try:
    from gnuradio import blocks, gr
    import osmosdr
except Exception as exc:
    print(f"gnuradio/osmosdr import failed: {exc}", file=sys.stderr)
    print("Run with the SDR venv, e.g. .venv-sdr/bin/python read_energy.py", file=sys.stderr)
    sys.exit(1)

EPS = 1e-20


@dataclass
class Target:
    label: str
    serial: str
    freq_hz: float
    source: str


@dataclass
class Row:
    label: str
    serial: str
    index: Optional[int] = None
    freq_hz: float = 0.0
    status: str = "INIT"
    rms: Optional[float] = None
    power_lin: Optional[float] = None
    dbfs: Optional[float] = None
    samples: int = 0
    updated_at: float = 0.0
    error: str = ""


class ProbeTop(gr.top_block):
    def __init__(self, index: int, freq_hz: float, sample_rate: float, vec_len: int,
                 gain: float, if_gain: float, bb_gain: float):
        super().__init__("hackrf_energy_probe")
        self.probe = blocks.probe_signal_vc(vec_len)
        self.stream_to_vec = blocks.stream_to_vector(gr.sizeof_gr_complex * 1, vec_len)
        self.src = osmosdr.source(args=f"numchan=1 hackrf={index}")
        self.src.set_time_unknown_pps(osmosdr.time_spec_t())
        self.src.set_sample_rate(sample_rate)
        self.src.set_center_freq(freq_hz, 0)
        try:
            self.src.set_freq_corr(0, 0)
        except Exception:
            pass
        for fn, val in (("set_gain", gain), ("set_if_gain", if_gain), ("set_bb_gain", bb_gain)):
            try:
                getattr(self.src, fn)(val, 0)
            except Exception:
                raise Exception("не ставится  усиление")
        try:
            self.src.set_bandwidth(0, 0)
        except Exception:
            pass
        try:
            self.src.set_antenna('', 0)
        except Exception:
            pass
        self.connect((self.src, 0), (self.stream_to_vec, 0))
        self.connect((self.stream_to_vec, 0), (self.probe, 0))

    def read_metrics(self) -> Tuple[float, float, float, int]:
        arr = np.asarray(self.probe.level(), dtype=np.complex64)
        if arr.size == 0:
            raise RuntimeError("no samples")
        p = float(np.mean(arr.real * arr.real + arr.imag * arr.imag))
        rms = math.sqrt(max(p, 0.0))
        dbfs = 10.0 * math.log10(max(p, EPS))
        return rms, p, dbfs, int(arr.size)


class Worker(threading.Thread):
    def __init__(self, target: Target, serial_to_index: Dict[str, int], rows: Dict[str, Row],
                 lock: threading.Lock, stop_event: threading.Event, args: argparse.Namespace):
        super().__init__(daemon=True)
        self.target = target
        self.serial_to_index = serial_to_index
        self.rows = rows
        self.lock = lock
        self.stop_event = stop_event
        self.args = args
        self.tb: Optional[ProbeTop] = None

    def _set_row(self, **kwargs):
        with self.lock:
            row = self.rows[self.target.label]
            for k, v in kwargs.items():
                setattr(row, k, v)
            row.updated_at = time.time()

    def _open(self) -> bool:
        idx = self.serial_to_index.get(self.target.serial)
        if idx is None:
            self._set_row(status="NOT_FOUND", error="serial not in hackrf_info", index=None)
            return False
        self._set_row(index=idx, status="OPENING", error="", freq_hz=self.target.freq_hz)
        try:
            self.tb = ProbeTop(
                idx, self.target.freq_hz, self.args.sample_rate, self.args.vec_len,
                self.args.gain, self.args.if_gain, self.args.bb_gain
            )
            self.tb.start()
            time.sleep(0.15)
            self._set_row(status="OK", error="")
            return True
        except Exception as exc:
            msg = str(exc)
            status = "BUSY" if ("Resource busy" in msg or "-1000" in msg) else "ERR"
            self._set_row(status=status, error=msg)
            self.tb = None
            return False

    def _close(self):
        if self.tb is not None:
            try:
                self.tb.stop()
                self.tb.wait()
            except Exception:
                pass
            self.tb = None

    def run(self):
        time.sleep(self.args.stagger)
        while not self.stop_event.is_set():
            if self.tb is None and not self._open():
                if self.stop_event.wait(self.args.reopen_delay):
                    break
                continue
            try:
                rms, p, dbfs, n = self.tb.read_metrics()
                self._set_row(status="OK", rms=rms, power_lin=p, dbfs=dbfs, samples=n, error="")
            except Exception as exc:
                self._set_row(status="ERR", error=str(exc))
                self._close()
                if self.stop_event.wait(self.args.reopen_delay):
                    break
                continue
            if self.stop_event.wait(self.args.interval):
                break
        self._close()


def parse_env(path: Path) -> Dict[str, str]:
    out: Dict[str, str] = {}
    if not path.exists():
        return out
    for raw in path.read_text(encoding="utf-8", errors="ignore").splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        out[k.strip()] = v.strip().strip('"').strip("'")
    return out


def collect_targets(env: Dict[str, str], only: Optional[set], override_freq_mhz: Optional[float]) -> List[Target]:
    targets: List[Target] = []
    for k, v in env.items():
        m = re.fullmatch(r"hack_(\d+)", k)
        if m:
            label = m.group(1)
        else:
            m = re.fullmatch(r"HACKID_(\d+)", k)
            if not m:
                continue
            label = m.group(1)
        if only and label not in only:
            continue
        mhz = override_freq_mhz if override_freq_mhz is not None else float(label)
        targets.append(Target(label=label, serial=v.lower(), freq_hz=mhz * 1e6, source=k))

    uniq: Dict[str, Target] = {}
    for t in sorted(targets, key=lambda x: (int(x.label), 0 if x.source.startswith("hack_") else 1)):
        uniq.setdefault(t.label, t)
    return [uniq[k] for k in sorted(uniq, key=lambda x: int(x))]


def parse_hackrf_info() -> Dict[str, int]:
    try:
        proc = subprocess.run(["hackrf_info"], capture_output=True, text=True, timeout=15)
    except FileNotFoundError:
        raise RuntimeError("hackrf_info not found")
    except subprocess.TimeoutExpired:
        raise RuntimeError("hackrf_info timeout")
    text = (proc.stdout or "") + "\n" + (proc.stderr or "")
    out: Dict[str, int] = {}
    cur_idx: Optional[int] = None
    for line in text.splitlines():
        m = re.search(r"^Index:\s*(\d+)", line)
        if m:
            cur_idx = int(m.group(1))
            continue
        m = re.search(r"^Serial number:\s*([0-9a-fA-F]+)", line)
        if m and cur_idx is not None:
            out[m.group(1).lower()] = cur_idx
    if not out:
        raise RuntimeError("no devices parsed from hackrf_info")
    return out


def fmt(v: Optional[float], spec: str) -> str:
    return "-" if v is None else format(v, spec)


def render(rows: Dict[str, Row], started_at: float, env_path: Path, serial_to_index: Dict[str, int]):
    now = time.time()
    print("\x1b[2J\x1b[H", end="")
    print("HackRF Energy Monitor (relative power: RMS / linear / dBFS, not calibrated dBm)")
    print(f"env: {env_path} | discovered: {len(serial_to_index)} | uptime: {int(now-started_at)}s | {time.strftime('%Y-%m-%d %H:%M:%S')}")
    print()
    header = f"{'band':>5} {'idx':>3} {'freq':>7} {'status':>9} {'rms':>10} {'power':>12} {'dBFS':>9} {'N':>5} {'age':>5} {'serial':>12}  error"
    print(header)
    print('-' * len(header))
    for label in sorted(rows, key=lambda x: int(x)):
        r = rows[label]
        idx = '-' if r.index is None else str(r.index)
        age = '-' if r.updated_at <= 0 else f"{(now-r.updated_at):.1f}"
        err = (r.error or "")
        if len(err) > 64:
            err = err[:61] + '...'
        print(
            f"{r.label:>5} {idx:>3} {r.freq_hz/1e6:>7.1f} {r.status:>9} "
            f"{fmt(r.rms, '.6f'):>10} {fmt(r.power_lin, '.8f'):>12} {fmt(r.dbfs, '.2f'):>9} "
            f"{r.samples:>5} {age:>5} {r.serial[-12:]:>12}  {err}"
        )
    print()
    print("Ctrl+C to stop. Use --only 2400,5200 or --freq-mhz 2450 to limit/override tuning.")
    sys.stdout.flush()


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description="Realtime HackRF relative energy monitor")
    p.add_argument("--env", default=".env", help="Path to .env (default: repo_root/.env)")
    p.add_argument("--only", default="", help="Comma-separated labels (e.g. 2400,5200)")
    p.add_argument("--freq-mhz", type=float, default=None, help="Override tuning frequency for all devices (MHz)")
    p.add_argument("--sample-rate", type=float, default=2e6, help="Sample rate in Hz (HackRF min ~2e6)")
    p.add_argument("--vec-len", type=int, default=4096, help="Probe vector length")
    p.add_argument("--interval", type=float, default=0.5, help="Per-device read interval (s)")
    p.add_argument("--refresh", type=float, default=0.5, help="Console refresh interval (s)")
    p.add_argument("--reopen-delay", type=float, default=1.0, help="Retry delay after BUSY/ERR (s)")
    p.add_argument("--gain", type=float, default=0.0, help="General gain")
    p.add_argument("--if-gain", type=float, default=16.0, help="IF gain")
    p.add_argument("--bb-gain", type=float, default=16.0, help="BB gain")
    return p


def main() -> int:
    args = build_parser().parse_args()
    only = {x.strip() for x in args.only.split(',') if x.strip()} or None

    env_path = Path(args.env)
    if not env_path.is_absolute():
        env_path = (Path(__file__).resolve().parent / env_path).resolve()
    env = parse_env(env_path)
    targets = collect_targets(env, only=only, override_freq_mhz=args.freq_mhz)
    if not targets:
        print(f"No hack_/HACKID_ entries found in {env_path}", file=sys.stderr)
        return 2

    try:
        serial_to_index = parse_hackrf_info()
    except Exception as exc:
        print(f"hackrf discovery failed: {exc}", file=sys.stderr)
        return 3

    rows = {t.label: Row(label=t.label, serial=t.serial, freq_hz=t.freq_hz) for t in targets}
    lock = threading.Lock()
    stop_event = threading.Event()

    def on_signal(signum, frame):
        stop_event.set()

    signal.signal(signal.SIGINT, on_signal)
    signal.signal(signal.SIGTERM, on_signal)

    workers: List[Worker] = []
    for i, t in enumerate(targets):
        wa = argparse.Namespace(**vars(args))
        wa.stagger = i * 0.15
        w = Worker(t, serial_to_index, rows, lock, stop_event, wa)
        workers.append(w)
        w.start()

    started = time.time()
    try:
        while not stop_event.is_set():
            with lock:
                snap = {k: Row(**vars(v)) for k, v in rows.items()}
            render(snap, started, env_path, serial_to_index)
            stop_event.wait(args.refresh)
    finally:
        stop_event.set()
        for w in workers:
            w.join(timeout=2)
    return 0


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