#!/usr/bin/env python3
"""
FT-991A Web GUI Server
======================
FastAPI + WebSocket server for real-time control of Yaesu FT-991A ham radio.
"""

import asyncio
import glob
import json
import logging
import os
import re
import shutil
import subprocess
import tempfile
import time
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Set

import httpx
import serial.tools.list_ports
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from .cat import CW_WIDTH_CODE_TO_HZ, PREAMP_MODE_NAMES, FT991A, Band, Mode
from .cw import (
    CWDecoder,
    CWFrequencyLocator,
    build_cw_hunt_package,
    build_cw_sweep_shortlist,
    list_cw_bands,
    normalize_cw_band_list,
    resolve_cw_band_window,
    summarize_cw_candidate_passes,
)
from .cw_copy import (
    CWCopyConfig,
    build_cw_copy_log_path,
    build_multimon_cw_command,
    parse_multimon_cw_line,
    score_copy_text,
)


WEB_LOG_DIR = Path.home() / "logs" / "ft991a"
WEB_LOG_DIR.mkdir(parents=True, exist_ok=True)
WEB_LOG_PATH = WEB_LOG_DIR / "ft991a-web.log"

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.StreamHandler(), logging.FileHandler(WEB_LOG_PATH)],
)
logger = logging.getLogger(__name__)

# Try to import SoapySDR - gracefully handle if not available
try:
    import numpy as np
    import SoapySDR

    SOAPY_SDR_AVAILABLE = True
    logger.info("SoapySDR available - SDR support enabled")
except ImportError:
    SOAPY_SDR_AVAILABLE = False
    logger.warning("SoapySDR not available - SDR functionality disabled")

# ── Data Models ──────────────────────────────────────────────


class FrequencyRequest(BaseModel):
    frequency: int  # Hz


class ModeRequest(BaseModel):
    mode: str  # Mode name (LSB, USB, etc.)


class PowerRequest(BaseModel):
    power: int  # Watts (5-100)


class BandRequest(BaseModel):
    band: str  # Band name (160M, 80M, etc.)


class SquelchRequest(BaseModel):
    level: int  # 0-255


class PTTRequest(BaseModel):
    enable: bool


class ConfigRequest(BaseModel):
    port: str = "/dev/ttyUSB0"
    baudrate: int = 38400


class SetupTestRequest(BaseModel):
    port: str
    baudrate: int


class SetupSaveRequest(BaseModel):
    port: str
    baudrate: int
    callsign: Optional[str] = None


class ScanRequest(BaseModel):
    start_freq: int  # Start frequency in Hz
    end_freq: int  # End frequency in Hz
    step: int  # Step size in Hz
    threshold: int  # S-meter threshold (0-255)


class MemoryRecallRequest(BaseModel):
    channel: int  # 1-99


class MemoryStoreRequest(BaseModel):
    channel: int  # 1-99


class MemoryClearRequest(BaseModel):
    channel: int  # 1-99


class MonitorStartRequest(BaseModel):
    clip_duration: int = 20
    interval: int = 120
    language: str = "es"
    smeter_threshold: int = 20


class CWSampleDecodeRequest(BaseModel):
    samples: List[float]
    sample_rate: int = 8000
    tone_freq: int = 600


class CWCandidateRequest(BaseModel):
    top_n: int = 8


class CWHuntRequest(BaseModel):
    band: Optional[str] = None
    band_start_hz: Optional[int] = None
    band_stop_hz: Optional[int] = None
    center_freq_hz: Optional[int] = None
    top_n: int = 8
    snapshot: bool = True
    snapshot_frames: int = 4
    sdr_bandwidth: int = 500000
    sdr_fft_size: int = 2048
    min_spacing_hz: int = 150
    window_gap_hz: int = 1000
    cycle_ms: int = 30000
    min_dwell_ms: int = 1200
    upcoming_count: int = 3
    action_ready_ms: int = 2000
    action_critical_ms: int = 500
    apply_cw_profile: bool = True
    mode: str = "CW"
    squelch_level: int = 0
    reference_freq_hz: Optional[int] = None


class CWRXProfileRequest(BaseModel):
    mode: Optional[str] = None
    squelch_level: Optional[int] = None
    clarifier_enabled: Optional[bool] = None
    clarifier_offset_hz: Optional[int] = None
    if_shift_hz: Optional[int] = None
    cw_pitch_hz: Optional[int] = None
    narrow: Optional[bool] = None
    width_code: Optional[int] = None
    width_hz: Optional[int] = None
    rf_gain: Optional[int] = None
    preamp_mode: Optional[str] = None
    attenuator: Optional[bool] = None
    auto_notch: Optional[bool] = None
    noise_blanker: Optional[bool] = None
    noise_blanker_level: Optional[int] = None
    noise_reduction: Optional[bool] = None
    noise_reduction_level: Optional[int] = None
    cw_spot: Optional[bool] = None


class CWSweepRequest(BaseModel):
    bands: Optional[List[str]] = None
    top_n_per_pass: int = 5
    top_n_per_band: int = 5
    shortlist_size: int = 12
    snapshot_frames: int = 4
    passes_per_band: int = 3
    sdr_bandwidth: int = 500000
    sdr_fft_size: int = 2048
    min_spacing_hz: int = 150
    apply_cw_profile: bool = False
    mode: str = "CW"
    squelch_level: int = 0
    reference_freq_hz: Optional[int] = None


class CWCopyRequest(BaseModel):
    duration_sec: Optional[int] = 20
    frequency_hz: Optional[int] = None
    apply_cw_profile: bool = True
    mode: str = "CW"
    squelch_level: int = 0
    audio_device: Optional[str] = None
    sample_rate: int = 22050
    bandpass_low_hz: int = 500
    bandpass_high_hz: int = 900
    verbosity: int = 1
    auto_threshold: bool = True
    auto_timing: bool = True
    dit_ms: Optional[int] = None
    gap_ms: Optional[int] = None


def serialize_payload(value):
    """Convert dataclasses and nested payloads into JSON-serializable structures."""
    if hasattr(value, "__dataclass_fields__"):
        return asdict(value)
    if isinstance(value, list):
        return [serialize_payload(entry) for entry in value]
    if isinstance(value, dict):
        return {key: serialize_payload(entry) for key, entry in value.items()}
    return value


def discover_radio_port_candidates(configured_port: Optional[str]) -> List[str]:
    """Return CAT port candidates ordered by stable FT-991A-friendly preference."""
    ordered: List[str] = []

    def add(path: Optional[str]):
        if not path:
            return
        if path in ordered:
            return
        if Path(path).exists():
            ordered.append(path)

    add(configured_port)

    by_id_paths = sorted(glob.glob("/dev/serial/by-id/*"))
    for path in by_id_paths:
        lower = path.lower()
        if "cp210" in lower and "if00" in lower:
            add(path)
    for path in by_id_paths:
        lower = path.lower()
        if "cp210" in lower or "silicon" in lower:
            add(path)

    for port in serial.tools.list_ports.comports():
        summary = " ".join(
            [port.device or "", port.description or "", port.manufacturer or ""]
        ).lower()
        if "cp210" in summary or "silicon labs" in summary:
            add(port.device)

    for fallback in ("/dev/ttyUSB0", "/dev/ttyUSB1", "/dev/ttyUSB2"):
        add(fallback)

    return ordered


def build_radio_status_payload(active_radio: FT991A) -> Dict:
    """Build the current radio state payload used by multiple endpoints."""
    status = active_radio.get_status()
    return {
        "frequency_a": status.frequency_a,
        "frequency_b": status.frequency_b,
        "mode": status.mode,
        "tx_active": status.tx_active,
        "squelch_open": status.squelch_open,
        "s_meter": status.s_meter,
        "power_output": status.power_output,
        "swr": status.swr,
        "tx_lockout": tx_lockout,
        "squelch_level": active_radio.get_squelch_level(),
    }


def build_cw_hunt_log_path() -> Path:
    log_dir = Path.home() / "Projects" / "lbf-ham-radio" / "logs" / "cw-hunt"
    log_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    return log_dir / f"cw_hunt_{stamp}.json"


def build_cw_sweep_log_path() -> Path:
    log_dir = Path.home() / "Projects" / "lbf-ham-radio" / "logs" / "cw-sweep"
    log_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    return log_dir / f"cw_sweep_{stamp}.json"


def list_audio_capture_candidates(preferred_device: Optional[str] = None) -> List[str]:
    """Return likely ALSA capture devices ordered by FT-991A USB audio preference."""
    ordered: List[str] = []

    def add(device: Optional[str]):
        if not device:
            return
        if device not in ordered:
            ordered.append(device)

    add(preferred_device)

    for device in (
        "hw:CARD=CODEC,DEV=0",
        "plughw:CARD=CODEC,DEV=0",
        "hw:CARD=CODEC",
        "plughw:CARD=CODEC",
        "hw:2,0",
        "plughw:2,0",
        "hw:1,0",
        "plughw:1,0",
    ):
        add(device)

    try:
        result = subprocess.run(
            ["arecord", "-l"], capture_output=True, text=True, timeout=5
        )
        if result.returncode == 0:
            for line in result.stdout.splitlines():
                if "USB" not in line and "CODEC" not in line:
                    continue
                match = re.search(r"card\s+(\d+):.*device\s+(\d+)", line)
                if not match:
                    continue
                card_num, device_num = match.groups()
                add(f"hw:{card_num},{device_num}")
                add(f"plughw:{card_num},{device_num}")
    except (subprocess.TimeoutExpired, FileNotFoundError):
        pass

    return ordered


async def resolve_audio_capture_device(
    preferred_device: Optional[str],
    sample_rate: int,
) -> str:
    """Probe ALSA capture devices and return the first working option."""
    if sample_rate <= 0:
        raise HTTPException(status_code=400, detail="sample_rate must be positive")

    for device in list_audio_capture_candidates(preferred_device):
        probe = None
        try:
            probe = await asyncio.create_subprocess_exec(
                "arecord",
                "-D",
                device,
                "-f",
                "S16_LE",
                "-r",
                str(sample_rate),
                "-c",
                "1",
                "-t",
                "raw",
                "-d",
                "1",
                stdout=asyncio.subprocess.DEVNULL,
                stderr=asyncio.subprocess.DEVNULL,
            )
            await asyncio.wait_for(probe.wait(), timeout=3)
            if probe.returncode == 0:
                return device
        except (asyncio.TimeoutError, FileNotFoundError):
            if probe is not None:
                try:
                    probe.kill()
                except ProcessLookupError:
                    pass
        except Exception:
            if probe is not None:
                try:
                    probe.kill()
                except ProcessLookupError:
                    pass

    raise HTTPException(
        status_code=500,
        detail="No working ALSA capture device found for CW copy",
    )


def ensure_cw_copy_dependencies() -> None:
    """Fail fast when the host is missing the external CW copy toolchain."""
    missing = [
        binary for binary in ("arecord", "sox", "multimon-ng") if shutil.which(binary) is None
    ]
    if missing:
        raise HTTPException(
            status_code=503,
            detail="Missing CW copy dependencies: " + ", ".join(missing),
        )


