#!/usr/bin/env python3
"""
CW (Morse Code) Module for FT-991A
==================================
Provides CW encoding/decoding and keying functionality for ham radio operations.

Features:
- Text to Morse code conversion
- Morse code to text decoding
- CW keying via FT-991A TX commands
- Configurable WPM (5-40 WPM)
- Precise timing based on standard Morse timing
- Placeholder for future audio decoding

Timing Standards (per ARRL):
- Dit length = 1200 / WPM milliseconds
- Dah length = 3 × dit length
- Inter-element gap = 1 × dit length
- Letter gap = 3 × dit length
- Word gap = 7 × dit length

WARNING: CW transmission requires a valid amateur radio license.
Always ensure a licensed operator is present and controlling the station.
"""

import logging
import re
import time
from dataclasses import asdict, dataclass
from time import time as epoch_time
from typing import Dict, List, Optional

logger = logging.getLogger(__name__)


CW_BAND_RANGES = {
    "160m": {"start_hz": 1_800_000, "stop_hz": 2_000_000},
    "80m": {"start_hz": 3_500_000, "stop_hz": 4_000_000},
    "60m": {"start_hz": 5_330_000, "stop_hz": 5_405_000},
    "40m": {"start_hz": 7_000_000, "stop_hz": 7_300_000},
    "30m": {"start_hz": 10_100_000, "stop_hz": 10_150_000},
    "20m": {"start_hz": 14_000_000, "stop_hz": 14_350_000},
    "17m": {"start_hz": 18_068_000, "stop_hz": 18_168_000},
    "15m": {"start_hz": 21_000_000, "stop_hz": 21_450_000},
    "12m": {"start_hz": 24_890_000, "stop_hz": 24_990_000},
    "10m": {"start_hz": 28_000_000, "stop_hz": 29_700_000},
}


def resolve_cw_band_window(
    *,
    band: Optional[str] = None,
    center_freq_hz: Optional[int] = None,
    band_start_hz: Optional[int] = None,
    band_stop_hz: Optional[int] = None,
) -> dict:
    """Resolve a CW hunt search window from explicit bounds, band name, or center frequency."""
    if band_start_hz is not None and band_stop_hz is not None:
        start_hz = min(int(band_start_hz), int(band_stop_hz))
        stop_hz = max(int(band_start_hz), int(band_stop_hz))
        return {
            "band": band or "custom",
            "start_hz": start_hz,
            "stop_hz": stop_hz,
            "center_hz": (start_hz + stop_hz) // 2,
        }

    if band:
        key = band.lower()
        if key not in CW_BAND_RANGES:
            raise ValueError(f"Unknown CW band: {band}")
        entry = CW_BAND_RANGES[key]
        return {
            "band": key,
            "start_hz": entry["start_hz"],
            "stop_hz": entry["stop_hz"],
            "center_hz": (entry["start_hz"] + entry["stop_hz"]) // 2,
        }

    if center_freq_hz is not None:
        center = int(center_freq_hz)
        for band_name, entry in CW_BAND_RANGES.items():
            if entry["start_hz"] <= center <= entry["stop_hz"]:
                return {
                    "band": band_name,
                    "start_hz": entry["start_hz"],
                    "stop_hz": entry["stop_hz"],
                    "center_hz": center,
                }
        return {
            "band": "centered",
            "start_hz": center - 250_000,
            "stop_hz": center + 250_000,
            "center_hz": center,
        }

    default = CW_BAND_RANGES["40m"]
    return {
        "band": "40m",
        "start_hz": default["start_hz"],
        "stop_hz": default["stop_hz"],
        "center_hz": (default["start_hz"] + default["stop_hz"]) // 2,
    }


def rank_reference_candidate(
    candidates: List[Dict], reference_freq_hz: Optional[int]
) -> Optional[Dict]:
    """Locate the nearest ranked candidate to a reference frequency."""
    if reference_freq_hz is None or not candidates:
        return None

    ranked = sorted(candidates, key=lambda row: row.get("rank", 999999))
    best = None
    for idx, candidate in enumerate(ranked, start=1):
        delta_hz = abs(int(candidate["freq_hz"]) - int(reference_freq_hz))
        if best is None or delta_hz < best["delta_hz"]:
            best = {
                "reference_freq_hz": int(reference_freq_hz),
                "rank": idx,
                "delta_hz": delta_hz,
                "candidate": candidate,
            }
    return best


def build_cw_hunt_package(
    candidates: List[Dict],
    *,
    reference_freq_hz: Optional[int] = None,
    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,
    hotspot_half_width_hz: int = 75,
) -> Dict:
    """Convert ranked CW candidates into machine-usable review windows and control guidance."""
    from .scanner import BandScanner, HeatmapHotspot

    scanner = BandScanner(None)  # type: ignore[arg-type]
    hotspots = [
        HeatmapHotspot(
            start_hz=int(candidate["freq_hz"]) - max(25, hotspot_half_width_hz),
            end_hz=int(candidate["freq_hz"]) + max(25, hotspot_half_width_hz),
            center_hz=int(candidate["freq_hz"]),
            avg_s_meter=round(float(candidate.get("confidence", 0.0)) * 100.0, 2),
            peak_s_meter=max(
                1, min(255, int(round(float(candidate.get("confidence", 0.0)) * 255.0)))
            ),
            sample_count=1,
            activity_score=round(float(candidate.get("confidence", 0.0)), 3),
        )
        for candidate in candidates
    ]

    windows = scanner.merge_hotspot_windows(hotspots, max_gap_hz=max(0, window_gap_hz))
    plan = scanner.build_hotspot_window_plan(
        windows,
        cycle_ms=max(1000, cycle_ms),
        min_dwell_ms=max(250, min_dwell_ms),
    )
    now_epoch_ms = int(epoch_time() * 1000)
    timeline = scanner.build_hotspot_window_timeline(plan)
    clock = scanner.build_hotspot_window_clock(timeline, start_epoch_ms=now_epoch_ms)
    brief = scanner.build_hotspot_window_brief(
        clock, now_epoch_ms=now_epoch_ms, upcoming_count=max(0, upcoming_count)
    )
    snapshot = scanner.build_hotspot_window_snapshot(
        clock,
        now_epoch_ms=now_epoch_ms,
        ready_threshold_ms=max(0, action_ready_ms),
        critical_threshold_ms=max(0, action_critical_ms),
        upcoming_count=max(0, upcoming_count),
    )
    control_packet = scanner.build_hotspot_window_control_packet(snapshot)
    return {
        "hotspots": [asdict(entry) for entry in hotspots],
        "windows": [asdict(entry) for entry in windows],
        "plan": [asdict(entry) for entry in plan],
        "timeline": [asdict(entry) for entry in timeline],
        "brief": asdict(brief) if brief else None,
        "snapshot": asdict(snapshot) if snapshot else None,
        "control_packet": asdict(control_packet) if control_packet else None,
        "reference_check": rank_reference_candidate(candidates, reference_freq_hz),
    }


def list_cw_bands() -> List[str]:
    """Return supported CW band keys in deterministic order."""
    return list(CW_BAND_RANGES.keys())


def normalize_cw_band_list(bands: Optional[List[str]]) -> List[str]:
    """Validate and normalize requested CW band names."""
    if not bands:
        return list_cw_bands()

    normalized: List[str] = []
    for band in bands:
        key = str(band).strip().lower()
        if not key:
            continue
        if key not in CW_BAND_RANGES:
            raise ValueError(f"Unknown CW band: {band}")
        if key not in normalized:
            normalized.append(key)

    if not normalized:
        raise ValueError("No valid CW bands requested")
    return normalized