def build_cw_copy_config(req: CWCopyRequest, audio_device: str) -> CWCopyConfig:
    """Convert API request fields into a validated multimon CW copy config."""
    if req.duration_sec is not None and not (1 <= req.duration_sec <= 300):
        raise HTTPException(status_code=400, detail="duration_sec must be 1-300")
    if req.sample_rate not in {8000, 11025, 16000, 22050, 44100, 48000}:
        raise HTTPException(status_code=400, detail="Unsupported sample_rate")
    if not (0 <= req.verbosity <= 3):
        raise HTTPException(status_code=400, detail="verbosity must be 0-3")

    try:
        return CWCopyConfig(
            audio_device=audio_device,
            sample_rate=req.sample_rate,
            duration_sec=req.duration_sec,
            bandpass_low_hz=req.bandpass_low_hz,
            bandpass_high_hz=req.bandpass_high_hz,
            verbosity=req.verbosity,
            auto_threshold=req.auto_threshold,
            auto_timing=req.auto_timing,
            dit_ms=req.dit_ms,
            gap_ms=req.gap_ms,
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))


def append_cw_copy_event(log_path: Path, event: Dict) -> None:
    """Append a structured CW copy event to durable JSONL logs."""
    with log_path.open("a", encoding="utf-8") as handle:
        handle.write(json.dumps(event) + "\n")


async def run_cw_copy_capture(req: CWCopyRequest) -> Dict:
    """Run a bounded RX-only CW copy session and return structured results."""
    ensure_cw_copy_dependencies()
    active_radio = await get_radio_or_reconnect()
    status_before = build_radio_status_payload(active_radio)
    controls_before = build_receive_controls_payload(active_radio)

    if req.apply_cw_profile:
        apply_rx_profile(
            active_radio,
            build_default_cw_profile_request(
                mode=req.mode, squelch_level=req.squelch_level
            ),
        )

    if req.frequency_hz is not None:
        active_radio.set_frequency_a(req.frequency_hz)
        await asyncio.sleep(1.2)

    frequency_hz = req.frequency_hz or active_radio.get_status().frequency_a
    status_tuned = build_radio_status_payload(active_radio)
    controls_after = build_receive_controls_payload(active_radio)
    audio_device = await resolve_audio_capture_device(req.audio_device, req.sample_rate)
    copy_config = build_cw_copy_config(req, audio_device)
    log_path = build_cw_copy_log_path()

    append_cw_copy_event(
        log_path,
        {
            "type": "session_start",
            "timestamp": datetime.utcnow().isoformat() + "Z",
            "frequency_hz": frequency_hz,
            "status_before": status_before,
            "status_tuned": status_tuned,
            "controls_before": controls_before,
            "controls_after": controls_after,
            "config": copy_config.to_dict(),
        },
    )

    command = build_multimon_cw_command(copy_config)
    process = await asyncio.create_subprocess_exec(
        "bash",
        "-lc",
        command,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.STDOUT,
    )

    copies: List[Dict] = []
    raw_events: List[Dict] = []
    last_copy: Optional[Dict] = None

    try:
        while process.stdout is not None:
            line = await process.stdout.readline()
            if not line:
                break
            parsed = parse_multimon_cw_line(line.decode("utf-8", errors="replace"))
            if not parsed:
                continue
            append_cw_copy_event(log_path, parsed)
            if parsed["type"] == "copy":
                copy_event = {
                    "index": len(copies) + 1,
                    "timestamp": parsed["timestamp"],
                    "text": parsed["text"],
                }
                copies.append(copy_event)
                last_copy = copy_event
            elif parsed["type"] == "stats":
                if last_copy is not None:
                    last_copy["stats"] = parsed["stats"]
            else:
                raw_events.append(parsed)

        returncode = await process.wait()
    finally:
        if process.returncode is None:
            process.terminate()
            try:
                await asyncio.wait_for(process.wait(), timeout=2)
            except asyncio.TimeoutError:
                process.kill()

    summary = {
        "success": returncode == 0,
        "frequency_hz": frequency_hz,
        "audio_device": audio_device,
        "status_before": status_before,
        "status_tuned": status_tuned,
        "controls_before": controls_before,
        "controls_after": controls_after,
        "config": copy_config.to_dict(),
        "copy_count": len(copies),
        "copies": copies,
        "raw_event_count": len(raw_events),
        "log_path": str(log_path),
        "best_copy": max(copies, key=lambda row: score_copy_text(row["text"])) if copies else None,
    }
    append_cw_copy_event(
        log_path,
        {
            "type": "session_end",
            "timestamp": datetime.utcnow().isoformat() + "Z",
            "returncode": returncode,
            "copy_count": len(copies),
        },
    )
    return summary


def build_receive_controls_payload(active_radio: FT991A) -> Dict:
    """Build a machine-readable receive-control snapshot."""
    controls = active_radio.get_receive_controls()
    return {
        **controls,
        "mode": active_radio.get_mode(),
        "squelch_level": active_radio.get_squelch_level(),
        "if_state": active_radio.get_if_state(),
    }


def build_rx_capabilities_payload() -> Dict:
    """Describe the documented RX controls exposed through the API."""
    return {
        "supported_cw_bands": list_cw_bands(),
        "preamp_modes": PREAMP_MODE_NAMES,
        "cw_width_codes": CW_WIDTH_CODE_TO_HZ,
        "ranges": {
            "clarifier_offset_hz": {"min": -9999, "max": 9999},
            "if_shift_hz": {"min": -1000, "max": 1000},
            "cw_pitch_hz": {"min": 300, "max": 1050},
            "squelch_level": {"min": 0, "max": 255},
            "rf_gain": {"min": 0, "max": 255},
            "noise_blanker_level": {"min": 0, "max": 255},
            "noise_reduction_level": {"min": 0, "max": 15},
        },
        "default_cw_profile": {
            "mode": "CW",
            "squelch_level": 0,
            "clarifier_enabled": False,
            "clarifier_offset_hz": 0,
            "if_shift_hz": 0,
            "narrow": True,
            "width_hz": 500,
        },
    }


def build_default_cw_profile_request(mode: str = "CW", squelch_level: int = 0) -> CWRXProfileRequest:
    """Default RX-only CW profile tuned for narrow manual hunting."""
    return CWRXProfileRequest(
        mode=mode,
        squelch_level=squelch_level,
        clarifier_enabled=False,
        clarifier_offset_hz=0,
        if_shift_hz=0,
        narrow=True,
        width_hz=500,
    )


def apply_rx_profile(active_radio: FT991A, profile: CWRXProfileRequest) -> Dict:
    """Apply documented receive-side controls and return the resulting state."""
    if profile.mode is not None:
        if profile.mode not in {"LSB", "USB", "CW", "CW_R", "AM", "FM", "DATA_USB", "DATA_LSB"}:
            raise HTTPException(status_code=400, detail=f"Unsupported mode: {profile.mode}")
        active_radio.set_mode(Mode[profile.mode])

    if profile.squelch_level is not None:
        active_radio.set_squelch_level(profile.squelch_level)
    if profile.clarifier_enabled is not None:
        active_radio.set_clarifier_enabled(profile.clarifier_enabled)
    if profile.clarifier_offset_hz is not None:
        active_radio.set_clarifier_offset_hz(profile.clarifier_offset_hz)
    if profile.if_shift_hz is not None:
        active_radio.set_if_shift_hz(profile.if_shift_hz)
    if profile.cw_pitch_hz is not None:
        active_radio.set_cw_pitch_hz(profile.cw_pitch_hz)
    if profile.narrow is not None:
        active_radio.set_narrow_enabled(profile.narrow)
    if profile.width_hz is not None:
        active_radio.set_width_hz(profile.width_hz)
    elif profile.width_code is not None:
        active_radio.set_width_code(profile.width_code)
    if profile.rf_gain is not None:
        active_radio.set_rf_gain(profile.rf_gain)
    if profile.preamp_mode is not None:
        active_radio.set_preamp_mode(profile.preamp_mode)
    if profile.attenuator is not None:
        active_radio.set_rf_attenuator_enabled(profile.attenuator)
    if profile.auto_notch is not None:
        active_radio.set_auto_notch_enabled(profile.auto_notch)
    if profile.noise_blanker is not None:
        active_radio.set_noise_blanker_enabled(profile.noise_blanker)
    if profile.noise_blanker_level is not None:
        active_radio.set_noise_blanker_level(profile.noise_blanker_level)
    if profile.noise_reduction is not None:
        active_radio.set_noise_reduction_enabled(profile.noise_reduction)
    if profile.noise_reduction_level is not None:
        active_radio.set_noise_reduction_level(profile.noise_reduction_level)
    if profile.cw_spot is not None:
        active_radio.set_cw_spot_enabled(profile.cw_spot)

    return build_receive_controls_payload(active_radio)


# ── Global State ─────────────────────────────────────────────


app = FastAPI(title="FT-991A Web GUI", version="0.38.1")
radio: Optional[FT991A] = None
radio_connected = False
tx_lockout = False  # Safety: prevents accidental PTT
websocket_clients: Set[WebSocket] = set()

# Scanner state
scan_active = False
scan_task: Optional[asyncio.Task] = None

# Monitor state
monitor_active = False
monitor_task: Optional[asyncio.Task] = None
monitor_websocket_clients: Set[WebSocket] = set()
monitor_start_time: Optional[datetime] = None
monitor_clip_count = 0

# Configuration
radio_config = {"port": "/dev/ttyUSB0", "baudrate": 38400, "auto_reconnect": True}

# ── WebSocket Manager ────────────────────────────────────────


class ConnectionManager:
    def __init__(self):
        self.active_connections: List[WebSocket] = []

    async def connect(self, websocket: WebSocket):
        await websocket.accept()
        self.active_connections.append(websocket)
        websocket_clients.add(websocket)

    def disconnect(self, websocket: WebSocket):
        if websocket in self.active_connections:
            self.active_connections.remove(websocket)
        websocket_clients.discard(websocket)

    async def broadcast(self, message: dict):
        if not self.active_connections:
            return

        dead_connections = []
        for connection in self.active_connections:
            try:
                await connection.send_text(json.dumps(message))
            except Exception as e:
                logger.warning(f"WebSocket send failed: {e}")
                dead_connections.append(connection)

        # Clean up dead connections
        for conn in dead_connections:
            self.disconnect(conn)