def summarize_cw_candidate_passes(
    candidate_batches: List[List[Dict]],
    *,
    pass_timestamps: Optional[List[Optional[str]]] = None,
    min_spacing_hz: int = 150,
) -> List[Dict]:
    """Merge repeated snapshot candidates into persistence-aware sweep results."""
    if not candidate_batches:
        return []

    bucket_spacing_hz = max(25, int(min_spacing_hz))
    buckets: List[Dict] = []
    for pass_index, candidates in enumerate(candidate_batches, start=1):
        pass_timestamp = (
            pass_timestamps[pass_index - 1]
            if pass_timestamps and len(pass_timestamps) >= pass_index
            else None
        )
        for candidate in candidates:
            freq_hz = int(candidate["freq_hz"])
            bucket = next(
                (
                    existing
                    for existing in buckets
                    if abs(freq_hz - existing["anchor_freq_hz"]) < bucket_spacing_hz
                ),
                None,
            )
            if bucket is None:
                bucket = {
                    "anchor_freq_hz": freq_hz,
                    "freqs": [],
                    "scores": [],
                    "confidences": [],
                    "powers": [],
                    "signal_over_noise": [],
                    "drifts": [],
                    "pass_hits": set(),
                    "first_seen_timestamp": pass_timestamp,
                    "last_seen_timestamp": pass_timestamp,
                }
                buckets.append(bucket)

            bucket["freqs"].append(freq_hz)
            bucket["scores"].append(float(candidate.get("score", 0.0)))
            bucket["confidences"].append(float(candidate.get("confidence", 0.0)))
            bucket["powers"].append(float(candidate.get("power_db", -200.0)))
            bucket["signal_over_noise"].append(
                float(candidate.get("signal_over_noise_db", 0.0))
            )
            bucket["drifts"].append(float(candidate.get("drift_hz", 0.0)))
            bucket["pass_hits"].add(pass_index)
            if bucket["first_seen_timestamp"] is None:
                bucket["first_seen_timestamp"] = pass_timestamp
            if pass_timestamp is not None:
                bucket["last_seen_timestamp"] = pass_timestamp
            bucket["anchor_freq_hz"] = int(round(sum(bucket["freqs"]) / len(bucket["freqs"])))

    total_passes = max(1, len(candidate_batches))
    summarized: List[Dict] = []
    for bucket in buckets:
        max_score = max(bucket["scores"]) if bucket["scores"] else 0.0
        max_confidence = max(bucket["confidences"]) if bucket["confidences"] else 0.0
        peak_power = max(bucket["powers"]) if bucket["powers"] else -200.0
        avg_freq_hz = int(round(sum(bucket["freqs"]) / len(bucket["freqs"])))
        summarized.append(
            {
                "freq_hz": avg_freq_hz,
                "min_freq_hz": min(bucket["freqs"]),
                "max_freq_hz": max(bucket["freqs"]),
                "drift_span_hz": max(bucket["freqs"]) - min(bucket["freqs"]),
                "hits": len(bucket["freqs"]),
                "passes": total_passes,
                "occupancy": round(len(bucket["pass_hits"]) / total_passes, 3),
                "first_seen_pass": min(bucket["pass_hits"]),
                "last_seen_pass": max(bucket["pass_hits"]),
                "first_seen_timestamp": bucket["first_seen_timestamp"],
                "last_seen_timestamp": bucket["last_seen_timestamp"],
                "score": round(max_score, 3),
                "avg_score": round(sum(bucket["scores"]) / len(bucket["scores"]), 3),
                "confidence": round(max_confidence, 3),
                "avg_confidence": round(
                    sum(bucket["confidences"]) / len(bucket["confidences"]), 3
                ),
                "power_db": round(peak_power, 2),
                "avg_power_db": round(sum(bucket["powers"]) / len(bucket["powers"]), 2),
                "signal_over_noise_db": round(
                    max(bucket["signal_over_noise"]) if bucket["signal_over_noise"] else 0.0,
                    2,
                ),
                "avg_signal_over_noise_db": round(
                    sum(bucket["signal_over_noise"]) / len(bucket["signal_over_noise"]),
                    2,
                ),
                "avg_drift_hz": round(sum(bucket["drifts"]) / len(bucket["drifts"]), 2),
            }
        )

    summarized.sort(
        key=lambda row: (
            row["occupancy"],
            row["score"],
            row["confidence"],
            row["power_db"],
        ),
        reverse=True,
    )
    for idx, row in enumerate(summarized, start=1):
        row["rank"] = idx
    return summarized


def build_cw_sweep_shortlist(
    band_results: List[Dict],
    *,
    shortlist_size: int = 12,
) -> List[Dict]:
    """Flatten per-band persistence results into a global ranked shortlist."""
    flattened: List[Dict] = []
    for band_result in band_results:
        band = band_result["band"]
        search_window = band_result["search_window"]
        for candidate in band_result.get("candidates", []):
            enriched = dict(candidate)
            enriched["band"] = band
            enriched["search_window"] = search_window
            flattened.append(enriched)

    flattened.sort(
        key=lambda row: (
            row.get("occupancy", 0.0),
            row.get("score", 0.0),
            row.get("confidence", 0.0),
            row.get("power_db", -200.0),
        ),
        reverse=True,
    )
    limited = flattened[: max(1, int(shortlist_size))]
    for idx, row in enumerate(limited, start=1):
        row["global_rank"] = idx
    return limited

# International Morse Code Table (ITU-R M.1677-1)
MORSE_TABLE = {
    # Letters
    "A": ".-",
    "B": "-...",
    "C": "-.-.",
    "D": "-..",
    "E": ".",
    "F": "..-.",
    "G": "--.",
    "H": "....",
    "I": "..",
    "J": ".---",
    "K": "-.-",
    "L": ".-..",
    "M": "--",
    "N": "-.",
    "O": "---",
    "P": ".--.",
    "Q": "--.-",
    "R": ".-.",
    "S": "...",
    "T": "-",
    "U": "..-",
    "V": "...-",
    "W": ".--",
    "X": "-..-",
    "Y": "-.--",
    "Z": "--..",
    # Numbers
    "0": "-----",
    "1": ".----",
    "2": "..---",
    "3": "...--",
    "4": "....-",
    "5": ".....",
    "6": "-....",
    "7": "--...",
    "8": "---..",
    "9": "----.",
    # Common punctuation
    ".": ".-.-.-",
    ",": "--..--",
    "?": "..--..",
    "'": ".----.",
    "!": "-.-.--",
    "/": "-..-.",
    "(": "-.--.",
    ")": "-.--.-",
    "&": ".-...",
    ":": "---...",
    ";": "-.-.-.",
    "=": "-...-",
    "+": ".-.-.",
    "-": "-....-",
    "_": "..--.-",
    '"': ".-..-.",
    "$": "...-..-",
    "@": ".--.-.",
    # Common prosigns (procedural signals) - only non-conflicting ones
    "<SK>": "...-.-",  # End of contact (SK)
    "<KA>": "-.-.-",  # Attention (KA)
    "<SN>": "...-.",  # Understood (SN)
    # Special space character for word breaks
    " ": " ",
}

# Reverse lookup table for decoding
REVERSE_MORSE_TABLE = {code: char for char, code in MORSE_TABLE.items() if char != " "}


@dataclass
class CWTiming:
    """CW timing parameters for a given WPM"""

    wpm: int
    dit_ms: float
    dah_ms: float
    element_gap_ms: float
    letter_gap_ms: float
    word_gap_ms: float

    @classmethod
    def from_wpm(cls, wpm: int) -> "CWTiming":
        """Calculate timing parameters from WPM"""
        if not 5 <= wpm <= 40:
            raise ValueError("WPM must be between 5 and 40")

        # Standard formula: dit length in ms = 1200 / WPM
        dit_ms = 1200.0 / wpm

        return cls(
            wpm=wpm,
            dit_ms=dit_ms,
            dah_ms=dit_ms * 3,
            element_gap_ms=dit_ms,  # Gap between dots/dashes within letter
            letter_gap_ms=dit_ms * 3,  # Gap between letters
            word_gap_ms=dit_ms * 7,  # Gap between words
        )