class AudioConnectionManager:
    def __init__(self):
        self.active_connections: List[WebSocket] = []
        self.audio_process = None  # asyncio.subprocess.Process
        self.streaming_task: Optional[asyncio.Task] = None

    async def connect(self, websocket: WebSocket):
        await websocket.accept()
        self.active_connections.append(websocket)

        # Start audio streaming if this is the first client
        if len(self.active_connections) == 1:
            await self.start_audio_stream()

    def disconnect(self, websocket: WebSocket):
        if websocket in self.active_connections:
            self.active_connections.remove(websocket)

        # Stop audio streaming if no more clients
        if len(self.active_connections) == 0:
            asyncio.ensure_future(self.stop_audio_stream())

    async def broadcast_audio(self, data: bytes):
        if not self.active_connections:
            return

        dead_connections = []
        for connection in self.active_connections:
            try:
                await connection.send_bytes(data)
            except Exception as e:
                logger.warning(f"Audio WebSocket send failed: {e}")
                dead_connections.append(connection)

        for conn in dead_connections:
            if conn in self.active_connections:
                self.active_connections.remove(conn)

    async def start_audio_stream(self):
        """Start audio capture from PCM2903B CODEC using async subprocess"""
        if self.audio_process or self.streaming_task:
            return

        try:
            # Try audio devices in order
            audio_devices = [
                "hw:CARD=CODEC",
                "hw:CARD=CODEC,DEV=0",
                "plughw:CARD=CODEC,DEV=0",
                "hw:2,0",
                "hw:1,0",
            ]

            device_found = None
            for device in audio_devices:
                try:
                    test = await asyncio.create_subprocess_exec(
                        "arecord",
                        "-D",
                        device,
                        "-f",
                        "S16_LE",
                        "-r",
                        "48000",
                        "-c",
                        "1",
                        "-t",
                        "raw",
                        "-d",
                        "1",
                        stdout=asyncio.subprocess.DEVNULL,
                        stderr=asyncio.subprocess.DEVNULL,
                    )
                    await asyncio.wait_for(test.wait(), timeout=3)
                    if test.returncode == 0:
                        device_found = device
                        logger.info(f"Audio device found: {device}")
                        break
                except (asyncio.TimeoutError, Exception):
                    try:
                        test.kill()
                    except:
                        pass
                    continue

            if not device_found:
                logger.error("No compatible audio capture device found")
                return

            # Start async audio capture
            self.audio_process = await asyncio.create_subprocess_exec(
                "arecord",
                "-D",
                device_found,
                "-f",
                "S16_LE",
                "-r",
                "48000",
                "-c",
                "1",
                "-t",
                "raw",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.DEVNULL,
            )

            self.streaming_task = asyncio.create_task(self._stream_audio())
            logger.info(f"Audio streaming started: {device_found}")

        except Exception as e:
            logger.error(f"Failed to start audio stream: {e}")
            await self.stop_audio_stream()

    async def _stream_audio(self):
        """Non-blocking audio streaming to WebSocket clients"""
        chunk_size = 4096  # ~42ms at 48kHz mono 16-bit

        try:
            while self.audio_process and self.audio_process.returncode is None:
                data = await self.audio_process.stdout.read(chunk_size)
                if not data:
                    break
                await self.broadcast_audio(data)
        except asyncio.CancelledError:
            pass
        except Exception as e:
            logger.error(f"Audio streaming error: {e}")
        finally:
            await self.stop_audio_stream()

    async def stop_audio_stream(self):
        """Stop audio capture and streaming"""
        if self.streaming_task and not self.streaming_task.done():
            self.streaming_task.cancel()
        self.streaming_task = None

        if self.audio_process:
            try:
                self.audio_process.terminate()
                await asyncio.wait_for(self.audio_process.wait(), timeout=2)
            except (asyncio.TimeoutError, Exception):
                try:
                    self.audio_process.kill()
                except:
                    pass
            self.audio_process = None

        logger.info("Audio streaming stopped")


class SDRConnectionManager:
    def __init__(self):
        self.active_connections: List[WebSocket] = []
        self.sdr_device = None
        self.streaming_task: Optional[asyncio.Task] = None
        self.bandwidth = 2000000  # Default 2 MHz
        self.fft_size = 1024  # Default FFT size
        self.frame_rate = 10  # Target ~10 fps
        self.latest_bins: List[float] = []
        self.latest_center_freq: float = 0.0
        self.latest_timestamp: float = 0.0

    async def connect(self, websocket: WebSocket):
        await websocket.accept()
        self.active_connections.append(websocket)

        # Start SDR streaming if this is the first client
        if len(self.active_connections) == 1:
            await self.start_sdr_stream()

    def disconnect(self, websocket: WebSocket):
        if websocket in self.active_connections:
            self.active_connections.remove(websocket)

        # Stop SDR streaming if no more clients
        if len(self.active_connections) == 0:
            asyncio.ensure_future(self.stop_sdr_stream())

    async def broadcast_sdr_data(self, data: dict):
        if not self.active_connections:
            return

        dead_connections = []
        for connection in self.active_connections:
            try:
                await connection.send_text(json.dumps(data))
            except Exception as e:
                logger.warning(f"SDR WebSocket send failed: {e}")
                dead_connections.append(connection)

        for conn in dead_connections:
            if conn in self.active_connections:
                self.active_connections.remove(conn)

    async def start_sdr_stream(self):
        """Start SDR streaming from RSP2pro using SoapySDR"""
        if not SOAPY_SDR_AVAILABLE:
            logger.error("Cannot start SDR stream - SoapySDR not available")
            await self.broadcast_sdr_data(
                {"type": "sdr_error", "message": "SoapySDR not available"}
            )
            return

        if self.sdr_device or self.streaming_task:
            return

        try:
            # Initialize SDRplay RSP2pro
            logger.info("Initializing SDRplay RSP2pro...")

            # Create device handle
            args = dict(driver="sdrplay")
            self.sdr_device = SoapySDR.Device(args)

            # Configure device
            self.sdr_device.setSampleRate(SoapySDR.SOAPY_SDR_RX, 0, self.bandwidth)
            self.sdr_device.setFrequency(
                SoapySDR.SOAPY_SDR_RX, 0, 14074000
            )  # Default to 20m FT8
            self.sdr_device.setGain(SoapySDR.SOAPY_SDR_RX, 0, "IFGR", 40)  # IF gain
            self.sdr_device.setGain(SoapySDR.SOAPY_SDR_RX, 0, "RFGR", 7)  # RF gain
            self.sdr_device.setAntenna(SoapySDR.SOAPY_SDR_RX, 0, "RX")

            # Setup stream
            rx_stream = self.sdr_device.setupStream(
                SoapySDR.SOAPY_SDR_RX, SoapySDR.SOAPY_SDR_CF32
            )
            self.sdr_device.activateStream(rx_stream)

            # Start streaming task
            self.streaming_task = asyncio.create_task(self._stream_sdr_data(rx_stream))

            logger.info(
                f"SDR streaming started: {self.bandwidth/1e6:.1f} MHz bandwidth"
            )

            # Notify clients
            await self.broadcast_sdr_data(
                {
                    "type": "sdr_info",
                    "bandwidth": self.bandwidth,
                    "fft_size": self.fft_size,
                    "sample_rate": self.bandwidth,
                    "center_freq": 14074000,
                }
            )

        except Exception as e:
            logger.error(f"Failed to start SDR stream: {e}")
            await self.stop_sdr_stream()
            await self.broadcast_sdr_data(
                {"type": "sdr_error", "message": f"SDR initialization failed: {str(e)}"}
            )

    async def _stream_sdr_data(self, rx_stream):
        """Stream IQ data and perform FFT analysis"""
        buffer_size = self.fft_size

        try:
            while self.sdr_device:
                # Read IQ samples
                buffer = np.array([0] * buffer_size, np.complex64)
                sr = self.sdr_device.readStream(rx_stream, [buffer], buffer_size)

                if sr.ret != buffer_size:
                    continue

                # Perform FFT
                fft_data = np.fft.fftshift(np.fft.fft(buffer))
                power_spectrum = 20 * np.log10(
                    np.abs(fft_data) + 1e-10
                )  # Convert to dB

                # Get current radio frequency for centering
                center_freq = 14074000  # Default
                if radio_connected and radio:
                    try:
                        status = radio.get_status()
                        center_freq = status.frequency_a
                    except:
                        pass

                # Update SDR center frequency to match radio
                try:
                    current_sdr_freq = self.sdr_device.getFrequency(
                        SoapySDR.SOAPY_SDR_RX, 0
                    )
                    if (
                        abs(current_sdr_freq - center_freq) > 1000
                    ):  # More than 1 kHz difference
                        self.sdr_device.setFrequency(
                            SoapySDR.SOAPY_SDR_RX, 0, center_freq
                        )
                except:
                    pass

                bins_list = power_spectrum.tolist()
                ts = time.time()
                self.latest_bins = bins_list
                self.latest_center_freq = center_freq
                self.latest_timestamp = ts

                # Broadcast FFT data
                await self.broadcast_sdr_data(
                    {
                        "type": "sdr_fft",
                        "bins": bins_list,
                        "center_freq": center_freq,
                        "bandwidth": self.bandwidth,
                        "timestamp": ts,
                    }
                )

                # Control frame rate
                await asyncio.sleep(1.0 / self.frame_rate)

        except asyncio.CancelledError:
            pass
        except Exception as e:
            logger.error(f"SDR streaming error: {e}")
            await self.broadcast_sdr_data(
                {"type": "sdr_error", "message": f"Streaming error: {str(e)}"}
            )
        finally:
            await self.stop_sdr_stream()

    async def capture_fft_snapshot(
        self,
        center_freq_hz: int,
        *,
        bandwidth_hz: Optional[int] = None,
        fft_size: Optional[int] = None,
        frames: int = 4,
    ) -> Dict:
        """Capture a standalone SDR FFT snapshot without requiring a WebSocket client."""
        if not SOAPY_SDR_AVAILABLE:
            raise RuntimeError("SoapySDR not available")

        bandwidth = int(bandwidth_hz or self.bandwidth)
        size = int(fft_size or self.fft_size)
        frame_count = max(1, int(frames))

        valid_bandwidths = [200000, 500000, 1000000, 2000000, 5000000, 10000000]
        valid_sizes = [256, 512, 1024, 2048]
        if bandwidth not in valid_bandwidths:
            raise ValueError(f"Invalid bandwidth. Must be one of: {valid_bandwidths}")
        if size not in valid_sizes:
            raise ValueError(f"Invalid FFT size. Must be one of: {valid_sizes}")

        stream = None
        device = None
        try:
            device = SoapySDR.Device(dict(driver="sdrplay"))
            device.setSampleRate(SoapySDR.SOAPY_SDR_RX, 0, bandwidth)
            device.setFrequency(SoapySDR.SOAPY_SDR_RX, 0, int(center_freq_hz))
            device.setGain(SoapySDR.SOAPY_SDR_RX, 0, "IFGR", 40)
            device.setGain(SoapySDR.SOAPY_SDR_RX, 0, "RFGR", 7)
            device.setAntenna(SoapySDR.SOAPY_SDR_RX, 0, "RX")

            stream = device.setupStream(
                SoapySDR.SOAPY_SDR_RX, SoapySDR.SOAPY_SDR_CF32
            )
            device.activateStream(stream)

            acc = None
            valid_frames = 0
            window = np.hanning(size)
            for _ in range(frame_count):
                buffer = np.empty(size, np.complex64)
                sr = device.readStream(stream, [buffer], size, timeoutUs=200000)
                sample_count = int(sr.ret)
                if sample_count <= 0:
                    continue
                frame = buffer[:sample_count]
                if sample_count < size:
                    frame = np.pad(frame, (0, size - sample_count))
                spectrum = np.fft.fftshift(np.fft.fft(frame * window))
                power = 20 * np.log10(np.abs(spectrum) + 1e-10)
                acc = power if acc is None else acc + power
                valid_frames += 1

            if not valid_frames or acc is None:
                raise RuntimeError("No SDR frames captured")

            bins = (acc / valid_frames).tolist()
            ts = time.time()
            self.latest_bins = bins
            self.latest_center_freq = int(center_freq_hz)
            self.latest_timestamp = ts
            return {
                "source": "snapshot",
                "center_freq": int(center_freq_hz),
                "bandwidth": bandwidth,
                "fft_size": size,
                "frames": valid_frames,
                "timestamp": ts,
                "bins": bins,
            }
        finally:
            try:
                if device and stream is not None:
                    device.deactivateStream(stream)
                    device.closeStream(stream)
            except Exception:
                pass

    async def stop_sdr_stream(self):
        """Stop SDR streaming and cleanup"""
        if self.streaming_task and not self.streaming_task.done():
            self.streaming_task.cancel()
            try:
                await self.streaming_task
            except asyncio.CancelledError:
                pass
        self.streaming_task = None

        if self.sdr_device:
            try:
                # Close all streams
                self.sdr_device.closeStream()
                self.sdr_device.deactivateStream()
                self.sdr_device = None
            except:
                pass

        logger.info("SDR streaming stopped")

        # Notify clients
        await self.broadcast_sdr_data({"type": "sdr_status", "status": "stopped"})

    async def set_bandwidth(self, bandwidth_hz: int):
        """Change SDR bandwidth"""
        valid_bandwidths = [200000, 500000, 1000000, 2000000, 5000000, 10000000]
        if bandwidth_hz not in valid_bandwidths:
            raise ValueError(f"Invalid bandwidth. Must be one of: {valid_bandwidths}")

        self.bandwidth = bandwidth_hz

        # Restart stream with new bandwidth if active
        if self.sdr_device:
            await self.stop_sdr_stream()
            await self.start_sdr_stream()

    async def set_fft_size(self, fft_size: int):
        """Change FFT size"""
        valid_sizes = [256, 512, 1024, 2048]
        if fft_size not in valid_sizes:
            raise ValueError(f"Invalid FFT size. Must be one of: {valid_sizes}")

        self.fft_size = fft_size