def text_to_morse(text: str) -> str:
    """
    Convert text to Morse code.

    Args:
        text: Input text to encode

    Returns:
        String of dots, dashes, and spaces representing Morse code

    Example:
        >>> text_to_morse("HELLO WORLD")
        '.... . .-.. .-.. ---  .-- --- .-. .-.. -..'
    """
    text = text.upper().strip()
    if not text:
        return ""

    # Handle prosigns first (replace <XX> with special markers)
    import re

    prosign_pattern = r"<([A-Z]{2,3})>"
    for match in re.finditer(prosign_pattern, text):
        prosign = match.group(0)
        if prosign in MORSE_TABLE:
            # Replace with a unique placeholder that won't conflict
            text = text.replace(prosign, f"\x00{prosign}\x00")

    # Split into words first, then process each word
    words = re.split(r"\s+", text.strip())
    morse_words = []

    for word in words:
        morse_chars = []
        i = 0
        while i < len(word):
            if word[i] == "\x00":
                # Extract prosign
                end = word.find("\x00", i + 1)
                prosign = word[i + 1 : end]
                if prosign in MORSE_TABLE:
                    morse_chars.append(MORSE_TABLE[prosign])
                i = end + 1
            elif word[i] in MORSE_TABLE:
                morse_chars.append(MORSE_TABLE[word[i]])
                i += 1
            else:
                logger.warning(f"Unknown character '{word[i]}' - skipping")
                i += 1

        if morse_chars:
            morse_words.append(" ".join(morse_chars))

    # Join words with double spaces (word gap)
    return "  ".join(morse_words)


def morse_to_text(morse: str) -> str:
    """
    Convert Morse code to text.

    Args:
        morse: Morse code string with dots, dashes, and spaces

    Returns:
        Decoded text string

    Example:
        >>> morse_to_text(".... . .-.. .-.. ---  .-- --- .-.. -..")
        'HELLO WORLD'
    """
    import re

    # Handle both normal spacing (2 spaces between words) and extra spacing gracefully
    # Strategy: Use 2+ consecutive spaces as word boundaries, but be smart about grouping
    # Split by 2+ spaces to get potential words
    words = re.split(r"  +", morse.strip())
    decoded_words = []

    for word in words:
        # Clean up the word and split by single spaces
        clean_word = re.sub(r"\s+", " ", word.strip())
        if not clean_word:
            continue

        letters = clean_word.split(" ")
        decoded_letters = []

        for letter in letters:
            if letter in REVERSE_MORSE_TABLE:
                decoded_letters.append(REVERSE_MORSE_TABLE[letter])
            elif letter:  # Skip empty strings
                logger.warning(f"Unknown Morse code '{letter}' - skipping")

        if decoded_letters:
            decoded_words.append("".join(decoded_letters))

    return " ".join(decoded_words)


class CWKeyer:
    """
    CW keyer using FT-991A TX commands for precise timing.

    Handles automatic CW transmission with proper Morse code timing.
    Uses the radio's TX1/TX0 CAT commands for keying.
    """

    def __init__(self, radio_instance, wpm: int = 20):
        """
        Initialize CW keyer.

        Args:
            radio_instance: Connected FT991A instance
            wpm: Words per minute (5-40)
        """
        self.radio = radio_instance
        self.timing = CWTiming.from_wpm(wpm)
        self._is_keying = False

    def set_wpm(self, wpm: int):
        """Set transmission speed in WPM"""
        self.timing = CWTiming.from_wpm(wpm)
        logger.info(f"CW speed set to {wpm} WPM (dit={self.timing.dit_ms:.1f}ms)")

    def _key_down(self):
        """Key the transmitter (start tone)"""
        if not self._is_keying:
            self.radio.ptt_on()  # TX1 command
            self._is_keying = True

    def _key_up(self):
        """Unkey the transmitter (stop tone)"""
        if self._is_keying:
            self.radio.ptt_off()  # TX0 command
            self._is_keying = False

    def _send_dit(self):
        """Send a dit (dot)"""
        self._key_down()
        time.sleep(self.timing.dit_ms / 1000.0)
        self._key_up()

    def _send_dah(self):
        """Send a dah (dash)"""
        self._key_down()
        time.sleep(self.timing.dah_ms / 1000.0)
        self._key_up()

    def _element_gap(self):
        """Inter-element gap (between dots/dashes within a letter)"""
        time.sleep(self.timing.element_gap_ms / 1000.0)

    def _letter_gap(self):
        """Inter-letter gap"""
        time.sleep(self.timing.letter_gap_ms / 1000.0)

    def _word_gap(self):
        """Inter-word gap (additional, total gap = letter_gap + word_gap)"""
        time.sleep(
            self.timing.word_gap_ms / 1000.0 - self.timing.letter_gap_ms / 1000.0
        )

    def send_morse_code(self, morse: str):
        """
        Send Morse code string using radio keying.

        Args:
            morse: Morse code string (dots, dashes, spaces)

        Example:
            keyer.send_morse_code(".... .  .-- --- .-. .-.. -..")  # "HE WORLD"
        """
        logger.info(f"Keying CW at {self.timing.wpm} WPM: {morse}")

        try:
            # Split into words (double space separated)
            words = re.split(r"  +", morse.strip())

            for word_idx, word in enumerate(words):
                if word_idx > 0:
                    self._word_gap()  # Gap between words

                # Split word into letters (single space separated)
                letters = word.split(" ")

                for letter_idx, letter in enumerate(letters):
                    if letter_idx > 0:
                        self._letter_gap()  # Gap between letters

                    # Send each element (dot/dash) in the letter
                    for element_idx, element in enumerate(letter):
                        if element_idx > 0:
                            self._element_gap()  # Gap between elements

                        if element == ".":
                            self._send_dit()
                        elif element == "-":
                            self._send_dah()
                        else:
                            logger.warning(
                                f"Unknown Morse element '{element}' - skipping"
                            )

        except Exception as e:
            logger.error(f"Error during CW keying: {e}")
            # Ensure we unkey if something goes wrong
            self._key_up()
            raise
        finally:
            # Always ensure we end in receive mode
            self._key_up()

    def send_text(self, text: str):
        """
        Convert text to Morse and send via radio.

        Args:
            text: Text message to send in CW
        """
        morse = text_to_morse(text)
        if morse.strip():
            self.send_morse_code(morse)
        else:
            logger.warning("No valid Morse code generated from text")

    def emergency_stop(self):
        """Emergency stop - immediately unkey transmitter"""
        logger.warning("CW EMERGENCY STOP - unkeying transmitter")
        self._key_up()