manager = ConnectionManager()
audio_manager = AudioConnectionManager()
sdr_manager = SDRConnectionManager()
cw_locator = CWFrequencyLocator()

# ── Radio Control Functions ──────────────────────────────────


async def connect_radio():
    """Connect to the radio and start monitoring."""
    global radio, radio_connected

    try:
        if radio:
            try:
                radio.disconnect()
            except Exception:
                pass

        last_error = None
        candidates = discover_radio_port_candidates(radio_config.get("port"))
        logger.info(f"Trying CAT ports: {candidates}")

        for port in candidates:
            try:
                candidate_radio = FT991A(port, radio_config["baudrate"])
                if candidate_radio.connect():
                    radio = candidate_radio
                    radio_connected = True
                    radio_config["port"] = port
                    logger.info(f"Connected to FT-991A on {port}")
                    await manager.broadcast(
                        {
                            "type": "connection",
                            "status": "connected",
                            "port": port,
                            "candidates": candidates,
                        }
                    )
                    asyncio.create_task(monitor_radio())
                    return
                candidate_radio.disconnect()
            except Exception as err:
                last_error = err
                logger.warning(f"CAT connect attempt failed on {port}: {err}")

        radio = None
        radio_connected = False
        message = str(last_error) if last_error else "No response from any CAT port"
        logger.error(message)
        await manager.broadcast(
            {
                "type": "connection",
                "status": "failed",
                "message": message,
                "candidates": candidates,
            }
        )

    except Exception as e:
        logger.error(f"Radio connection error: {e}")
        radio = None
        radio_connected = False
        await manager.broadcast(
            {"type": "connection", "status": "error", "message": str(e)}
        )


async def disconnect_radio():
    """Disconnect from the radio."""
    global radio, radio_connected

    if radio:
        try:
            # Safety: ensure PTT is off
            radio.ptt_off()
        except:
            pass
        radio.disconnect()
        radio = None
        radio_connected = False
        logger.info("Disconnected from radio")
        await manager.broadcast({"type": "connection", "status": "disconnected"})


async def get_radio_or_reconnect() -> FT991A:
    """Return a connected radio, attempting CAT rediscovery on demand."""
    global radio, radio_connected
    if not radio_connected or not radio:
        await connect_radio()
    if not radio_connected or not radio:
        raise HTTPException(status_code=503, detail="Radio not connected")
    return radio


async def monitor_radio():
    """Background task to monitor radio status and broadcast updates."""
    global radio, radio_connected

    while radio_connected and radio:
        try:
            # Get comprehensive status
            status = radio.get_status()

            # Broadcast to all connected clients
            await manager.broadcast(
                {
                    "type": "status",
                    "timestamp": datetime.now().isoformat(),
                    "data": {
                        "frequency_a": status.frequency_a,
                        "frequency_b": status.frequency_b,
                        "mode": status.mode,
                        "tx_active": status.tx_active,
                        "squelch_open": status.squelch_open,
                        "s_meter": status.s_meter,
                        "power_output": status.power_output,
                        "swr": status.swr,
                        "tx_lockout": tx_lockout,
                        "squelch_level": radio.get_squelch_level(),
                    },
                }
            )

            # Sleep before next poll
            await asyncio.sleep(0.5)  # 2 Hz update rate

        except Exception as e:
            logger.error(f"Monitor error: {e}")
            radio_connected = False
            await manager.broadcast(
                {"type": "connection", "status": "lost", "message": str(e)}
            )
            break


# ── Static Files ─────────────────────────────────────────────

# Create static directory if it doesn't exist
static_dir = Path(__file__).parent / "static"
static_dir.mkdir(exist_ok=True)

# Mount static files
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")

# ── Web Routes ───────────────────────────────────────────────


@app.get("/", response_class=HTMLResponse)
async def get_root():
    """Serve the main HTML interface."""
    try:
        return FileResponse(str(static_dir / "index.html"))
    except FileNotFoundError:
        return HTMLResponse("""
        <html>
        <body>
        <h1>FT-991A Web GUI</h1>
        <p>Frontend not found. Please ensure the static files are properly packaged.</p>
        </body>
        </html>
        """)


# ── API Endpoints ────────────────────────────────────────────


@app.get("/api/version")
async def api_version():
    """Get package version."""
    try:
        from ft991a import __version__

        return {"version": __version__}
    except Exception:
        return {"version": "unknown"}