class CWDecoder:
    """CW decoder using Goertzel tone detection + adaptive timing analysis."""

    def __init__(self, sample_rate: int = 8000, tone_freq: int = 600, wpm_hint: int = 20):
        self.sample_rate = sample_rate
        self.tone_freq = tone_freq
        self.wpm_hint = wpm_hint
        self._enabled = False
        self._last_result: Dict = {}
        self._frame_ms = 10
        logger.info("CWDecoder initialized (goertzel/adaptive mode)")

    def start_listening(self):
        self._enabled = True

    def stop_listening(self):
        self._enabled = False

    def _goertzel_power(self, frame: List[float], target_freq: float) -> float:
        import math

        n = len(frame)
        if n == 0:
            return 0.0
        k = int(0.5 + ((n * target_freq) / self.sample_rate))
        omega = (2.0 * math.pi * k) / n
        coeff = 2.0 * math.cos(omega)
        s_prev = 0.0
        s_prev2 = 0.0
        for x in frame:
            s = x + coeff * s_prev - s_prev2
            s_prev2 = s_prev
            s_prev = s
        power = s_prev2 * s_prev2 + s_prev * s_prev - coeff * s_prev * s_prev2
        return max(0.0, power)

    def decode_audio_buffer(self, audio_data: List[float]) -> Optional[str]:
        import math
        import statistics

        if not audio_data or len(audio_data) < int(self.sample_rate * 0.2):
            return None

        frame_len = max(8, int(self.sample_rate * (self._frame_ms / 1000.0)))
        frames = [audio_data[i : i + frame_len] for i in range(0, len(audio_data) - frame_len, frame_len)]
        if len(frames) < 5:
            return None

        # Detect tone energy with slight frequency robustness (target +-20Hz)
        powers = []
        for fr in frames:
            p0 = self._goertzel_power(fr, self.tone_freq)
            p1 = self._goertzel_power(fr, self.tone_freq - 20)
            p2 = self._goertzel_power(fr, self.tone_freq + 20)
            powers.append(max(p0, p1, p2))

        med = statistics.median(powers)
        abs_dev = [abs(p - med) for p in powers]
        mad = statistics.median(abs_dev) if abs_dev else 0.0
        thresh = med + max(1e-9, 3.0 * mad)
        tone_mask = [p > thresh for p in powers]

        # Run-length encode tone/silence
        runs = []
        cur = tone_mask[0]
        length = 1
        for b in tone_mask[1:]:
            if b == cur:
                length += 1
            else:
                runs.append((cur, length))
                cur = b
                length = 1
        runs.append((cur, length))

        tone_units_ms = [r[1] * self._frame_ms for r in runs if r[0]]
        if not tone_units_ms:
            return None

        tone_sorted = sorted(tone_units_ms)
        short_n = max(1, len(tone_sorted) // 3)
        dit_ms = sum(tone_sorted[:short_n]) / short_n
        dit_ms = max(20.0, min(220.0, dit_ms))

        morse_parts = []
        current_symbol = []
        timing_scores = []

        for is_tone, run_len in runs:
            dur_ms = run_len * self._frame_ms
            units = dur_ms / dit_ms
            if is_tone:
                sym = '-' if units >= 2.2 else '.'
                current_symbol.append(sym)
                timing_scores.append(max(0.0, 1.0 - abs((3.0 if sym == '-' else 1.0) - units) / 3.0))
            else:
                if units <= 1.8:
                    # intra-character gap
                    continue
                if current_symbol:
                    morse_parts.append(''.join(current_symbol))
                    current_symbol = []
                if units >= 4.5:
                    morse_parts.append(' / ')

        if current_symbol:
            morse_parts.append(''.join(current_symbol))

        # Normalize spacing
        morse = []
        for tok in morse_parts:
            if tok == ' / ':
                if morse and morse[-1] != '/':
                    morse.append('/')
            elif tok:
                morse.append(tok)
        if not morse:
            return None

        morse_str = ' '.join(morse).replace('/ ', '/').replace(' /', '/').replace('/', '  ')
        text = morse_to_text(morse_str)

        snr_proxy = (max(powers) / (med + 1e-9)) if med > 0 else 0.0
        timing_conf = sum(timing_scores) / len(timing_scores) if timing_scores else 0.0
        conf = max(0.0, min(1.0, 0.5 * min(1.0, snr_proxy / 8.0) + 0.5 * timing_conf))

        self._last_result = {
            "morse": morse_str,
            "text": text,
            "confidence": round(conf, 3),
            "dit_ms": round(dit_ms, 1),
            "tone_freq": self.tone_freq,
            "snr_proxy": round(snr_proxy, 3),
        }
        return morse_str

    def decode_with_metadata(self, audio_data: List[float]) -> Dict:
        morse = self.decode_audio_buffer(audio_data)
        if not morse:
            return {"ok": False, "reason": "no_cw_detected"}
        return {"ok": True, **self._last_result}

    def get_decoder_stats(self) -> Dict:
        return {
            "enabled": self._enabled,
            "status": "listening" if self._enabled else "idle",
            "sample_rate": self.sample_rate,
            "tone_freq": self.tone_freq,
            "frame_ms": self._frame_ms,
            "last_result": self._last_result,
        }


class CWFrequencyLocator:
    """Find and track CW-like narrowband peaks from wideband FFT frames."""

    def __init__(self):
        self._tracks: Dict[int, float] = {}

    def scan_fft(
        self,
        bins: List[float],
        center_freq: float,
        bandwidth: float,
        top_n: int = 8,
        scan_start_hz: Optional[int] = None,
        scan_stop_hz: Optional[int] = None,
        min_spacing_hz: int = 150,
    ) -> List[Dict]:
        if not bins or bandwidth <= 0:
            return []

        n = len(bins)
        if n < 8:
            return []

        # Local maxima above adaptive threshold
        sorted_bins = sorted(bins)
        noise_floor = sorted_bins[max(0, int(0.65 * n) - 1)]
        peak_thresh = noise_floor + 8.0
        cands = []
        for i in range(2, n - 2):
            v = bins[i]
            if v < peak_thresh:
                continue
            if v > bins[i - 1] and v > bins[i + 1]:
                # narrowness proxy: peak over local shoulders
                shoulders = (bins[i - 2] + bins[i - 1] + bins[i + 1] + bins[i + 2]) / 4.0
                sharpness = v - shoulders
                if sharpness < 2.0:
                    continue
                frac = (i / (n - 1)) - 0.5
                freq = center_freq + frac * bandwidth
                if scan_start_hz is not None and freq < scan_start_hz:
                    continue
                if scan_stop_hz is not None and freq > scan_stop_hz:
                    continue
                prev = self._tracks.get(i, freq)
                drift = freq - prev
                self._tracks[i] = freq
                signal_over_noise = max(0.0, v - noise_floor)
                score = (signal_over_noise * 0.65) + (sharpness * 0.35)
                conf = max(0.0, min(1.0, score / 24.0))
                cands.append({
                    "bin": i,
                    "freq_hz": int(round(freq)),
                    "power_db": round(v, 2),
                    "sharpness": round(sharpness, 2),
                    "drift_hz": round(drift, 1),
                    "signal_over_noise_db": round(signal_over_noise, 2),
                    "score": round(score, 3),
                    "confidence": round(conf, 3),
                })

        cands.sort(key=lambda x: (x["score"], x["power_db"]), reverse=True)
        clustered = []
        for candidate in cands:
            if any(
                abs(candidate["freq_hz"] - existing["freq_hz"])
                < max(25, int(min_spacing_hz))
                for existing in clustered
            ):
                continue
            clustered.append(candidate)
            if len(clustered) >= top_n:
                break

        for idx, candidate in enumerate(clustered, start=1):
            candidate["rank"] = idx
        return clustered


# Convenience functions for command-line usage


def encode_text_to_morse(text: str) -> str:
    """Convenience function: text to Morse"""
    return text_to_morse(text)


def decode_morse_to_text(morse: str) -> str:
    """Convenience function: Morse to text"""
    return morse_to_text(morse)


def create_keyer(radio_instance, wpm: int = 20) -> CWKeyer:
    """Convenience function: create CW keyer"""
    return CWKeyer(radio_instance, wpm)


def create_decoder(sample_rate: int = 8000, tone_freq: int = 600) -> CWDecoder:
    """Convenience function: create CW decoder"""
    return CWDecoder(sample_rate, tone_freq)


if __name__ == "__main__":
    # Basic testing
    print("CW Module Test")
    print("=" * 40)

    # Test encoding
    test_text = "HELLO WORLD CQ"
    morse = text_to_morse(test_text)
    print(f"Text: {test_text}")
    print(f"Morse: {morse}")

    # Test decoding
    decoded = morse_to_text(morse)
    print(f"Decoded: {decoded}")
    print(f"Roundtrip OK: {decoded == test_text}")

    # Test timing calculation
    timing_20wpm = CWTiming.from_wpm(20)
    print("\n20 WPM Timing:")
    print(f"  Dit: {timing_20wpm.dit_ms:.1f}ms")
    print(f"  Dah: {timing_20wpm.dah_ms:.1f}ms")
    print(f"  Letter gap: {timing_20wpm.letter_gap_ms:.1f}ms")