@app.get("/api/status")
async def api_status():
    """Get current radio status."""
    try:
        active_radio = await get_radio_or_reconnect()
        return {
            "connected": True,
            "port": radio_config.get("port"),
            "status": build_radio_status_payload(active_radio),
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/api/rx/state")
async def api_rx_state():
    """Get parsed read-only IF state alongside normal status fields."""
    try:
        active_radio = await get_radio_or_reconnect()
        return {
            "connected": True,
            "port": radio_config.get("port"),
            "status": build_radio_status_payload(active_radio),
            "if_state": active_radio.get_if_state(),
            "controls": build_receive_controls_payload(active_radio),
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/api/rx/capabilities")
async def api_rx_capabilities():
    """Describe the receive-side controls and CW band surfaces exposed by the API."""
    return build_rx_capabilities_payload()


@app.get("/api/rx/controls")
async def api_rx_controls():
    """Get the current receive-side control state."""
    try:
        active_radio = await get_radio_or_reconnect()
        return {
            "connected": True,
            "port": radio_config.get("port"),
            "controls": build_receive_controls_payload(active_radio),
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/rx/cw_profile")
async def api_apply_rx_cw_profile(req: CWRXProfileRequest):
    """Apply an RX-only CW profile derived from documented FT-991A controls."""
    try:
        active_radio = await get_radio_or_reconnect()
        before = build_receive_controls_payload(active_radio)
        after = apply_rx_profile(active_radio, req)
        return {
            "success": True,
            "port": radio_config.get("port"),
            "before": before,
            "after": after,
        }
    except HTTPException:
        raise
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/api/squelch")
async def api_get_squelch():
    """Get main RX squelch level (0-255)."""
    try:
        active_radio = await get_radio_or_reconnect()
        return {"success": True, "squelch_level": active_radio.get_squelch_level()}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/squelch")
async def api_set_squelch(req: SquelchRequest):
    """Set main RX squelch level (0-255)."""
    try:
        active_radio = await get_radio_or_reconnect()
        active_radio.set_squelch_level(req.level)
        return {"success": True, "squelch_level": active_radio.get_squelch_level()}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/frequency/a")
async def set_frequency_a(req: FrequencyRequest):
    """Set VFO-A frequency."""
    try:
        active_radio = await get_radio_or_reconnect()
        active_radio.set_frequency_a(req.frequency)
        return {"success": True, "frequency": req.frequency}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/frequency/b")
async def set_frequency_b(req: FrequencyRequest):
    """Set VFO-B frequency."""
    try:
        active_radio = await get_radio_or_reconnect()
        active_radio.set_frequency_b(req.frequency)
        return {"success": True, "frequency": req.frequency}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/mode")
async def set_mode(req: ModeRequest):
    """Set operating mode."""
    try:
        active_radio = await get_radio_or_reconnect()
        # Convert mode name to enum
        mode = Mode[req.mode]
        active_radio.set_mode(mode)
        return {"success": True, "mode": req.mode}
    except KeyError:
        raise HTTPException(status_code=400, detail=f"Invalid mode: {req.mode}")
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/power")
async def set_power(req: PowerRequest):
    """Set TX power level."""
    try:
        active_radio = await get_radio_or_reconnect()
        active_radio.set_power_level(req.power)
        return {"success": True, "power": req.power}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/band")
async def set_band(req: BandRequest):
    """Set band (changes frequency)."""
    try:
        active_radio = await get_radio_or_reconnect()
        # Convert band name to enum
        band_name = (
            f"HF_{req.band}" if not req.band.startswith(("VHF_", "UHF_")) else req.band
        )
        band = Band[band_name]
        active_radio.set_band(band)
        return {"success": True, "band": req.band, "frequency": band.value}
    except KeyError:
        raise HTTPException(status_code=400, detail=f"Invalid band: {req.band}")
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/ptt")
async def set_ptt(req: PTTRequest):
    """Control PTT (Push-To-Talk)."""
    global tx_lockout

    if tx_lockout:
        raise HTTPException(status_code=423, detail="TX lockout enabled")

    try:
        active_radio = await get_radio_or_reconnect()
        if req.enable:
            active_radio.ptt_on()
            logger.warning("PTT ON via web interface")
        else:
            active_radio.ptt_off()
            logger.info("PTT OFF via web interface")

        return {"success": True, "ptt": req.enable}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/lockout")
async def toggle_tx_lockout():
    """Toggle TX lockout safety feature."""
    global tx_lockout

    tx_lockout = not tx_lockout
    logger.info(f"TX lockout {'enabled' if tx_lockout else 'disabled'}")

    # If enabling lockout, ensure PTT is off
    if tx_lockout and radio_connected and radio:
        try:
            radio.ptt_off()
        except:
            pass

    return {"success": True, "lockout": tx_lockout}


@app.get("/api/modes")
async def get_modes():
    """Get available operating modes."""
    return {"modes": [mode.name for mode in Mode]}


@app.get("/api/bands")
async def get_bands():
    """Get available bands."""
    return {"bands": [band.name for band in Band]}


@app.get("/api/audio/devices")
async def get_audio_devices():
    """List available ALSA capture devices for audio setup."""
    try:
        # Get detailed device list
        result = subprocess.run(["arecord", "-l"], capture_output=True, text=True)
        if result.returncode != 0:
            return {"success": False, "error": "arecord not available"}

        devices = []
        current_card = None

        for line in result.stdout.split("\n"):
            line = line.strip()
            if line.startswith("card "):
                # Parse card line: "card 1: Device [USB Audio CODEC], device 0: USB Audio [USB Audio]"
                parts = line.split(":", 2)
                if len(parts) >= 2:
                    card_info = parts[0]  # "card 1"
                    device_info = parts[1].strip()  # "Device [USB Audio CODEC]"

                    card_num = card_info.split()[1]
                    if "[" in device_info and "]" in device_info:
                        device_name = device_info.split("[")[1].split("]")[0]
                    else:
                        device_name = device_info

                    current_card = {
                        "card_num": int(card_num),
                        "name": device_name,
                        "devices": [],
                    }

            elif line.startswith("  Subdevices:") and current_card:
                # Parse subdevice info
                subdevice_count = line.split(":")[1].strip().split("/")[0]
                current_card["subdevices"] = int(subdevice_count)

                # Create device entry
                device_entry = {
                    "card": current_card["card_num"],
                    "device": 0,  # Usually device 0 for audio
                    "name": current_card["name"],
                    "hw_name": f"hw:{current_card['card_num']},0",
                    "card_name": f"hw:CARD={current_card['name'].replace(' ', '').replace('-', '')[:8]}",
                    "is_usb_codec": "USB Audio CODEC" in current_card["name"]
                    or "PCM2903B" in current_card["name"],
                    "subdevices": current_card.get("subdevices", 1),
                }
                devices.append(device_entry)
                current_card = None

        # Also get PCM device list for additional info
        pcm_result = subprocess.run(["arecord", "-L"], capture_output=True, text=True)
        pcm_info = pcm_result.stdout if pcm_result.returncode == 0 else ""

        return {
            "success": True,
            "devices": devices,
            "raw_output": result.stdout,
            "pcm_info": pcm_info,
        }

    except Exception as e:
        return {"success": False, "error": str(e)}


@app.post("/api/vfo/swap")
async def swap_vfo():
    """Swap VFO-A and VFO-B."""
    try:
        active_radio = await get_radio_or_reconnect()
        active_radio.swap_vfo()
        return {"success": True}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/vfo/a-to-b")
async def vfo_a_to_b():
    """Copy VFO-A to VFO-B."""
    try:
        active_radio = await get_radio_or_reconnect()
        active_radio.vfo_a_to_b()
        return {"success": True}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/tuner/on")
async def tuner_on():
    """Turn antenna tuner ON."""
    try:
        active_radio = await get_radio_or_reconnect()
        active_radio.tuner_on()
        return {"success": True, "tuner": "on"}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/tuner/off")
async def tuner_off():
    """Turn antenna tuner OFF."""
    try:
        active_radio = await get_radio_or_reconnect()
        active_radio.tuner_off()
        return {"success": True, "tuner": "off"}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/tuner/start")
async def tuner_start():
    """Start antenna auto-tune."""
    try:
        active_radio = await get_radio_or_reconnect()
        active_radio.tuner_start()
        return {"success": True, "tuner": "tuning"}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/api/tuner/status")
async def tuner_status():
    """Get tuner status."""
    try:
        active_radio = await get_radio_or_reconnect()
        status = active_radio.tuner_status()
        labels = {"0": "off", "1": "on", "2": "tuning"}
        return {"status": labels.get(status, "unknown"), "raw": status}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/config")
async def update_config(req: ConfigRequest):
    """Update radio connection configuration."""
    global radio_config

    # Disconnect current radio
    if radio_connected:
        await disconnect_radio()

    # Update config
    radio_config["port"] = req.port
    radio_config["baudrate"] = req.baudrate

    # Attempt reconnection
    await connect_radio()

    return {"success": True, "config": radio_config}


@app.get("/api/config")
async def get_config():
    """Get current configuration."""
    return {
        "config": radio_config,
        "connected": radio_connected,
        "port_candidates": discover_radio_port_candidates(radio_config.get("port")),
    }


# ── Memory Management Endpoints ──────────────────────────────


@app.get("/api/memory/list")
async def get_memory_list():
    """Scan and list all memory channels (MR001; through MR099;)."""
    if not radio_connected or not radio:
        raise HTTPException(status_code=503, detail="Radio not connected")

    try:
        logger.info("Starting memory channel scan (MR001-MR099)")
        channels = {}

        # Scan all 99 memory channels
        for ch in range(1, 100):
            channel_str = f"{ch:03d}"

            try:
                # Send MR command to read memory channel
                mr_command = f"MR{channel_str};"
                logger.debug(f"Sending CAT: {mr_command}")

                # Use raw serial for memory commands
                radio.serial.write(mr_command.encode())
                response = radio.serial.readline().decode().strip()

                logger.debug(f"MR{channel_str} response: {response}")

                # Parse MR response format
                if response.startswith(f"MR{channel_str}") and len(response) > 10:
                    # Parse memory data from response
                    # Format: MR001+14074000000100200000000000000000000000000;
                    #         MRccc+ffffffffmmm...other...

                    try:
                        # Extract frequency (positions 6-16, 11 digits)
                        freq_str = response[6:17]  # 11 digit frequency in Hz
                        frequency = int(freq_str) if freq_str.isdigit() else 0

                        # Extract mode (position 17-19, 3 digits)
                        mode_code = response[17:20] if len(response) > 19 else "000"
                        mode_map = {
                            "001": "LSB",
                            "002": "USB",
                            "003": "CW",
                            "004": "FM",
                            "005": "AM",
                            "006": "RTTY-LSB",
                            "007": "CW-R",
                            "008": "DATA-LSB",
                            "009": "RTTY-USB",
                            "010": "DATA-FM",
                            "011": "FM-N",
                            "012": "DATA-USB",
                            "013": "AM-N",
                            "014": "C4FM",
                        }
                        mode = mode_map.get(mode_code, "UNK")

                        # Store channel data if frequency is valid
                        if frequency > 0:
                            channels[channel_str] = {
                                "channel": channel_str,
                                "frequency": frequency,
                                "mode": mode,
                                "mode_code": mode_code,
                                "raw_response": response,
                                "label": "",  # TODO: Implement MT command for labels
                                "ctcss": "OFF",  # TODO: Parse CTCSS from response
                                "is_current": False,  # Will be updated if needed
                            }
                            logger.debug(
                                f"Memory {channel_str}: {frequency} Hz, {mode}"
                            )

                    except (ValueError, IndexError) as e:
                        logger.warning(
                            f"Failed to parse memory {channel_str} response: {e}"
                        )
                        continue

            except Exception as e:
                logger.warning(f"Error reading memory {channel_str}: {e}")
                continue

        logger.info(f"Memory scan completed: {len(channels)} programmed channels found")

        return {
            "success": True,
            "channels": channels,
            "total_programmed": len(channels),
            "total_capacity": 99,
        }

    except Exception as e:
        logger.error(f"Memory scan failed: {e}")
        raise HTTPException(status_code=500, detail=f"Memory scan failed: {str(e)}")


@app.post("/api/memory/recall")
async def recall_memory_channel(req: MemoryRecallRequest):
    """Recall memory channel to VFO-A (MC command)."""
    if not radio_connected or not radio:
        raise HTTPException(status_code=503, detail="Radio not connected")

    if req.channel < 1 or req.channel > 99:
        raise HTTPException(status_code=400, detail="Channel must be between 1 and 99")

    try:
        channel_str = f"{req.channel:03d}"
        mc_command = f"MC{channel_str};"

        logger.info(f"Recalling memory channel {channel_str}")
        logger.debug(f"Sending CAT: {mc_command}")

        # Send MC command to recall memory
        radio.serial.write(mc_command.encode())

        # Give radio time to process
        await asyncio.sleep(0.1)

        # Verify by reading current frequency
        try:
            status = radio.get_status()
            logger.info(
                f"Memory {channel_str} recalled - VFO-A: {status.frequency_a} Hz"
            )
        except:
            pass

        return {
            "success": True,
            "channel": channel_str,
            "message": f"Memory channel {channel_str} recalled to VFO-A",
        }

    except Exception as e:
        logger.error(f"Memory recall failed: {e}")
        raise HTTPException(status_code=500, detail=f"Memory recall failed: {str(e)}")


@app.post("/api/memory/store")
async def store_to_memory_channel(req: MemoryStoreRequest):
    """Store current VFO-A to memory channel (MW command)."""
    if not radio_connected or not radio:
        raise HTTPException(status_code=503, detail="Radio not connected")

    if req.channel < 1 or req.channel > 99:
        raise HTTPException(status_code=400, detail="Channel must be between 1 and 99")

    try:
        channel_str = f"{req.channel:03d}"

        # Get current VFO-A status first
        status = radio.get_status()
        current_freq = status.frequency_a
        current_mode = status.mode

        logger.info(
            f"Storing VFO-A to memory {channel_str}: {current_freq} Hz, {current_mode}"
        )

        # Send MW command to store current VFO-A to memory
        # MW command format: MW{ccc}; - stores current VFO-A to channel ccc
        mw_command = f"MW{channel_str};"
        logger.debug(f"Sending CAT: {mw_command}")

        radio.serial.write(mw_command.encode())

        # Give radio time to process
        await asyncio.sleep(0.2)

        return {
            "success": True,
            "channel": channel_str,
            "stored_frequency": current_freq,
            "stored_mode": current_mode,
            "message": f"VFO-A stored to memory channel {channel_str}",
        }

    except Exception as e:
        logger.error(f"Memory store failed: {e}")
        raise HTTPException(status_code=500, detail=f"Memory store failed: {str(e)}")


@app.post("/api/memory/clear")
async def clear_memory_channel(req: MemoryClearRequest):
    """Clear/delete a memory channel."""
    if not radio_connected or not radio:
        raise HTTPException(status_code=503, detail="Radio not connected")

    if req.channel < 1 or req.channel > 99:
        raise HTTPException(status_code=400, detail="Channel must be between 1 and 99")

    try:
        channel_str = f"{req.channel:03d}"

        logger.info(f"Clearing memory channel {channel_str}")

        # The FT-991A doesn't have a direct "clear memory" command
        # We need to store an invalid/empty frequency to clear it
        # Alternative: Use MW with a specific "empty" pattern

        # Method: Store a very low frequency (30 kHz) which effectively "clears" the channel
        # First set VFO to minimum frequency, then store, then restore VFO
        original_status = radio.get_status()
        original_freq = original_status.frequency_a

        # Set VFO-A to minimum frequency (30 kHz)
        radio.set_frequency_a(30000)
        await asyncio.sleep(0.1)

        # Store this "empty" frequency to memory
        mw_command = f"MW{channel_str};"
        radio.serial.write(mw_command.encode())
        await asyncio.sleep(0.2)

        # Restore original VFO-A frequency
        radio.set_frequency_a(original_freq)

        logger.info(f"Memory channel {channel_str} cleared (set to 30 kHz)")

        return {
            "success": True,
            "channel": channel_str,
            "message": f"Memory channel {channel_str} cleared",
        }

    except Exception as e:
        logger.error(f"Memory clear failed: {e}")
        raise HTTPException(status_code=500, detail=f"Memory clear failed: {str(e)}")


# ── Scanner Endpoints ────────────────────────────────────────


@app.post("/api/scan")
async def start_scan(req: ScanRequest):
    """Start frequency scanning."""
    global scan_active, scan_task

    if not radio_connected or not radio:
        raise HTTPException(status_code=503, detail="Radio not connected")

    if scan_active:
        raise HTTPException(status_code=409, detail="Scan already in progress")

    # Validate parameters
    if req.start_freq >= req.end_freq:
        raise HTTPException(
            status_code=400, detail="Start frequency must be less than end frequency"
        )

    if req.start_freq < 30000 or req.end_freq > 470000000:
        raise HTTPException(
            status_code=400, detail="Frequency range must be between 30 kHz and 470 MHz"
        )

    if req.step < 100 or req.step > 1000000:
        raise HTTPException(
            status_code=400, detail="Step size must be between 100 Hz and 1 MHz"
        )

    try:
        scan_active = True
        scan_task = asyncio.create_task(scan_frequency_range(req))

        logger.info(
            f"Started scan: {req.start_freq}-{req.end_freq} Hz, step {req.step} Hz, threshold {req.threshold}"
        )

        return {
            "success": True,
            "message": "Scan started",
            "parameters": {
                "start_freq": req.start_freq,
                "end_freq": req.end_freq,
                "step": req.step,
                "threshold": req.threshold,
                "estimated_points": int((req.end_freq - req.start_freq) / req.step),
            },
        }

    except Exception as e:
        scan_active = False
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/scan/stop")
async def stop_scan():
    """Stop current frequency scan."""
    global scan_active, scan_task

    if not scan_active:
        return {"success": True, "message": "No scan in progress"}

    scan_active = False

    if scan_task:
        scan_task.cancel()
        try:
            await scan_task
        except asyncio.CancelledError:
            pass
        scan_task = None

    # Broadcast scan stopped
    await manager.broadcast(
        {"type": "scan_complete", "message": "Scan stopped by user"}
    )

    logger.info("Scan stopped by user request")
    return {"success": True, "message": "Scan stopped"}


async def scan_frequency_range(req: ScanRequest):
    """Background task to perform the frequency scan."""
    global scan_active

    try:
        current_freq = req.start_freq
        scan_count = 0
        active_count = 0
        start_time = time.time()

        while scan_active and current_freq <= req.end_freq:
            try:
                # Set radio frequency
                radio.set_frequency_a(current_freq)

                # Wait for radio to settle (200ms as specified)
                await asyncio.sleep(0.2)

                # Read S-meter
                status = radio.get_status()
                s_meter = status.s_meter
                mode = status.mode

                scan_count += 1

                # Check if signal is above threshold
                is_active = s_meter >= req.threshold
                if is_active:
                    active_count += 1

                # Broadcast progress
                await manager.broadcast(
                    {
                        "type": "scan_progress",
                        "frequency": current_freq,
                        "s_meter": s_meter,
                        "mode": mode,
                        "is_active": is_active,
                        "scan_count": scan_count,
                        "active_count": active_count,
                        "progress": (
                            (current_freq - req.start_freq)
                            / (req.end_freq - req.start_freq)
                        )
                        * 100,
                    }
                )

                # Move to next frequency
                current_freq += req.step

                # Small delay to prevent overwhelming the radio
                await asyncio.sleep(0.05)

            except Exception as e:
                logger.error(f"Error during scan at {current_freq} Hz: {e}")
                # Continue scan despite individual frequency errors
                current_freq += req.step
                continue

        # Scan completed successfully
        if scan_active:  # Only if not cancelled
            elapsed_time = time.time() - start_time

            await manager.broadcast(
                {
                    "type": "scan_complete",
                    "message": f"Scan completed: {scan_count} frequencies, {active_count} active signals",
                    "summary": {
                        "total_frequencies": scan_count,
                        "active_signals": active_count,
                        "elapsed_time": round(elapsed_time, 1),
                        "scan_rate": (
                            round(scan_count / elapsed_time, 1)
                            if elapsed_time > 0
                            else 0
                        ),
                    },
                }
            )

            logger.info(
                f"Scan completed: {scan_count} frequencies in {elapsed_time:.1f}s, {active_count} active"
            )

    except asyncio.CancelledError:
        logger.info("Scan cancelled")
        raise
    except Exception as e:
        logger.error(f"Scan error: {e}")
        await manager.broadcast({"type": "scan_error", "message": str(e)})
    finally:
        scan_active = False


# ── Setup Wizard Endpoints ───────────────────────────────────


@app.get("/api/setup/ports")
async def get_setup_ports():
    """List available serial ports for setup wizard."""
    try:
        ports = []
        for port in serial.tools.list_ports.comports():
            port_info = {
                "device": port.device,
                "description": port.description or "Unknown device",
                "manufacturer": port.manufacturer or "Unknown",
                "vid": port.vid,
                "pid": port.pid,
            }
            # Add helpful info for common devices
            if "CP210" in port.description or "Silicon Labs" in (
                port.manufacturer or ""
            ):
                port_info["likely_ft991a"] = True
                port_info["description"] += " (CP2105 - likely FT-991A)"
            else:
                port_info["likely_ft991a"] = False

            ports.append(port_info)

        return {"success": True, "ports": ports}
    except Exception as e:
        return {"success": False, "error": str(e)}


@app.post("/api/setup/test")
async def test_setup_connection(req: SetupTestRequest):
    """Test a port+baud combination for setup wizard."""
    test_radio = None
    try:
        # Create temporary radio connection
        test_radio = FT991A(req.port, req.baudrate)

        # Attempt connection
        if test_radio.connect():
            # Test basic CAT command
            status = test_radio.get_status()

            # Get radio identification if possible
            radio_info = {
                "model": "FT-991A",  # We know this from the CAT implementation
                "frequency_a": status.frequency_a,
                "mode": status.mode,
                "connected": True,
            }

            test_radio.disconnect()
            return {"success": True, "connected": True, "radio_info": radio_info}
        else:
            if test_radio:
                test_radio.disconnect()
            return {
                "success": False,
                "connected": False,
                "error": "No response from radio",
            }

    except Exception as e:
        if test_radio:
            try:
                test_radio.disconnect()
            except:
                pass
        return {"success": False, "connected": False, "error": str(e)}


@app.post("/api/setup/save")
async def save_setup_config(req: SetupSaveRequest):
    """Save setup wizard configuration."""
    global radio_config

    try:
        # Disconnect current radio if connected
        if radio_connected:
            await disconnect_radio()

        # Update configuration
        radio_config["port"] = req.port
        radio_config["baudrate"] = req.baudrate

        # Store callsign in config if provided
        if req.callsign:
            radio_config["callsign"] = req.callsign.upper()

        # Attempt connection
        await connect_radio()

        return {"success": True, "connected": radio_connected, "config": radio_config}

    except Exception as e:
        return {"success": False, "error": str(e)}


# ── WebSocket Endpoint ───────────────────────────────────────


@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    """WebSocket for real-time updates."""
    await manager.connect(websocket)
    try:
        # Send initial status
        if radio_connected and radio:
            status = radio.get_status()
            await websocket.send_text(
                json.dumps(
                    {
                        "type": "status",
                        "timestamp": datetime.now().isoformat(),
                        "data": {
                            "frequency_a": status.frequency_a,
                            "frequency_b": status.frequency_b,
                            "mode": status.mode,
                            "tx_active": status.tx_active,
                            "squelch_open": status.squelch_open,
                            "s_meter": status.s_meter,
                            "power_output": status.power_output,
                            "swr": status.swr,
                            "tx_lockout": tx_lockout,
                        },
                    }
                )
            )

        # Keep connection alive
        while True:
            await websocket.receive_text()
    except WebSocketDisconnect:
        manager.disconnect(websocket)
    except Exception as e:
        logger.error(f"WebSocket error: {e}")
        manager.disconnect(websocket)


@app.websocket("/ws/audio")
async def audio_websocket_endpoint(websocket: WebSocket):
    """WebSocket for audio streaming from PCM2903B CODEC."""
    await audio_manager.connect(websocket)
    try:
        # Send initial audio info
        await websocket.send_text(
            json.dumps(
                {
                    "type": "audio_info",
                    "format": "S16_LE",
                    "sample_rate": 48000,
                    "channels": 1,
                    "chunk_size": 4096,
                }
            )
        )

        # Keep connection alive and handle client messages
        while True:
            try:
                message = await websocket.receive_text()
                # Handle client control messages if needed
                data = json.loads(message)
                if data.get("type") == "ping":
                    await websocket.send_text(json.dumps({"type": "pong"}))
            except Exception:
                # Client disconnected or sent binary data
                break

    except WebSocketDisconnect:
        audio_manager.disconnect(websocket)
    except Exception as e:
        logger.error(f"Audio WebSocket error: {e}")
        audio_manager.disconnect(websocket)


@app.websocket("/ws/sdr")
async def sdr_websocket_endpoint(websocket: WebSocket):
    """WebSocket for SDR streaming from RSP2pro."""
    await sdr_manager.connect(websocket)
    try:
        # Send initial SDR info
        await websocket.send_text(
            json.dumps(
                {
                    "type": "sdr_info",
                    "driver": "sdrplay",
                    "device": "RSP2pro",
                    "serial": "1717050C11",
                    "available": SOAPY_SDR_AVAILABLE,
                    "bandwidth": sdr_manager.bandwidth,
                    "fft_size": sdr_manager.fft_size,
                }
            )
        )

        # Keep connection alive and handle client messages
        while True:
            try:
                message = await websocket.receive_text()
                data = json.loads(message)

                if data.get("type") == "ping":
                    await websocket.send_text(json.dumps({"type": "pong"}))
                elif data.get("type") == "set_bandwidth":
                    try:
                        await sdr_manager.set_bandwidth(data.get("bandwidth", 2000000))
                        await websocket.send_text(
                            json.dumps(
                                {
                                    "type": "bandwidth_changed",
                                    "bandwidth": sdr_manager.bandwidth,
                                }
                            )
                        )
                    except ValueError as e:
                        await websocket.send_text(
                            json.dumps({"type": "error", "message": str(e)})
                        )
                elif data.get("type") == "set_fft_size":
                    try:
                        await sdr_manager.set_fft_size(data.get("fft_size", 1024))
                        await websocket.send_text(
                            json.dumps(
                                {
                                    "type": "fft_size_changed",
                                    "fft_size": sdr_manager.fft_size,
                                }
                            )
                        )
                    except ValueError as e:
                        await websocket.send_text(
                            json.dumps({"type": "error", "message": str(e)})
                        )

            except Exception as e:
                logger.error(f"SDR WebSocket message error: {e}")
                break

    except WebSocketDisconnect:
        sdr_manager.disconnect(websocket)
    except Exception as e:
        logger.error(f"SDR WebSocket error: {e}")
        sdr_manager.disconnect(websocket)


# ── SDR API Endpoints ────────────────────────────────────────


@app.get("/api/sdr/status")
async def get_sdr_status():
    """Get SDR device status and capabilities."""
    return {
        "available": SOAPY_SDR_AVAILABLE,
        "active_connections": len(sdr_manager.active_connections) if sdr_manager else 0,
        "streaming": sdr_manager.sdr_device is not None if sdr_manager else False,
        "bandwidth": sdr_manager.bandwidth if sdr_manager else 2000000,
        "fft_size": sdr_manager.fft_size if sdr_manager else 1024,
        "supported_bandwidths": [200000, 500000, 1000000, 2000000, 5000000, 10000000],
        "supported_fft_sizes": [256, 512, 1024, 2048],
    }


@app.post("/api/sdr/bandwidth")
async def set_sdr_bandwidth(data: dict):
    """Set SDR bandwidth."""
    if not SOAPY_SDR_AVAILABLE:
        raise HTTPException(status_code=503, detail="SoapySDR not available")

    try:
        bandwidth = data.get("bandwidth")
        if not bandwidth:
            raise HTTPException(status_code=400, detail="Bandwidth parameter required")

        await sdr_manager.set_bandwidth(int(bandwidth))
        return {"success": True, "bandwidth": sdr_manager.bandwidth}
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/sdr/fft_size")
async def set_sdr_fft_size(data: dict):
    """Set SDR FFT size."""
    if not SOAPY_SDR_AVAILABLE:
        raise HTTPException(status_code=503, detail="SoapySDR not available")

    try:
        fft_size = data.get("fft_size")
        if not fft_size:
            raise HTTPException(status_code=400, detail="FFT size parameter required")

        await sdr_manager.set_fft_size(int(fft_size))
        return {"success": True, "fft_size": sdr_manager.fft_size}
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/api/cw/bands")
async def get_cw_bands():
    """List supported CW band windows for scalable sweep clients."""
    return {
        "success": True,
        "bands": [
            resolve_cw_band_window(band=band_name)
            for band_name in normalize_cw_band_list(None)
        ],
    }


@app.post("/api/cw/copy")
async def api_cw_copy(req: CWCopyRequest):
    """Run a bounded RX-only CW copy session using the validated multimon-ng path."""
    try:
        return await run_cw_copy_capture(req)
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/api/cw/candidates")
async def get_cw_candidates(
    top_n: int = 8,
    band: Optional[str] = None,
    band_start_hz: Optional[int] = None,
    band_stop_hz: Optional[int] = None,
    center_freq_hz: Optional[int] = None,
    snapshot: bool = False,
    sdr_bandwidth: Optional[int] = None,
    sdr_fft_size: Optional[int] = None,
    min_spacing_hz: int = 150,
):
    """Locate likely CW frequencies from an SDR frame with band-aware filtering."""
    try:
        resolved_center = center_freq_hz
        if resolved_center is None:
            try:
                resolved_center = (await get_radio_or_reconnect()).get_frequency_a()
            except HTTPException:
                resolved_center = None

        search_window = resolve_cw_band_window(
            band=band,
            center_freq_hz=resolved_center,
            band_start_hz=band_start_hz,
            band_stop_hz=band_stop_hz,
        )
        target_center = int(center_freq_hz or search_window["center_hz"])
        requested_bandwidth = int(sdr_bandwidth or sdr_manager.bandwidth)
        requested_fft = int(sdr_fft_size or sdr_manager.fft_size)

        need_snapshot = (
            snapshot
            or not sdr_manager.latest_bins
            or not sdr_manager.latest_center_freq
            or abs(int(sdr_manager.latest_center_freq) - target_center)
            > max(1000, requested_bandwidth // 4)
            or requested_bandwidth != sdr_manager.bandwidth
            or requested_fft != sdr_manager.fft_size
        )

        if need_snapshot:
            sdr_frame = await sdr_manager.capture_fft_snapshot(
                target_center,
                bandwidth_hz=requested_bandwidth,
                fft_size=requested_fft,
            )
            bins = sdr_frame["bins"]
            center_freq = sdr_frame["center_freq"]
            bandwidth = sdr_frame["bandwidth"]
            fft_size = sdr_frame["fft_size"]
            timestamp = sdr_frame["timestamp"]
            source = "snapshot"
        else:
            bins = sdr_manager.latest_bins
            center_freq = int(sdr_manager.latest_center_freq)
            bandwidth = sdr_manager.bandwidth
            fft_size = sdr_manager.fft_size
            timestamp = sdr_manager.latest_timestamp
            source = "latest_frame"

        candidates = cw_locator.scan_fft(
            bins,
            center_freq,
            bandwidth,
            max(1, min(20, int(top_n))),
            scan_start_hz=search_window["start_hz"],
            scan_stop_hz=search_window["stop_hz"],
            min_spacing_hz=max(25, int(min_spacing_hz)),
        )
        return {
            "success": True,
            "source": source,
            "timestamp": timestamp,
            "center_freq": int(center_freq),
            "bandwidth": int(bandwidth),
            "fft_size": int(fft_size),
            "search_window": search_window,
            "candidates": candidates,
        }
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except RuntimeError as e:
        raise HTTPException(status_code=503, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/cw/hunt")
async def run_cw_hunt(req: CWHuntRequest):
    """Run an RX-only CW hunt snapshot and return machine-usable review guidance."""
    try:
        active_radio = await get_radio_or_reconnect()
        before_status = build_radio_status_payload(active_radio)
        before_controls = build_receive_controls_payload(active_radio)

        if req.apply_cw_profile:
            apply_rx_profile(
                active_radio,
                build_default_cw_profile_request(
                    mode=req.mode,
                    squelch_level=max(0, min(255, req.squelch_level)),
                ),
            )

        after_status = build_radio_status_payload(active_radio)
        after_controls = build_receive_controls_payload(active_radio)
        search_window = resolve_cw_band_window(
            band=req.band,
            center_freq_hz=req.center_freq_hz or after_status["frequency_a"],
            band_start_hz=req.band_start_hz,
            band_stop_hz=req.band_stop_hz,
        )

        sdr_frame = await sdr_manager.capture_fft_snapshot(
            int(req.center_freq_hz or search_window["center_hz"]),
            bandwidth_hz=req.sdr_bandwidth,
            fft_size=req.sdr_fft_size,
            frames=req.snapshot_frames,
        )
        candidates = cw_locator.scan_fft(
            sdr_frame["bins"],
            sdr_frame["center_freq"],
            sdr_frame["bandwidth"],
            max(1, min(20, int(req.top_n))),
            scan_start_hz=search_window["start_hz"],
            scan_stop_hz=search_window["stop_hz"],
            min_spacing_hz=max(25, int(req.min_spacing_hz)),
        )
        hunt_package = build_cw_hunt_package(
            candidates,
            reference_freq_hz=req.reference_freq_hz or before_status["frequency_a"],
            window_gap_hz=req.window_gap_hz,
            cycle_ms=req.cycle_ms,
            min_dwell_ms=req.min_dwell_ms,
            upcoming_count=req.upcoming_count,
            action_ready_ms=req.action_ready_ms,
            action_critical_ms=req.action_critical_ms,
            hotspot_half_width_hz=max(25, int(req.min_spacing_hz // 2)),
        )
        result = {
            "success": True,
            "timestamp": sdr_frame["timestamp"],
            "radio": {
                "port": radio_config.get("port"),
                "status_before": before_status,
                "status_after": after_status,
                "controls_before": before_controls,
                "controls_after": after_controls,
                "if_state": active_radio.get_if_state(),
            },
            "search_window": search_window,
            "sdr": {
                "source": sdr_frame["source"],
                "center_freq": sdr_frame["center_freq"],
                "bandwidth": sdr_frame["bandwidth"],
                "fft_size": sdr_frame["fft_size"],
                "frames": sdr_frame["frames"],
            },
            "candidates": candidates,
            **hunt_package,
        }
        result["recommended_frequency_hz"] = (
            result["plan"][0]["center_hz"]
            if result.get("plan")
            else (candidates[0]["freq_hz"] if candidates else None)
        )
        log_path = build_cw_hunt_log_path()
        log_path.write_text(json.dumps(serialize_payload(result), indent=2))
        result["log_path"] = str(log_path)
        return result
    except HTTPException:
        raise
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except RuntimeError as e:
        raise HTTPException(status_code=503, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/cw/sweep")
async def run_cw_sweep(req: CWSweepRequest):
    """Run a persistence-aware multi-band CW sweep for API operators."""
    try:
        active_radio = await get_radio_or_reconnect()
        before_status = build_radio_status_payload(active_radio)
        before_controls = build_receive_controls_payload(active_radio)

        if req.apply_cw_profile:
            apply_rx_profile(
                active_radio,
                build_default_cw_profile_request(
                    mode=req.mode,
                    squelch_level=max(0, min(255, req.squelch_level)),
                ),
            )

        after_status = build_radio_status_payload(active_radio)
        after_controls = build_receive_controls_payload(active_radio)
        bands = normalize_cw_band_list(req.bands)
        band_results = []

        for band_name in bands:
            search_window = resolve_cw_band_window(band=band_name)
            pass_candidates: List[List[Dict]] = []
            pass_timestamps: List[Optional[str]] = []
            pass_summaries: List[Dict] = []

            for _ in range(max(1, int(req.passes_per_band))):
                sdr_frame = await sdr_manager.capture_fft_snapshot(
                    int(search_window["center_hz"]),
                    bandwidth_hz=req.sdr_bandwidth,
                    fft_size=req.sdr_fft_size,
                    frames=req.snapshot_frames,
                )
                candidates = cw_locator.scan_fft(
                    sdr_frame["bins"],
                    sdr_frame["center_freq"],
                    sdr_frame["bandwidth"],
                    max(1, min(20, int(req.top_n_per_pass))),
                    scan_start_hz=search_window["start_hz"],
                    scan_stop_hz=search_window["stop_hz"],
                    min_spacing_hz=max(25, int(req.min_spacing_hz)),
                )
                pass_candidates.append(candidates)
                pass_timestamps.append(sdr_frame["timestamp"])
                pass_summaries.append(
                    {
                        "timestamp": sdr_frame["timestamp"],
                        "center_freq": sdr_frame["center_freq"],
                        "bandwidth": sdr_frame["bandwidth"],
                        "fft_size": sdr_frame["fft_size"],
                        "candidate_count": len(candidates),
                    }
                )

            candidates = summarize_cw_candidate_passes(
                pass_candidates,
                pass_timestamps=pass_timestamps,
                min_spacing_hz=max(25, int(req.min_spacing_hz)),
            )[: max(1, min(20, int(req.top_n_per_band)))]
            band_results.append(
                {
                    "band": band_name,
                    "search_window": search_window,
                    "passes": max(1, int(req.passes_per_band)),
                    "pass_summaries": pass_summaries,
                    "candidates": candidates,
                }
            )

        global_candidates = build_cw_sweep_shortlist(
            band_results,
            shortlist_size=max(1, min(50, int(req.shortlist_size))),
        )
        if req.reference_freq_hz is None:
            reference_freq_hz = before_status["frequency_a"]
        else:
            reference_freq_hz = req.reference_freq_hz

        result = {
            "success": True,
            "timestamp": datetime.now().isoformat(),
            "radio": {
                "port": radio_config.get("port"),
                "status_before": before_status,
                "status_after": after_status,
                "controls_before": before_controls,
                "controls_after": after_controls,
                "if_state": active_radio.get_if_state(),
            },
            "sdr": {
                "bandwidth": req.sdr_bandwidth,
                "fft_size": req.sdr_fft_size,
                "snapshot_frames": req.snapshot_frames,
                "passes_per_band": req.passes_per_band,
            },
            "bands": band_results,
            "global_candidates": global_candidates,
            "recommended_band": global_candidates[0]["band"] if global_candidates else None,
            "recommended_frequency_hz": (
                global_candidates[0]["freq_hz"] if global_candidates else None
            ),
            "reference_freq_hz": reference_freq_hz,
        }
        log_path = build_cw_sweep_log_path()
        log_path.write_text(json.dumps(serialize_payload(result), indent=2))
        result["log_path"] = str(log_path)
        return result
    except HTTPException:
        raise
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except RuntimeError as e:
        raise HTTPException(status_code=503, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/api/cw/decode_samples")
async def decode_cw_samples(req: CWSampleDecodeRequest):
    """Decode CW from sample buffer (worker validation path)."""
    if not req.samples or len(req.samples) < max(100, req.sample_rate // 5):
        raise HTTPException(status_code=400, detail="Insufficient sample buffer")

    decoder = CWDecoder(sample_rate=req.sample_rate, tone_freq=req.tone_freq)
    return decoder.decode_with_metadata(req.samples)


# ── Monitor Functions ────────────────────────────────────────


async def monitor_loop(config: dict):
    """Main monitor loop - captures, transcribes, and translates audio."""
    global monitor_clip_count, monitor_start_time

    radio_host = "192.168.10.179"
    radio_port = 2222
    radio_user = "bonsaihorn"
    audio_device = "hw:CARD=CODEC"

    # Get OpenAI API key
    env_path = Path.home() / "Projects" / "helios" / ".env"
    openai_api_key = None
    if env_path.exists():
        with open(env_path) as f:
            for line in f:
                if line.startswith("OPENAI_API_KEY="):
                    openai_api_key = line.split("=", 1)[1].strip()
                    break

    if not openai_api_key:
        logger.error("OPENAI_API_KEY not found in ~/Projects/helios/.env")
        return

    # Setup log directory
    log_dir = Path.home() / "Projects" / "lbf-ham-radio" / "logs" / "radio-monitor"
    log_dir.mkdir(parents=True, exist_ok=True)

    transcript_log = log_dir / f"transcripts_{datetime.now().strftime('%Y%m%d')}.jsonl"

    monitor_start_time = datetime.now()

    async with httpx.AsyncClient() as client:
        while monitor_active:
            try:
                monitor_clip_count += 1
                timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")

                # Get radio status
                try:
                    response = await client.get("http://localhost:8000/api/status")
                    status = response.json().get("status", {})
                    freq = status.get("frequency_a", 0)
                    freq_mhz = freq / 1000000
                    s_meter = status.get("s_meter", 0)
                    mode = status.get("mode", "?")
                except:
                    freq_mhz = 0.0
                    s_meter = 0
                    mode = "?"

                logger.info(
                    f"Monitor clip {monitor_clip_count}: {freq_mhz:.3f} MHz ({mode}), S-meter: {s_meter}"
                )

                # Skip if S-meter too low
                if s_meter < config["smeter_threshold"]:
                    await asyncio.sleep(config["interval"])
                    continue

                # Record audio via SSH
                with tempfile.NamedTemporaryFile(
                    suffix=".wav", delete=False
                ) as temp_file:
                    raw_file = temp_file.name

                ssh_cmd = f"ssh -p {radio_port} {radio_user}@{radio_host} 'arecord -D {audio_device} -f S16_LE -r 48000 -c 1 -d {config['clip_duration']} /tmp/_radio_clip.wav 2>/dev/null'"
                scp_cmd = f"scp -P {radio_port} {radio_user}@{radio_host}:/tmp/_radio_clip.wav {raw_file}"

                try:
                    # Record
                    process = await asyncio.create_subprocess_shell(
                        ssh_cmd,
                        stdout=asyncio.subprocess.PIPE,
                        stderr=asyncio.subprocess.PIPE,
                    )
                    await process.wait()

                    # Copy file
                    process = await asyncio.create_subprocess_shell(
                        scp_cmd,
                        stdout=asyncio.subprocess.PIPE,
                        stderr=asyncio.subprocess.PIPE,
                    )
                    await process.wait()

                    # Filter audio with sox
                    filt_file = raw_file.replace(".wav", "_filt.wav")
                    sox_cmd = f"sox {raw_file} {filt_file} sinc 200-3400 compand 0.3,1 6:-70,-60,-20 -5 -90 0.2 norm"
                    process = await asyncio.create_subprocess_shell(
                        sox_cmd,
                        stdout=asyncio.subprocess.PIPE,
                        stderr=asyncio.subprocess.PIPE,
                    )
                    await process.wait()

                    # Transcribe with OpenAI Whisper
                    with open(filt_file, "rb") as audio_file:
                        files = {"file": audio_file}
                        data = {
                            "model": "whisper-1",
                            "language": (
                                config["language"]
                                if config["language"] != "auto"
                                else ""
                            ),
                            "prompt": "Conversación de radioaficionados en español caribeño. Indicativos KP4, WP4, NP4, W, K, N.",
                            "response_format": "json",
                        }
                        headers = {"Authorization": f"Bearer {openai_api_key}"}

                        response = await client.post(
                            "https://api.openai.com/v1/audio/transcriptions",
                            files=files,
                            data=data,
                            headers=headers,
                        )

                        transcription = response.json()
                        spanish_text = transcription.get("text", "").strip()

                    # Validate transcript
                    if len(spanish_text) < 10 or any(
                        phrase in spanish_text.lower()
                        for phrase in [
                            "qth.*qth.*qth",
                            "señal report.*señal report",
                            "www.",
                            "visite",
                            "suscríbete",
                        ]
                    ):
                        logger.info("Invalid/noise transcript, skipping")
                        await asyncio.sleep(config["interval"])
                        continue

                    # Translate with GPT-4o-mini
                    translate_prompt = f"Translate this ham radio conversation from Spanish to English. Preserve any callsigns (like KP4ABC, WP4XYZ, W1ABC) exactly as spoken. Keep it natural.\n\n{spanish_text}"

                    translate_response = await client.post(
                        "https://api.openai.com/v1/chat/completions",
                        json={
                            "model": "gpt-4o-mini",
                            "max_tokens": 500,
                            "messages": [{"role": "user", "content": translate_prompt}],
                        },
                        headers=headers,
                    )

                    translation = translate_response.json()
                    english_text = translation["choices"][0]["message"]["content"]

                    # Extract callsigns
                    callsign_pattern = r"\b[AKNW][A-Z]?[0-9][A-Z]{1,3}\b"
                    callsigns = list(
                        set(
                            re.findall(
                                callsign_pattern,
                                f"{spanish_text} {english_text}",
                                re.IGNORECASE,
                            )
                        )
                    )
                    callsigns = [c.upper() for c in callsigns]

                    if callsigns:
                        logger.info(f"🎯 CALLSIGNS DETECTED: {' '.join(callsigns)}")

                    # Create transcript entry
                    transcript = {
                        "timestamp": timestamp,
                        "freq_mhz": f"{freq_mhz:.3f}",
                        "mode": mode,
                        "s_meter": s_meter,
                        "spanish": spanish_text,
                        "english": english_text,
                        "callsigns": " ".join(callsigns),
                    }

                    # Save to JSONL
                    with open(transcript_log, "a") as f:
                        f.write(json.dumps(transcript) + "\n")

                    # Send to WebSocket clients
                    for websocket in list(monitor_websocket_clients):
                        try:
                            await websocket.send_text(json.dumps(transcript))
                        except:
                            monitor_websocket_clients.discard(websocket)

                    logger.info(f"ES: {spanish_text}")
                    logger.info(f"EN: {english_text}")

                    # Cleanup temp files
                    os.unlink(raw_file)
                    os.unlink(filt_file)

                except Exception as e:
                    logger.error(f"Monitor processing error: {e}")

                await asyncio.sleep(config["interval"])

            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.error(f"Monitor loop error: {e}")
                await asyncio.sleep(5)


@app.post("/api/monitor/start")
async def start_monitor(request: MonitorStartRequest):
    """Start radio monitoring."""
    global monitor_active, monitor_task, monitor_clip_count

    if monitor_active:
        raise HTTPException(status_code=400, detail="Monitor already running")

    config = {
        "clip_duration": request.clip_duration,
        "interval": request.interval,
        "language": request.language,
        "smeter_threshold": request.smeter_threshold,
    }

    monitor_active = True
    monitor_clip_count = 0
    monitor_task = asyncio.create_task(monitor_loop(config))

    return {"success": True, "message": "Monitor started"}


@app.post("/api/monitor/stop")
async def stop_monitor():
    """Stop radio monitoring."""
    global monitor_active, monitor_task

    if not monitor_active:
        raise HTTPException(status_code=400, detail="Monitor not running")

    monitor_active = False
    if monitor_task:
        monitor_task.cancel()
        try:
            await monitor_task
        except asyncio.CancelledError:
            pass
        monitor_task = None

    return {"success": True, "message": "Monitor stopped"}


@app.get("/api/monitor/status")
async def get_monitor_status():
    """Get monitor status."""
    duration = 0
    if monitor_start_time and monitor_active:
        duration = int((datetime.now() - monitor_start_time).total_seconds())

    return {
        "running": monitor_active,
        "clip_count": monitor_clip_count,
        "duration": duration,
        "last_clip": None,
    }


@app.get("/api/monitor/transcripts")
async def get_transcripts(limit: int = 50):
    """Get recent transcripts."""
    log_dir = Path.home() / "Projects" / "lbf-ham-radio" / "logs" / "radio-monitor"
    transcript_log = log_dir / f"transcripts_{datetime.now().strftime('%Y%m%d')}.jsonl"

    if not transcript_log.exists():
        return {"transcripts": []}

    transcripts = []
    with open(transcript_log) as f:
        lines = f.readlines()
        for line in lines[-limit:]:
            try:
                transcripts.append(json.loads(line.strip()))
            except json.JSONDecodeError:
                continue

    return {"transcripts": list(reversed(transcripts))}


@app.get("/api/monitor/transcripts/export")
async def export_transcripts():
    """Download transcript log as JSON."""
    log_dir = Path.home() / "Projects" / "lbf-ham-radio" / "logs" / "radio-monitor"
    transcript_log = log_dir / f"transcripts_{datetime.now().strftime('%Y%m%d')}.jsonl"

    if not transcript_log.exists():
        raise HTTPException(status_code=404, detail="No transcript log found")

    return FileResponse(
        transcript_log,
        media_type="application/octet-stream",
        filename=f"transcripts_{datetime.now().strftime('%Y%m%d')}.jsonl",
    )


@app.websocket("/ws/monitor")
async def websocket_monitor(websocket: WebSocket):
    """WebSocket endpoint for live monitor updates."""
    await websocket.accept()
    monitor_websocket_clients.add(websocket)

    try:
        while True:
            await websocket.receive_text()
    except WebSocketDisconnect:
        pass
    finally:
        monitor_websocket_clients.discard(websocket)


# ── Startup/Shutdown ─────────────────────────────────────────


@app.on_event("startup")
async def startup_event():
    """Initialize radio connection on startup."""
    logger.info("FT-991A Web GUI starting up...")
    if radio_config.get("auto_reconnect", True):
        await connect_radio()


@app.on_event("shutdown")
async def shutdown_event():
    """Clean shutdown - ensure PTT is off and audio/SDR stopped."""
    logger.info("FT-991A Web GUI shutting down...")

    # Stop audio streaming
    await audio_manager.stop_audio_stream()

    # Stop SDR streaming
    await sdr_manager.stop_sdr_stream()

    # Stop any active scan
    global scan_active, scan_task
    if scan_active and scan_task:
        scan_active = False
        scan_task.cancel()
        try:
            await scan_task
        except asyncio.CancelledError:
            pass

    # Stop monitor if running
    global monitor_active, monitor_task
    if monitor_active and monitor_task:
        monitor_active = False
        monitor_task.cancel()
        try:
            await monitor_task
        except asyncio.CancelledError:
            pass

    # Disconnect radio safely
    if radio_connected and radio:
        try:
            radio.ptt_off()  # Safety
            radio.disconnect()
        except:
            pass


# ── Main Entry Point ─────────────────────────────────────────

if __name__ == "__main__":
    # Parse command line args
    import argparse

    import uvicorn

    parser = argparse.ArgumentParser(description="FT-991A Web GUI Server")
    parser.add_argument("--host", default="0.0.0.0", help="Host to bind to")
    parser.add_argument("--port", type=int, default=8991, help="Port to bind to")
    parser.add_argument(
        "--radio-port", default="/dev/ttyUSB0", help="Radio serial port"
    )
    parser.add_argument("--baud", type=int, default=38400, help="Radio baud rate")
    parser.add_argument(
        "--no-auto-connect", action="store_true", help="Don't auto-connect to radio"
    )
    parser.add_argument(
        "--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"]
    )
    args = parser.parse_args()

    # Update config from args
    radio_config["port"] = args.radio_port
    radio_config["baudrate"] = args.baud
    radio_config["auto_reconnect"] = not args.no_auto_connect

    # Set log level
    logging.getLogger().setLevel(getattr(logging, args.log_level))

    logger.info(f"Starting FT-991A Web GUI on {args.host}:{args.port}")
    logger.info(f"Radio config: {args.radio_port} @ {args.baud} baud")

    uvicorn.run(
        "ft991a.web:app",
        host=args.host,
        port=args.port,
        log_level=args.log_level.lower(),
        reload=False,
    )
