#!python
"""
hamctl -- control the Hamlib SDR pipeline (radio + sidecar + audio).

Configures the SDR's frequency, mode, and DSP parameters by talking to
rigctld over its CAT port (default 4532), and manages PulseAudio
routing by loading and unloading module-loopback instances. Use
--host to control a remote rigctld.

Radio + DSP:

  hamctl show                                        # full status (radio, DSP,
                                                     # audio routing). Aliases:
                                                     # info, status.
  hamctl list [topic]                                # valid values for a
                                                     # keyword. topic = modes,
                                                     # agc, levels, verbs,
                                                     # sinks, or any DSP level
                                                     # name. No topic = all.
  hamctl freq <khz>
  hamctl mode <mode> [width_hz]
  hamctl bandwidth <hz>                              # alias for mode <current> <hz>
  hamctl agc <off|superfast|fast|slow|medium|long|auto|on>
  hamctl squelch <0..1>
  hamctl nr <0..1>                                   # noise reduction
  hamctl nb <0..1>                                   # noise blanker
  hamctl notch <hz>                                  # 0 = off
  hamctl rfgain <0..1>
  hamctl preamp <db>
  hamctl att <db>
  hamctl cwpitch <hz>
  hamctl apf <0..1>                                  # audio peak filter
  hamctl vad <0..1>                                  # alias for squelch (see notes)
  hamctl ptt [0|1]                                   # no arg -> get
  hamctl smeter [--vhf]                              # S-units and dBm; --vhf
                                                     # switches the reference
                                                     # to S9 = -93 dBm
  hamctl smeter live [--vhf] [--hz N]                # realtime bargraph;
                                                     # press q to quit.
                                                     # Default 8 Hz.

Favorites (saved radio configurations):

  hamctl save <name>                                 # snapshot current state
  hamctl load <name>                                 # apply a saved config
  hamctl favorites                                   # list saved names
  hamctl rename <old> <new>
  hamctl delete <name>
  hamctl --favorites <path> ...                      # use an alternate file
                                                     # (default: $XDG_CONFIG_HOME
                                                     # /hamctl/favorites.json,
                                                     # i.e. ~/.config/hamctl/...)

Band-aware tuning:

  hamctl tune <khz>                                  # set frequency and apply
                                                     # the mode/width/levels
                                                     # of the matching band
  hamctl bands                                       # list known bands
  hamctl --bands <path> ...                          # alternate bands file
                                                     # (default: ~/.config/
                                                     # hamctl/bands.json)

Audio routing (PulseAudio):

  hamctl audio list                                  # output devices (sinks)
  hamctl audio sources                               # input / monitor sources
  hamctl audio loopbacks                             # currently active routes
  hamctl audio attach <sink>  [--source SRC]  [--latency MS]
  hamctl audio detach <module_id | name_pattern | all>

Defaults: rigctld on localhost:4532, audio source = hamlib-rx.monitor.

VAD: there is no spectral / statistical VAD in the sidecar; the
``vad`` verb is an alias for ``squelch`` since RMS squelch on the
demodulated audio is what most listeners actually want. Add a real
VAD by extending apply_squelch() in hamlib_sidecar_common.py.
"""

import argparse
import json
import os
import re
import select
import socket
import subprocess
import sys
import termios
import time
import tty


# ---------------------------------------------------------------------------
# Hamlib level mappings
# ---------------------------------------------------------------------------

# user-friendly name -> (Hamlib level identifier, value type)
LEVELS = {
    'agc':     ('AGC',     'agc'),
    'squelch': ('SQL',     'float'),
    'nr':      ('NR',      'float'),
    'nb':      ('NB',      'float'),
    'rfgain':  ('RF',      'float'),
    'preamp':  ('PREAMP',  'int'),
    'att':     ('ATT',     'int'),
    'notch':   ('NOTCHF',  'int'),
    'cwpitch': ('CWPITCH', 'int'),
    'apf':     ('APF',     'float'),
}

# AGC enum values, see hamlib/rig.h agc_level_e
AGC_LEVELS = {
    'off':       0,
    'superfast': 1,
    'fast':      2,
    'slow':      3,
    'user':      4,
    'medium':    5,
    'auto':      6,
    'long':      7,
    'on':        8,
}
AGC_NAMES = {v: k for k, v in AGC_LEVELS.items()}

MODES = {
    'AM', 'CW', 'CWR', 'USB', 'LSB', 'RTTY', 'RTTYR',
    'FM', 'WFM', 'FMN', 'PKTUSB', 'PKTLSB', 'PKTFM',
}


# ---------------------------------------------------------------------------
# CAT (rigctld) TCP client
# ---------------------------------------------------------------------------

class RigError(RuntimeError):
    pass


def cat(host, port, cmd, timeout=3.0, settle=0.05):
    """Send one CAT command and return the full text response."""
    try:
        sock = socket.create_connection((host, port), timeout=timeout)
    except (OSError, socket.timeout) as e:
        raise RigError(f"connect {host}:{port} failed: {e}")
    try:
        sock.sendall((cmd + '\n').encode())
        sock.settimeout(timeout)
        data = b''
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            try:
                chunk = sock.recv(4096)
            except socket.timeout:
                break
            if not chunk:
                break
            data += chunk
            if b'\n' in data:
                # Give rigctld a tick to push any tail bytes through.
                try:
                    sock.settimeout(settle)
                    more = sock.recv(4096)
                    if more:
                        data += more
                except socket.timeout:
                    pass
                break
    finally:
        sock.close()
    return data.decode(errors='replace').strip()


# Hamlib error codes from include/hamlib/rig.h (rig_errcode_e).
RIG_ERRORS = {
    -1:  ('EINVAL',     'invalid parameter (value out of range or '
                        'unsupported by this backend)'),
    -2:  ('ECONF',      'invalid configuration'),
    -3:  ('ENOMEM',     'out of memory'),
    -4:  ('ENIMPL',     'feature not implemented in this backend'),
    -5:  ('ETIMEOUT',   'communication timeout'),
    -6:  ('EIO',        'I/O error'),
    -7:  ('EINTERNAL',  'internal Hamlib error'),
    -8:  ('EPROTO',     'protocol error'),
    -9:  ('ERJCTED',    'command rejected by the rig'),
    -10: ('ETRUNC',     'command truncated'),
    -11: ('ENAVAIL',    'function not available'),
    -12: ('ENTARGET',   'target VFO unavailable'),
}


def _radio_identity(host, port):
    """Return (model_name, mfg) or (None, None) on failure.

    Tries `1` (chk_rig shortcut) first, falling back to `dump_caps`.
    Both produce the same model/mfg lines but `dump_caps` is unsupported
    in many builds.
    """
    for probe in ('1', 'dump_caps'):
        try:
            r = cat(host, port, probe)
        except RigError:
            continue
        name = mfg = None
        for line in r.splitlines():
            if line.startswith('Model name:'):
                name = line.split(':', 1)[1].strip()
            elif line.startswith('Mfg name:'):
                mfg = line.split(':', 1)[1].strip()
            if name and mfg:
                return (name, mfg)
        if name or mfg:
            return (name, mfg)
    return (None, None)


def cat_set(host, port, cmd):
    """Send a set-style command; raise a friendly RigError on failure."""
    r = cat(host, port, cmd)
    m = re.search(r'RPRT\s+(-?\d+)', r)
    if m and int(m.group(1)) != 0:
        code = int(m.group(1))
        sym, desc = RIG_ERRORS.get(code, ('?', 'unknown error'))
        name, mfg = _radio_identity(host, port)
        radio = f"{mfg} {name}" if name else "the rigctld backend"
        msg = (f"{cmd!r} rejected by {radio} at {host}:{port}\n"
               f"  rigctld returned RPRT {code} ({sym}: {desc})")
        if code == -1 and cmd.startswith('F '):
            msg += ("\n  Most likely: the value is outside the radio's "
                    "supported range, or you're targeting the wrong "
                    "rigctld (use --host to switch).")
        raise RigError(msg)
    return r


def cat_get(host, port, cmd):
    """Send a get-style command; return the first non-RPRT line."""
    r = cat(host, port, cmd)
    for line in r.splitlines():
        if line and not line.startswith('RPRT'):
            return line
    return ''


# ---------------------------------------------------------------------------
# Radio + DSP commands
# ---------------------------------------------------------------------------

def fmt_freq_hz(hz):
    if hz >= 1_000_000:
        return f"{hz/1e6:.6f} MHz"
    return f"{hz/1000:.3f} kHz"


def _format_level(name, raw):
    """Pretty-print a level value, with unit and a one-word summary."""
    if raw in ('', None):
        return '(not reported)'
    typ = LEVELS[name][1]
    try:
        if typ == 'agc':
            n = int(float(raw))
            return f"{AGC_NAMES.get(n, '?')} ({n})"
        if typ == 'float':
            v = float(raw)
            return f"{v:5.3f}" + ("  (off)" if v <= 0.0 else '')
        if typ == 'int':
            n = int(float(raw))
            units = {
                'preamp':  'dB',
                'att':     'dB',
                'notch':   'Hz',
                'cwpitch': 'Hz',
            }.get(name, '')
            tail = ''
            if name == 'notch' and n == 0:
                tail = '  (off)'
            return f"{n} {units}".rstrip() + tail
    except (ValueError, TypeError):
        return raw
    return raw


def do_info(host, port):
    try:
        freq_hz = int(cat_get(host, port, 'f') or 0)
    except (RigError, ValueError):
        freq_hz = 0
    mode_lines = cat(host, port, 'm').splitlines()
    mode = mode_lines[0] if mode_lines else '?'
    width = 0
    if len(mode_lines) > 1:
        try:
            width = int(mode_lines[1])
        except ValueError:
            pass

    bar = "=" * 72
    print(bar)
    print(" HAMLIB SDR STATUS")
    print(bar)
    print()
    print("radio")
    print("-----")
    print(f"  frequency : {fmt_freq_hz(freq_hz)}  ({freq_hz} Hz)")
    print(f"  mode      : {mode}")
    print(f"  width     : {width} Hz")
    try:
        ptt = cat_get(host, port, 't')
    except RigError:
        ptt = '?'
    print(f"  ptt       : {'on' if ptt == '1' else 'off'}")

    try:
        strength = _read_strength(host, port)
    except RigError:
        strength = None
    if strength is not None:
        vhf = freq_hz >= 30_000_000
        s_label, dbm = smeter_format(strength, vhf=vhf)
        ref = "VHF" if vhf else "HF"
        print(f"  smeter    : {s_label}  ({dbm:.1f} dBm, {ref} scale)")
    else:
        print(f"  smeter    : (not reported by this backend)")

    print()
    print("dsp levels")
    print("----------")
    for name, (hl, _typ) in LEVELS.items():
        try:
            v = cat_get(host, port, f'l {hl}')
        except RigError:
            v = ''
        print(f"  {name:8} : {_format_level(name, v)}")

    print()
    print("audio routing")
    print("-------------")
    lbs = list_loopbacks()
    if not lbs:
        print("  (no active loopbacks)")
    else:
        descs = sink_descriptions()
        for lb in lbs:
            src, sink = parse_loopback_args(lb['args'])
            desc = descs.get(sink or '', '')
            print(f"  module {lb['id']}")
            print(f"    source : {src or '?'}")
            print(f"    sink   : {sink or '?'}")
            if desc:
                print(f"             ({desc})")

    print()
    print("connection")
    print("----------")
    print(f"  rigctld   : {host}:{port}")
    print()


def do_freq(host, port, khz_arg):
    """Set frequency in kHz.

    `khz_arg` may be an absolute frequency ("162550") or a signed
    delta from the current frequency ("+5", "-2.5", "+12.345"). The
    leading sign is what distinguishes the two -- "5" is absolute,
    "+5" is relative.
    """
    s = str(khz_arg).strip()
    relative = s.startswith(('+', '-'))
    try:
        khz = float(s)
    except ValueError:
        raise RigError(f"could not parse {khz_arg!r} as a kHz value")

    if relative:
        # Read current freq, then apply the delta.
        try:
            cur_hz = int(cat_get(host, port, 'f') or 0)
        except (RigError, ValueError):
            raise RigError("could not read current frequency for relative tune")
        delta_hz = int(round(khz * 1000))
        new_hz = cur_hz + delta_hz
        if new_hz < 0:
            raise RigError(f"relative tune of {khz:+.3f} kHz from "
                           f"{fmt_freq_hz(cur_hz)} would underflow")
        cat_set(host, port, f'F {new_hz}')
        print(f"freq: {fmt_freq_hz(cur_hz)} {khz:+.3f} kHz "
              f"-> {fmt_freq_hz(new_hz)}")
    else:
        hz = int(round(khz * 1000))
        cat_set(host, port, f'F {hz}')
        print(f"freq set: {fmt_freq_hz(hz)}")


def do_mode(host, port, mode, width):
    mode = mode.upper()
    if mode not in MODES:
        raise RigError(f"unknown mode {mode!r}; one of {sorted(MODES)}")
    cat_set(host, port, f'M {mode} {int(width)}')
    print(f"mode set: {mode} {int(width)} Hz")


def do_bandwidth(host, port, width):
    """Set the passband width while keeping the current mode."""
    mode_lines = cat(host, port, 'm').splitlines()
    if not mode_lines:
        raise RigError("could not read current mode")
    mode = mode_lines[0]
    cat_set(host, port, f'M {mode} {int(width)}')
    print(f"bandwidth set: {mode} {int(width)} Hz")


def do_agc(host, port, level):
    level = level.lower()
    if level not in AGC_LEVELS:
        raise RigError(
            f"unknown AGC level {level!r}; one of {sorted(AGC_LEVELS)}")
    cat_set(host, port, f'L AGC {AGC_LEVELS[level]}')
    print(f"agc set: {level} ({AGC_LEVELS[level]})")


def do_level_float(host, port, level_name, value):
    v = float(value)
    if not 0.0 <= v <= 1.0:
        raise RigError(f"{level_name} value must be in 0..1 (got {v})")
    hl = LEVELS[level_name][0]
    cat_set(host, port, f'L {hl} {v}')
    print(f"{level_name} set: {v}")


def do_level_int(host, port, level_name, value):
    v = int(value)
    hl = LEVELS[level_name][0]
    cat_set(host, port, f'L {hl} {v}')
    print(f"{level_name} set: {v}")


def smeter_format(strength_db_rel_s9, vhf=False):
    """Map a Hamlib STRENGTH reading (dB relative to S9) to (S-unit, dBm).

    Hamlib's STRENGTH convention: integer dB referenced to S9 (so 0 = S9,
    +20 = S9 +20 dB = 20 over 9, -54 = S0). S-unit calibration is the
    IARU standard: S9 = -73 dBm on HF, S9 = -93 dBm on V/UHF, with 6 dB
    per S-unit below S9.
    """
    s9_dbm = -93.0 if vhf else -73.0
    dbm = s9_dbm + float(strength_db_rel_s9)

    if strength_db_rel_s9 >= 0:
        s_label = f"S9 +{int(round(strength_db_rel_s9))} dB"
    else:
        # 6 dB per S-unit below S9. Clamp at S0.
        units_below = -strength_db_rel_s9 / 6.0
        s_num = max(0, 9 - int(round(units_below)))
        s_label = f"S{s_num}"
    return s_label, dbm


def _read_strength(host, port):
    """Try to read RIG_LEVEL_STRENGTH. Returns a float or None."""
    raw = cat(host, port, 'l STRENGTH')
    # Plain `RPRT -1` means the backend does not support STRENGTH.
    if 'RPRT' in raw and re.search(r'RPRT\s+-\d', raw):
        return None
    # Otherwise the first non-RPRT line is the numeric value.
    for line in raw.splitlines():
        if line and not line.startswith('RPRT'):
            try:
                return float(line)
            except ValueError:
                return None
    return None


def do_smeter(host, port, vhf):
    strength = _read_strength(host, port)
    if strength is None:
        raise RigError(
            "this Hamlib backend does not export RIG_LEVEL_STRENGTH "
            "(SDR backends like RTL-SDR / KiwiSDR / TCI report no "
            "S-meter via CAT). The signal level lives in the IQ stream "
            "but is not currently surfaced by the sidecar.")
    s_label, dbm = smeter_format(strength, vhf=vhf)
    ref = "VHF (S9=-93 dBm)" if vhf else "HF (S9=-73 dBm)"
    print(f"smeter: {s_label}   {dbm:.1f} dBm   [{ref}, raw={strength:+.0f} dB]")


# ---------------------------------------------------------------------------
# Live S-meter bargraph
# ---------------------------------------------------------------------------

# Tick stops along the IARU S-scale, in dB relative to S9.
# Below S9: -54 (S0), -48 (S1), ... -6 (S8), 0 (S9).
# Above S9: +10, +20, ..., +60.
_SMETER_TICKS_BELOW = [(-54 + 6 * i, f"S{i}") for i in range(10)]   # S0..S9
_SMETER_TICKS_ABOVE = [(10 * k, f"+{10*k}") for k in range(1, 7)]    # +10..+60
_SMETER_TICKS = _SMETER_TICKS_BELOW + _SMETER_TICKS_ABOVE

_SMETER_MIN_DB = -54   # S0
_SMETER_MAX_DB = 60    # S9 +60


def _smeter_scale_to_col(value_db, width):
    """Map a dB-rel-S9 value to a column index 0..width-1."""
    span = _SMETER_MAX_DB - _SMETER_MIN_DB
    frac = (value_db - _SMETER_MIN_DB) / span
    frac = max(0.0, min(1.0, frac))
    return int(round(frac * (width - 1)))


def _smeter_render(strength, vhf, term_width):
    """Return a list of lines drawing the bargraph for one reading."""
    # Reserve some left margin for the numeric readout.
    bar_width = max(40, term_width - 2)
    s_label, dbm = smeter_format(strength, vhf=vhf)
    ref = "VHF S9=-93 dBm" if vhf else "HF S9=-73 dBm"

    # Header: numeric readout + reference.
    header = (f"  {s_label:>10}   {dbm:7.1f} dBm   "
              f"[raw {strength:+5.0f} dB rel S9, {ref}]")

    # Build the bar. Use '#' for filled, '.' for empty.
    filled = _smeter_scale_to_col(strength, bar_width)
    bar_line = ['.'] * bar_width
    # S9 division: switch to '=' above S9 to visually mark the "loud" zone.
    s9_col = _smeter_scale_to_col(0, bar_width)
    for i in range(filled + 1):
        bar_line[i] = '#' if i <= s9_col else '='
    # Strong vertical at S9 if not already filled past it.
    if filled < s9_col:
        bar_line[s9_col] = '|'
    bar = ''.join(bar_line)

    # Tick row: '|' at each tick column.
    tick_row = [' '] * bar_width
    label_row = [' '] * bar_width
    for db, name in _SMETER_TICKS:
        col = _smeter_scale_to_col(db, bar_width)
        tick_row[col] = '|'
        # Place the label centered under the tick, clamped to the row width.
        start = col - (len(name) - 1) // 2
        for k, ch in enumerate(name):
            pos = start + k
            if 0 <= pos < bar_width and label_row[pos] == ' ':
                label_row[pos] = ch
    tick_line = ''.join(tick_row)
    label_line = ''.join(label_row)

    return [
        header,
        '  ' + bar,
        '  ' + tick_line,
        '  ' + label_line,
        '',
        "  press 'q' to quit",
    ]


def _term_size():
    try:
        sz = os.get_terminal_size()
        return sz.columns, sz.lines
    except OSError:
        return 80, 24


def do_smeter_live(host, port, vhf, hz):
    """Live updating bargraph. `q` exits."""
    # Warn early if STRENGTH is unsupported, so the user sees a useful
    # message rather than a frozen empty bar.
    initial = _read_strength(host, port)
    if initial is None:
        raise RigError(
            "this Hamlib backend does not export RIG_LEVEL_STRENGTH "
            "(SDR backends like RTL-SDR / KiwiSDR / TCI report no "
            "S-meter via CAT).")

    period = 1.0 / max(1.0, float(hz))
    fd = sys.stdin.fileno()
    is_tty = sys.stdin.isatty()
    old_attrs = None
    if is_tty:
        old_attrs = termios.tcgetattr(fd)

    # Hide cursor, clear screen, home.
    sys.stdout.write("\033[?25l\033[2J\033[H")
    sys.stdout.flush()
    try:
        if is_tty:
            tty.setcbreak(fd)
        last_width = None
        strength = initial
        while True:
            cols, _rows = _term_size()
            lines = _smeter_render(strength, vhf, cols)
            # If width changed, clear so stale labels don't linger.
            if cols != last_width:
                sys.stdout.write("\033[2J")
                last_width = cols
            sys.stdout.write("\033[H")  # home
            # Right-pad to terminal width so a shorter line overwrites
            # whatever was there before.
            for ln in lines:
                pad = max(0, cols - len(ln))
                sys.stdout.write(ln + (' ' * pad) + "\n")
            sys.stdout.flush()

            # Wait for `q` or the refresh interval.
            t_end = time.monotonic() + period
            while True:
                remaining = t_end - time.monotonic()
                if remaining <= 0:
                    break
                if is_tty:
                    r, _, _ = select.select([fd], [], [], remaining)
                    if r:
                        ch = os.read(fd, 1)
                        if ch in (b'q', b'Q', b'\x03', b'\x1b'):
                            return
                        # other keys: ignore, keep waiting
                    else:
                        break
                else:
                    time.sleep(remaining)
                    break

            try:
                s = _read_strength(host, port)
            except RigError:
                s = None
            if s is not None:
                strength = s
    finally:
        if old_attrs is not None:
            termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)
        # Restore cursor, leave the bar on screen with one trailing newline.
        sys.stdout.write("\033[?25h\n")
        sys.stdout.flush()


def _level_summary(name):
    """Return a one-line description of valid values for a level keyword."""
    if name == 'agc':
        return (f"one of: {', '.join(sorted(AGC_LEVELS))}\n"
                f"    (Hamlib enum values 0..8)")
    typ = LEVELS.get(name, (None, None))[1]
    units = {
        'preamp':  'dB (e.g. 0, 10, 20)',
        'att':     'dB (e.g. 0, 6, 12)',
        'notch':   'Hz; 0 disables',
        'cwpitch': 'Hz (typical 400..1000)',
    }
    if typ == 'float':
        return "float in 0.0..1.0 (0 = off)"
    if typ == 'int':
        return f"integer in {units.get(name, 'Hz')}"
    return ""


def do_list(host, port, what):
    """Print valid values for keywords, or a directory of everything."""
    what = (what or '').lower()

    def print_modes():
        print("modes (for `hamctl mode <mode> [width_hz]`)")
        print("  " + ", ".join(sorted(MODES)))
        print("  Width is the passband in Hz; 0 lets the backend pick.")
        print("  Typical widths:")
        for m, w in [('USB / LSB', '2400'),
                     ('AM', '6000'), ('CW', '500'),
                     ('FM (NBFM)', '12500'),
                     ('WFM (broadcast)', '200000')]:
            print(f"    {m:<18}  {w} Hz")

    def print_agc():
        print("AGC presets (for `hamctl agc <name>`)")
        for n, v in sorted(AGC_LEVELS.items(), key=lambda kv: kv[1]):
            print(f"  {n:<10}  ({v})")

    def print_levels():
        print("DSP levels (set with `hamctl <name> <value>`)")
        for name in LEVELS:
            print(f"  {name:<8}  {_level_summary(name)}")

    def print_verbs():
        print("hamctl subcommands")
        print("  Radio:      freq, tune, mode, bandwidth, ptt, smeter")
        print("  DSP:        agc, squelch, nr, nb, rfgain, notch, preamp,")
        print("              att, cwpitch, apf, vad")
        print("  Audio:      audio list, audio sources, audio loopbacks,")
        print("              audio attach, audio detach")
        print("  Favorites:  save <name>, load <name>, rename <old> <new>,")
        print("              delete <name> (alias rm), favorites (alias favs)")
        print("  Bands:      tune <khz>, bands")
        print("  Other:      show (aliases: info, status), list")

    def print_audio_sinks():
        print("audio output devices (PulseAudio sinks)")
        try:
            sinks = list_sinks()
            descs = sink_descriptions()
            for s in sinks:
                d = descs.get(s['name'], '')
                marker = '*' if s['state'] == 'RUNNING' else ' '
                line = f"  {marker} {s['name']}"
                if d:
                    line += f"   ({d})"
                print(line)
            print("  (* = currently RUNNING; pass any unambiguous substring "
                  "to `audio attach`.)")
        except RuntimeError as e:
            print(f"  could not enumerate sinks: {e}")

    if what in ('', 'all'):
        print_verbs(); print()
        print_modes(); print()
        print_agc(); print()
        print_levels(); print()
        print_audio_sinks()
        return

    if what in ('modes', 'mode'):
        print_modes(); return
    if what in ('agc', 'agcs'):
        print_agc(); return
    if what in ('levels', 'dsp'):
        print_levels(); return
    if what in ('verbs', 'commands', 'subcommands'):
        print_verbs(); return
    if what in ('sinks', 'audio', 'devices'):
        print_audio_sinks(); return

    # Single-level keyword? Show just that summary.
    if what in LEVELS:
        print(f"valid values for `hamctl {what} <value>`:")
        print(f"  {_level_summary(what)}")
        return

    raise RigError(
        f"unknown topic {what!r}. Try one of: modes, agc, levels, "
        "verbs, sinks, or any DSP level name "
        f"({', '.join(LEVELS.keys())}).")


def do_ptt(host, port, value):
    if value is None:
        r = cat_get(host, port, 't')
        print(f"ptt: {'on' if r == '1' else 'off'}")
    else:
        on = 1 if value.lower() in ('1', 'on', 'true', 'yes') else 0
        cat_set(host, port, f'T {on}')
        print(f"ptt set: {'on' if on else 'off'}")


def do_vad(host, port, value):
    # VAD is not a separate DSP in the sidecar; the closest equivalent
    # is the RMS squelch in apply_squelch(). Make this verb a friendly
    # alias to keep the user's mental model intact, and explain.
    do_level_float(host, port, 'squelch', value)
    print("note: vad is implemented as RMS squelch on demodulated audio;")
    print("      add a real VAD by extending apply_squelch() in "
          "hamlib_sidecar_common.py.")


# ---------------------------------------------------------------------------
# Audio (pactl) plumbing
# ---------------------------------------------------------------------------

def _pactl_short(kind):
    """Return parsed rows from `pactl list <kind> short`."""
    r = subprocess.run(['pactl', 'list', kind, 'short'],
                       capture_output=True, text=True)
    if r.returncode != 0:
        raise RuntimeError(f"pactl list {kind} short failed: {r.stderr.strip()}")
    rows = []
    for line in r.stdout.splitlines():
        parts = line.split('\t')
        if len(parts) >= 2:
            rows.append(parts)
    return rows


def list_sinks():
    rows = _pactl_short('sinks')
    return [{'id': r[0], 'name': r[1], 'driver': r[2] if len(r) > 2 else '',
             'format': r[3] if len(r) > 3 else '',
             'state':  r[4] if len(r) > 4 else ''} for r in rows]


def list_sources():
    rows = _pactl_short('sources')
    return [{'id': r[0], 'name': r[1], 'driver': r[2] if len(r) > 2 else '',
             'format': r[3] if len(r) > 3 else '',
             'state':  r[4] if len(r) > 4 else ''} for r in rows]


def list_loopbacks():
    """Return [{id, args}] for every module-loopback instance."""
    rows = _pactl_short('modules')
    out = []
    for r in rows:
        if len(r) >= 2 and r[1] == 'module-loopback':
            args = r[2] if len(r) > 2 else ''
            out.append({'id': r[0], 'args': args})
    return out


def parse_loopback_args(args):
    """Pull source= and sink= out of a module-loopback args string."""
    src = sink = None
    for tok in args.split():
        if tok.startswith('source='):
            src = tok[len('source='):]
        elif tok.startswith('sink='):
            sink = tok[len('sink='):]
    return src, sink


def sink_descriptions():
    """Return {sink_name: description} from `pactl list sinks` (verbose)."""
    r = subprocess.run(['pactl', 'list', 'sinks'],
                       capture_output=True, text=True)
    if r.returncode != 0:
        return {}
    out = {}
    name = None
    for line in r.stdout.splitlines():
        s = line.strip()
        if s.startswith('Name:'):
            name = s.split(':', 1)[1].strip()
        elif s.startswith('Description:') and name:
            out[name] = s.split(':', 1)[1].strip()
            name = None
    return out


def _resolve_name(want, available, kind):
    """Resolve a name with exact or unambiguous substring match."""
    if want in available:
        return want
    matches = [n for n in available if want.lower() in n.lower()]
    if len(matches) == 1:
        return matches[0]
    if len(matches) > 1:
        raise RuntimeError(
            f"{kind} pattern {want!r} matches {len(matches)}: {matches}")
    raise RuntimeError(
        f"{kind} {want!r} not found. Available: {sorted(available)}")


def do_audio_list():
    descs = sink_descriptions()
    sinks = list_sinks()
    if not sinks:
        print("no sinks")
        return
    name_w = max(len(s['name']) for s in sinks)
    print(f"{'NAME':<{name_w}}  {'STATE':<9}  {'FORMAT':<24}  DESCRIPTION")
    for s in sinks:
        d = descs.get(s['name'], '')
        print(f"{s['name']:<{name_w}}  {s['state']:<9}  {s['format']:<24}  {d}")


def do_audio_sources():
    sources = list_sources()
    if not sources:
        print("no sources")
        return
    name_w = max(len(s['name']) for s in sources)
    print(f"{'NAME':<{name_w}}  {'STATE':<9}  FORMAT")
    for s in sources:
        print(f"{s['name']:<{name_w}}  {s['state']:<9}  {s['format']}")


def do_audio_loopbacks():
    lbs = list_loopbacks()
    if not lbs:
        print("no loopbacks")
        return
    print("MODULE  SOURCE                              SINK")
    for lb in lbs:
        src, sink = parse_loopback_args(lb['args'])
        print(f"{lb['id']:<6}  {src or '?':<35}  {sink or '?'}")


def do_audio_attach(sink, source, latency_msec):
    sinks = {s['name'] for s in list_sinks()}
    sink = _resolve_name(sink, sinks, 'sink')
    sources = {s['name'] for s in list_sources()}
    source = _resolve_name(source, sources, 'source')

    # Don't double-attach the same source->sink pair.
    for lb in list_loopbacks():
        s, k = parse_loopback_args(lb['args'])
        if s == source and k == sink:
            print(f"already attached: module {lb['id']}  {source} -> {sink}")
            return

    r = subprocess.run([
        'pactl', 'load-module', 'module-loopback',
        f'source={source}', f'sink={sink}',
        f'latency_msec={int(latency_msec)}',
    ], capture_output=True, text=True)
    if r.returncode != 0:
        raise RuntimeError(f"load-module failed: {r.stderr.strip()}")
    module_id = r.stdout.strip()
    print(f"attached: {source} -> {sink}  (module {module_id})")


def do_audio_detach(target):
    """Detach by exact module id, by sink/source substring, or 'all'."""
    lbs = list_loopbacks()
    if not lbs:
        print("no loopbacks to detach")
        return

    to_unload = []
    if target == 'all':
        to_unload = [(lb['id'], lb['args']) for lb in lbs]
    elif target.isdigit():
        for lb in lbs:
            if lb['id'] == target:
                to_unload.append((lb['id'], lb['args']))
                break
        if not to_unload:
            raise RuntimeError(f"no loopback with module id {target}")
    else:
        for lb in lbs:
            if target.lower() in lb['args'].lower():
                to_unload.append((lb['id'], lb['args']))
        if not to_unload:
            raise RuntimeError(
                f"no loopback matching {target!r}. Active:\n  " +
                "\n  ".join(f"module {lb['id']}: {lb['args']}" for lb in lbs))

    for mid, args in to_unload:
        src, sink = parse_loopback_args(args)
        r = subprocess.run(['pactl', 'unload-module', mid],
                           capture_output=True, text=True)
        if r.returncode != 0:
            print(f"  module {mid}: unload failed: {r.stderr.strip()}",
                  file=sys.stderr)
        else:
            print(f"  detached module {mid}  ({src} -> {sink})")


# ---------------------------------------------------------------------------
# Favorites (saved radio configurations)
# ---------------------------------------------------------------------------

# Schema version for the on-disk JSON. Bump if the captured field set
# changes incompatibly so future hamctl versions can migrate or refuse.
FAVORITES_SCHEMA = 1


def default_favorites_path():
    """The standard location, following XDG. Honors $HAMCTL_FAVORITES
    and $XDG_CONFIG_HOME if set."""
    env = os.environ.get('HAMCTL_FAVORITES')
    if env:
        return os.path.expanduser(env)
    xdg = os.environ.get('XDG_CONFIG_HOME')
    base = xdg if xdg else os.path.expanduser('~/.config')
    return os.path.join(base, 'hamctl', 'favorites.json')


def load_favorites(path):
    """Read favorites file; return ({name: entry}, schema). Missing file
    is fine -- treat as empty. Malformed JSON raises RuntimeError."""
    if not os.path.exists(path):
        return {}, FAVORITES_SCHEMA
    try:
        with open(path, 'r') as f:
            data = json.load(f)
    except json.JSONDecodeError as e:
        raise RuntimeError(f"favorites file {path} is not valid JSON: {e}")
    except OSError as e:
        raise RuntimeError(f"cannot read {path}: {e}")
    schema = int(data.get('schema', FAVORITES_SCHEMA))
    favs = data.get('favorites', {})
    if not isinstance(favs, dict):
        raise RuntimeError(
            f"{path}: 'favorites' is not a dict (corrupted file?)")
    return favs, schema


def save_favorites(path, favs):
    """Write favorites file atomically."""
    d = os.path.dirname(path)
    if d:
        os.makedirs(d, exist_ok=True)
    tmp = path + '.tmp'
    payload = {'schema': FAVORITES_SCHEMA, 'favorites': favs}
    try:
        with open(tmp, 'w') as f:
            json.dump(payload, f, indent=2, sort_keys=True)
            f.write('\n')
        os.replace(tmp, path)
    except OSError as e:
        raise RuntimeError(f"could not write {path}: {e}")


def snapshot_radio(host, port):
    """Capture current radio + DSP state into a favorites entry.

    Skipped: PTT (saving an on-air state into a "favorite" is a footgun;
    if you want hands-off TX automation, build that as a separate verb).
    """
    try:
        freq_hz = int(cat_get(host, port, 'f') or 0)
    except (RigError, ValueError):
        freq_hz = 0
    mode_lines = cat(host, port, 'm').splitlines()
    mode = mode_lines[0] if mode_lines else ''
    width = 0
    if len(mode_lines) > 1:
        try:
            width = int(mode_lines[1])
        except ValueError:
            pass

    levels = {}
    for name, (hl, typ) in LEVELS.items():
        try:
            raw = cat_get(host, port, f'l {hl}')
        except RigError:
            continue
        if raw in ('', None):
            continue
        try:
            if typ == 'float':
                levels[name] = float(raw)
            else:  # int or agc enum -- both come back numeric
                levels[name] = int(float(raw))
        except (ValueError, TypeError):
            pass

    return {
        'freq_hz': freq_hz,
        'mode':    mode,
        'width':   width,
        'levels':  levels,
        'saved_at_iso': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
        'rigctld': f"{host}:{port}",
    }


def apply_favorite(host, port, entry):
    """Apply an entry to the live radio. Order: mode -> freq -> levels,
    so the backend's filter logic sees a consistent mode before we set
    width / DSP knobs."""
    mode  = entry.get('mode')
    width = int(entry.get('width', 0) or 0)
    if mode:
        cat_set(host, port, f'M {mode} {width}')
    freq_hz = int(entry.get('freq_hz', 0) or 0)
    if freq_hz > 0:
        cat_set(host, port, f'F {freq_hz}')
    for name, raw in (entry.get('levels') or {}).items():
        hl_info = LEVELS.get(name)
        if not hl_info:
            print(f"  warn: unknown level {name!r} in favorite, skipping",
                  file=sys.stderr)
            continue
        hl = hl_info[0]
        # Some backends reject sets they don't support; that's fine,
        # just warn and continue rather than aborting the whole load.
        try:
            cat_set(host, port, f'L {hl} {raw}')
        except RigError as e:
            print(f"  warn: could not set {name}={raw}: {e.args[0].splitlines()[0]}",
                  file=sys.stderr)


def do_save(host, port, name, path):
    favs, _ = load_favorites(path)
    favs[name] = snapshot_radio(host, port)
    save_favorites(path, favs)
    e = favs[name]
    print(f"saved {name!r} to {path}")
    print(f"  freq:  {fmt_freq_hz(e['freq_hz'])}")
    print(f"  mode:  {e['mode']} {e['width']} Hz")
    if e['levels']:
        print(f"  levels: {', '.join(f'{k}={v}' for k,v in e['levels'].items())}")


def do_load(host, port, name, path):
    favs, _ = load_favorites(path)
    if name not in favs:
        raise RigError(
            f"no favorite named {name!r} in {path}. "
            f"Known: {sorted(favs.keys()) if favs else '(none)'}")
    entry = favs[name]
    apply_favorite(host, port, entry)
    print(f"loaded {name!r}: {fmt_freq_hz(entry['freq_hz'])} "
          f"{entry['mode']} {entry['width']} Hz")


def do_rename(name_from, name_to, path):
    favs, _ = load_favorites(path)
    if name_from not in favs:
        raise RigError(f"no favorite named {name_from!r}")
    if name_to in favs and name_to != name_from:
        # No confirmation requested by spec; overwrite silently.
        pass
    favs[name_to] = favs.pop(name_from)
    save_favorites(path, favs)
    print(f"renamed {name_from!r} -> {name_to!r}")


def do_delete(name, path):
    favs, _ = load_favorites(path)
    if name not in favs:
        raise RigError(f"no favorite named {name!r}")
    del favs[name]
    save_favorites(path, favs)
    print(f"deleted {name!r}")


def do_favorites(path):
    favs, schema = load_favorites(path)
    if not favs:
        print(f"no favorites in {path}")
        return
    name_w = max(len(n) for n in favs)
    print(f"{'NAME':<{name_w}}  FREQ            MODE  WIDTH      LEVELS")
    for name in sorted(favs):
        e = favs[name]
        lev = ', '.join(f"{k}={v}" for k, v in (e.get('levels') or {}).items())
        if len(lev) > 50:
            lev = lev[:47] + '...'
        print(f"{name:<{name_w}}  {fmt_freq_hz(e['freq_hz']):<14}  "
              f"{e['mode']:<4}  {e['width']:>6} Hz  {lev}")


# ---------------------------------------------------------------------------
# Bands (auto-apply mode/width/DSP based on frequency)
# ---------------------------------------------------------------------------

# Default band plan, shipped when bands.json doesn't yet exist.
# Frequencies are in kHz; entries are listed roughly in lf -> shf order.
# Lookup picks the entry whose [lo_khz, hi_khz] range contains the target
# AND has the narrowest span -- so HF sub-bands (CW vs SSB portions of a
# given ham band) win over broader catch-alls.
DEFAULT_BANDS = [
    # HF AM broadcast
    {'name': 'AM broadcast (MW)',  'lo_khz':   530,    'hi_khz':   1710,
     'mode': 'AM',  'width': 9000,
     'levels': {'agc': 'slow', 'squelch': 0.0}},
    # 160 m amateur (SSB portion, LSB by convention)
    {'name': '160 m SSB',          'lo_khz':  1840,    'hi_khz':   2000,
     'mode': 'LSB', 'width': 2400,
     'levels': {'agc': 'slow', 'squelch': 0.0}},
    # 80 m: CW & SSB sub-bands
    {'name': '80 m CW',            'lo_khz':  3500,    'hi_khz':   3600,
     'mode': 'CW',  'width': 500,
     'levels': {'agc': 'slow', 'cwpitch': 700}},
    {'name': '80 m SSB',           'lo_khz':  3600,    'hi_khz':   4000,
     'mode': 'LSB', 'width': 2400,
     'levels': {'agc': 'slow', 'squelch': 0.0}},
    # 60 m channels (USB by FCC rule)
    {'name': '60 m SSB',           'lo_khz':  5330,    'hi_khz':   5410,
     'mode': 'USB', 'width': 2400,
     'levels': {'agc': 'slow'}},
    # 40 m
    {'name': '40 m CW',            'lo_khz':  7000,    'hi_khz':   7125,
     'mode': 'CW',  'width': 500,
     'levels': {'agc': 'slow', 'cwpitch': 700}},
    {'name': '40 m SSB',           'lo_khz':  7125,    'hi_khz':   7300,
     'mode': 'LSB', 'width': 2400,
     'levels': {'agc': 'slow'}},
    # Shortwave broadcast (representative sample, not exhaustive)
    {'name': '41 m SW broadcast',  'lo_khz':  7200,    'hi_khz':   7450,
     'mode': 'AM',  'width': 6000,
     'levels': {'agc': 'slow'}},
    {'name': '31 m SW broadcast',  'lo_khz':  9400,    'hi_khz':   9900,
     'mode': 'AM',  'width': 6000,
     'levels': {'agc': 'slow'}},
    {'name': '25 m SW broadcast',  'lo_khz': 11600,    'hi_khz':  12100,
     'mode': 'AM',  'width': 6000,
     'levels': {'agc': 'slow'}},
    {'name': '19 m SW broadcast',  'lo_khz': 15100,    'hi_khz':  15800,
     'mode': 'AM',  'width': 6000,
     'levels': {'agc': 'slow'}},
    # 30 m (CW + digital only)
    {'name': '30 m CW',            'lo_khz': 10100,    'hi_khz':  10150,
     'mode': 'CW',  'width': 500,
     'levels': {'agc': 'slow', 'cwpitch': 700}},
    # 20 m
    {'name': '20 m CW',            'lo_khz': 14000,    'hi_khz':  14150,
     'mode': 'CW',  'width': 500,
     'levels': {'agc': 'slow', 'cwpitch': 700}},
    {'name': '20 m SSB',           'lo_khz': 14150,    'hi_khz':  14350,
     'mode': 'USB', 'width': 2400,
     'levels': {'agc': 'slow'}},
    # 17 m
    {'name': '17 m CW',            'lo_khz': 18068,    'hi_khz':  18110,
     'mode': 'CW',  'width': 500,
     'levels': {'agc': 'slow', 'cwpitch': 700}},
    {'name': '17 m SSB',           'lo_khz': 18110,    'hi_khz':  18168,
     'mode': 'USB', 'width': 2400,
     'levels': {'agc': 'slow'}},
    # 15 m
    {'name': '15 m CW',            'lo_khz': 21000,    'hi_khz':  21200,
     'mode': 'CW',  'width': 500,
     'levels': {'agc': 'slow', 'cwpitch': 700}},
    {'name': '15 m SSB',           'lo_khz': 21200,    'hi_khz':  21450,
     'mode': 'USB', 'width': 2400,
     'levels': {'agc': 'slow'}},
    # 12 m
    {'name': '12 m CW',            'lo_khz': 24890,    'hi_khz':  24930,
     'mode': 'CW',  'width': 500,
     'levels': {'agc': 'slow', 'cwpitch': 700}},
    {'name': '12 m SSB',           'lo_khz': 24930,    'hi_khz':  24990,
     'mode': 'USB', 'width': 2400,
     'levels': {'agc': 'slow'}},
    # CB
    {'name': 'CB (27 MHz AM)',     'lo_khz': 26965,    'hi_khz':  27405,
     'mode': 'AM',  'width': 6000,
     'levels': {'agc': 'medium', 'squelch': 0.1}},
    # 10 m
    {'name': '10 m CW',            'lo_khz': 28000,    'hi_khz':  28300,
     'mode': 'CW',  'width': 500,
     'levels': {'agc': 'slow', 'cwpitch': 700}},
    {'name': '10 m SSB',           'lo_khz': 28300,    'hi_khz':  29000,
     'mode': 'USB', 'width': 2400,
     'levels': {'agc': 'slow'}},
    {'name': '10 m FM',            'lo_khz': 29500,    'hi_khz':  29700,
     'mode': 'FM',  'width': 12500,
     'levels': {'agc': 'medium', 'squelch': 0.15}},
    # 6 m amateur (USB on weak-signal end, FM uppers)
    {'name': '6 m SSB',            'lo_khz': 50100,    'hi_khz':  50500,
     'mode': 'USB', 'width': 2400,
     'levels': {'agc': 'slow'}},
    {'name': '6 m FM',             'lo_khz': 52000,    'hi_khz':  54000,
     'mode': 'FM',  'width': 12500,
     'levels': {'agc': 'medium', 'squelch': 0.15}},
    # FM broadcast
    {'name': 'FM broadcast',       'lo_khz': 87500,    'hi_khz': 108000,
     'mode': 'WFM', 'width': 200000,
     'levels': {'agc': 'off'}},
    # Civil aviation AM (118-137 MHz)
    {'name': 'Air band (VHF AM)',  'lo_khz':118000,    'hi_khz': 137000,
     'mode': 'AM',  'width': 6000,
     'levels': {'agc': 'fast', 'squelch': 0.05}},
    # 2 m amateur (weak-signal vs FM)
    {'name': '2 m SSB',            'lo_khz':144000,    'hi_khz': 144300,
     'mode': 'USB', 'width': 2400,
     'levels': {'agc': 'slow'}},
    {'name': '2 m FM',             'lo_khz':144500,    'hi_khz': 148000,
     'mode': 'FM',  'width': 12500,
     'levels': {'agc': 'medium', 'squelch': 0.15}},
    # NOAA Weather Radio
    {'name': 'NOAA weather',       'lo_khz':162400,    'hi_khz': 162575,
     'mode': 'FM',  'width': 12500,
     'levels': {'agc': 'medium', 'squelch': 0.10}},
    # MURS
    {'name': 'MURS',               'lo_khz':151820,    'hi_khz': 154600,
     'mode': 'FM',  'width': 12500,
     'levels': {'agc': 'medium', 'squelch': 0.15}},
    # Marine VHF
    {'name': 'Marine VHF',         'lo_khz':156000,    'hi_khz': 162025,
     'mode': 'FM',  'width': 12500,
     'levels': {'agc': 'medium', 'squelch': 0.15}},
    # Mil air (AM)
    {'name': 'Mil air (UHF AM)',   'lo_khz':225000,    'hi_khz': 400000,
     'mode': 'AM',  'width': 12500,
     'levels': {'agc': 'fast', 'squelch': 0.05}},
    # 70 cm amateur (FM portion)
    {'name': '70 cm FM',           'lo_khz':440000,    'hi_khz': 450000,
     'mode': 'FM',  'width': 12500,
     'levels': {'agc': 'medium', 'squelch': 0.15}},
    # GMRS / FRS (rough range)
    {'name': 'GMRS / FRS',         'lo_khz':462550,    'hi_khz': 467725,
     'mode': 'FM',  'width': 12500,
     'levels': {'agc': 'medium', 'squelch': 0.15}},
]


def default_bands_path():
    """Path to the bands file. Lives next to favorites.json."""
    env = os.environ.get('HAMCTL_BANDS')
    if env:
        return os.path.expanduser(env)
    return os.path.join(os.path.dirname(default_favorites_path()), 'bands.json')


def load_bands(path):
    """Read bands file; if it doesn't exist, seed it with the defaults
    so the user has something to edit. Returns the list of band entries."""
    if not os.path.exists(path):
        bands = list(DEFAULT_BANDS)
        try:
            save_bands(path, bands)
        except RuntimeError:
            # If we can't write the seed, fall back to in-memory defaults.
            pass
        return bands
    try:
        with open(path, 'r') as f:
            data = json.load(f)
    except json.JSONDecodeError as e:
        raise RuntimeError(f"bands file {path} is not valid JSON: {e}")
    except OSError as e:
        raise RuntimeError(f"cannot read {path}: {e}")
    bands = data.get('bands')
    if not isinstance(bands, list):
        raise RuntimeError(f"{path}: 'bands' is not a list (corrupted?)")
    return bands


def save_bands(path, bands):
    """Write the bands file atomically. Schema 1."""
    d = os.path.dirname(path)
    if d:
        os.makedirs(d, exist_ok=True)
    tmp = path + '.tmp'
    payload = {'schema': 1, 'bands': bands}
    try:
        with open(tmp, 'w') as f:
            json.dump(payload, f, indent=2)
            f.write('\n')
        os.replace(tmp, path)
    except OSError as e:
        raise RuntimeError(f"could not write {path}: {e}")


def find_band(bands, khz):
    """Return the narrowest-span band containing `khz`, or None."""
    candidates = []
    for b in bands:
        lo = float(b.get('lo_khz', 0))
        hi = float(b.get('hi_khz', 0))
        if lo <= khz <= hi:
            candidates.append((hi - lo, b))
    if not candidates:
        return None
    candidates.sort(key=lambda t: t[0])
    return candidates[0][1]


def _apply_band_levels(host, port, levels):
    """Apply a band's level dict. AGC may be a name or an int; floats and
    ints flow through the existing set verbs."""
    for name, raw in (levels or {}).items():
        if name == 'agc':
            if isinstance(raw, str):
                lvl = raw.lower()
                if lvl not in AGC_LEVELS:
                    print(f"  warn: skipping AGC={raw!r} (unknown)",
                          file=sys.stderr)
                    continue
                num = AGC_LEVELS[lvl]
            else:
                num = int(raw)
            try:
                cat_set(host, port, f'L AGC {num}')
            except RigError as e:
                print(f"  warn: could not set agc: "
                      f"{e.args[0].splitlines()[0]}", file=sys.stderr)
            continue
        hl_info = LEVELS.get(name)
        if not hl_info:
            print(f"  warn: unknown level {name!r} in band, skipping",
                  file=sys.stderr)
            continue
        hl = hl_info[0]
        try:
            cat_set(host, port, f'L {hl} {raw}')
        except RigError as e:
            print(f"  warn: could not set {name}={raw}: "
                  f"{e.args[0].splitlines()[0]}", file=sys.stderr)


def do_tune(host, port, khz_arg, bands_path):
    """Set frequency AND apply the matching band's mode/width/levels.

    The kHz argument follows the same rules as `freq`: a leading +/-
    means a delta from the current frequency, otherwise absolute.
    """
    s = str(khz_arg).strip()
    relative = s.startswith(('+', '-'))
    try:
        khz = float(s)
    except ValueError:
        raise RigError(f"could not parse {khz_arg!r} as a kHz value")

    if relative:
        try:
            cur_hz = int(cat_get(host, port, 'f') or 0)
        except (RigError, ValueError):
            raise RigError("could not read current frequency for relative tune")
        target_khz = cur_hz / 1000.0 + khz
    else:
        target_khz = khz

    bands = load_bands(bands_path)
    band = find_band(bands, target_khz)

    if band:
        mode  = band.get('mode')
        width = int(band.get('width', 0) or 0)
        if mode:
            cat_set(host, port, f'M {mode} {width}')
        # Set frequency AFTER mode so the backend's filter logic sees
        # the right mode before we (re)set width / DSP knobs.
        hz = int(round(target_khz * 1000))
        if hz < 0:
            raise RigError(f"target {target_khz:+.3f} kHz is negative")
        cat_set(host, port, f'F {hz}')
        _apply_band_levels(host, port, band.get('levels'))
        levels = band.get('levels') or {}
        lev_str = ', '.join(f"{k}={v}" for k, v in levels.items())
        print(f"tuned: {fmt_freq_hz(hz)}  [{band.get('name', '?')}]")
        print(f"  mode: {mode} {width} Hz")
        if lev_str:
            print(f"  levels: {lev_str}")
    else:
        # No matching band -- just set the frequency.
        hz = int(round(target_khz * 1000))
        if hz < 0:
            raise RigError(f"target {target_khz:+.3f} kHz is negative")
        cat_set(host, port, f'F {hz}')
        print(f"tuned: {fmt_freq_hz(hz)}  (no band match, only frequency set)")


def do_bands(bands_path):
    bands = load_bands(bands_path)
    if not bands:
        print(f"no bands defined in {bands_path}")
        return
    name_w = max(len(b.get('name', '?')) for b in bands)
    print(f"{'NAME':<{name_w}}  RANGE                       MODE  WIDTH    LEVELS")
    # Sort by lower edge for readability.
    for b in sorted(bands, key=lambda x: float(x.get('lo_khz', 0))):
        lo = float(b.get('lo_khz', 0))
        hi = float(b.get('hi_khz', 0))
        rng = f"{lo:>10.0f} - {hi:<10.0f} kHz"
        levels = b.get('levels') or {}
        lev = ', '.join(f"{k}={v}" for k, v in levels.items())
        if len(lev) > 40:
            lev = lev[:37] + '...'
        print(f"{b.get('name','?'):<{name_w}}  {rng}  "
              f"{b.get('mode','?'):<4}  "
              f"{int(b.get('width',0) or 0):>6} Hz  {lev}")
    print(f"\n  bands file: {bands_path}")


# ---------------------------------------------------------------------------
# Argparse glue
# ---------------------------------------------------------------------------

def build_parser():
    p = argparse.ArgumentParser(
        prog='hamctl',
        description='Control the Hamlib SDR pipeline (radio + sidecar + audio).',
        formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument('--host', default='localhost',
                   help='rigctld host (default: localhost)')
    p.add_argument('--port', type=int, default=4532,
                   help='rigctld CAT port (default: 4532)')
    p.add_argument('-f', '--favorites', default=None,
                   help='path to favorites file (default: '
                        '$HAMCTL_FAVORITES or '
                        '~/.config/hamctl/favorites.json)')
    p.add_argument('--bands', default=None,
                   help='path to bands file (default: $HAMCTL_BANDS or '
                        '~/.config/hamctl/bands.json; auto-seeded with '
                        'defaults on first use)')

    sub = p.add_subparsers(dest='verb', required=True)

    sub.add_parser('show',
                   aliases=['info', 'status'],
                   help='show current radio, DSP, and audio state')

    sp = sub.add_parser('save',
                        help='save current radio state as a favorite')
    sp.add_argument('name', help='name to save under')

    sp = sub.add_parser('load',
                        help='apply a saved favorite to the radio')
    sp.add_argument('name', help='favorite name to load')

    sp = sub.add_parser('rename',
                        help='rename a saved favorite')
    sp.add_argument('old')
    sp.add_argument('new')

    sp = sub.add_parser('delete',
                        aliases=['rm'],
                        help='delete a saved favorite')
    sp.add_argument('name')

    sub.add_parser('favorites',
                   aliases=['favs'],
                   help='list saved favorites')

    sp = sub.add_parser('list',
                        help='list valid values for a keyword (modes, '
                             'agc, levels, verbs, sinks). With no '
                             'argument, prints all of them.')
    sp.add_argument('topic', nargs='?', default='',
                    help='modes | agc | levels | verbs | sinks | '
                         'or any DSP level name (e.g. squelch)')

    sp = sub.add_parser(
        'freq',
        help='set frequency (kHz). Absolute: "freq 162550". '
             'Relative: "freq +5" (up 5 kHz), "freq -2.5" (down 2.5 kHz).')
    # type=str so argparse doesn't claim `-5` as a flag; do_freq parses.
    sp.add_argument('khz', type=str, metavar='KHZ',
                    help='absolute kHz (e.g. 162550), or signed delta '
                         '(e.g. +5, -2.5)')

    sp = sub.add_parser(
        'tune',
        help='set frequency and apply the band plan (mode, width, '
             'AGC, etc.). Same kHz syntax as freq.')
    sp.add_argument('khz', type=str, metavar='KHZ',
                    help='absolute kHz (e.g. 124300), or signed delta '
                         '(e.g. +25)')

    sub.add_parser('bands', help='list known bands')

    sp = sub.add_parser('mode', help='set mode and passband width')
    sp.add_argument('mode',
                    help=f"one of {sorted(MODES)}")
    sp.add_argument('width', type=int, nargs='?', default=0,
                    help='passband width in Hz (0 = mode default)')

    sp = sub.add_parser('bandwidth',
                        help='set passband width, keep current mode')
    sp.add_argument('width', type=int)

    sp = sub.add_parser('agc', help='set AGC level')
    sp.add_argument('level',
                    help=f"one of {sorted(AGC_LEVELS)}")

    for name in ('squelch', 'nr', 'nb', 'rfgain', 'apf'):
        sp = sub.add_parser(name, help=f"set {name} (float 0..1)")
        sp.add_argument('value', type=float)

    for name in ('notch', 'preamp', 'att', 'cwpitch'):
        units = {'notch': 'Hz', 'preamp': 'dB',
                 'att': 'dB', 'cwpitch': 'Hz'}[name]
        sp = sub.add_parser(name, help=f"set {name} ({units})")
        sp.add_argument('value', type=int)

    sp = sub.add_parser('vad',
                        help='alias for squelch (no spectral VAD impl)')
    sp.add_argument('value', type=float)

    sp = sub.add_parser('ptt', help='set or get PTT state')
    sp.add_argument('value', nargs='?', help='0|1|on|off (omit to read)')

    sp = sub.add_parser('smeter',
                        help='read signal strength in S-units and dBm; '
                             '"smeter live" for a realtime bargraph')
    sp.add_argument('mode', nargs='?', default='',
                    help="omit for one-shot read; 'live' for bargraph "
                         "(q to quit)")
    sp.add_argument('--vhf', action='store_true',
                    help='use VHF S-meter scale (S9 = -93 dBm); default is HF')
    sp.add_argument('--hz', type=float, default=8.0,
                    help='update rate for live mode (default: 8 Hz)')

    ap = sub.add_parser('audio', help='audio devices + routing')
    ap_sub = ap.add_subparsers(dest='audio_verb', required=True)

    ap_sub.add_parser('list',
                      help='list output devices (PulseAudio sinks)')
    ap_sub.add_parser('sources',
                      help='list input/monitor sources')
    ap_sub.add_parser('loopbacks',
                      help='list active loopback modules')

    aa = ap_sub.add_parser('attach',
                           help='attach a source to a sink via loopback')
    aa.add_argument('sink',
                    help='target sink name or substring')
    aa.add_argument('--source', default='hamlib-rx.monitor',
                    help='source (default: hamlib-rx.monitor)')
    aa.add_argument('--latency', type=int, default=200,
                    help='loopback latency in ms (default: 200)')

    ad = ap_sub.add_parser('detach',
                           help='remove loopback(s) by id, name pattern, or "all"')
    ad.add_argument('target')

    return p


def main(argv=None):
    p = build_parser()
    args = p.parse_args(argv)
    fav_path = args.favorites or default_favorites_path()
    bands_path = args.bands or default_bands_path()
    try:
        if args.verb in ('show', 'info', 'status'):
            do_info(args.host, args.port)
        elif args.verb == 'save':
            do_save(args.host, args.port, args.name, fav_path)
        elif args.verb == 'load':
            do_load(args.host, args.port, args.name, fav_path)
        elif args.verb == 'rename':
            do_rename(args.old, args.new, fav_path)
        elif args.verb in ('delete', 'rm'):
            do_delete(args.name, fav_path)
        elif args.verb in ('favorites', 'favs'):
            do_favorites(fav_path)
        elif args.verb == 'list':
            do_list(args.host, args.port, args.topic)
        elif args.verb == 'freq':
            do_freq(args.host, args.port, args.khz)
        elif args.verb == 'tune':
            do_tune(args.host, args.port, args.khz, bands_path)
        elif args.verb == 'bands':
            do_bands(bands_path)
        elif args.verb == 'mode':
            do_mode(args.host, args.port, args.mode, args.width)
        elif args.verb == 'bandwidth':
            do_bandwidth(args.host, args.port, args.width)
        elif args.verb == 'agc':
            do_agc(args.host, args.port, args.level)
        elif args.verb in ('squelch', 'nr', 'nb', 'rfgain', 'apf'):
            do_level_float(args.host, args.port, args.verb, args.value)
        elif args.verb in ('notch', 'preamp', 'att', 'cwpitch'):
            do_level_int(args.host, args.port, args.verb, args.value)
        elif args.verb == 'vad':
            do_vad(args.host, args.port, args.value)
        elif args.verb == 'ptt':
            do_ptt(args.host, args.port, args.value)
        elif args.verb == 'smeter':
            sm_mode = (args.mode or '').lower()
            if sm_mode == 'live':
                do_smeter_live(args.host, args.port, args.vhf, args.hz)
            elif sm_mode in ('', 'once'):
                do_smeter(args.host, args.port, args.vhf)
            else:
                raise RigError(
                    f"unknown smeter mode {args.mode!r}; "
                    "use 'smeter' for one-shot or 'smeter live' for bargraph")
        elif args.verb == 'audio':
            if args.audio_verb == 'list':
                do_audio_list()
            elif args.audio_verb == 'sources':
                do_audio_sources()
            elif args.audio_verb == 'loopbacks':
                do_audio_loopbacks()
            elif args.audio_verb == 'attach':
                do_audio_attach(args.sink, args.source, args.latency)
            elif args.audio_verb == 'detach':
                do_audio_detach(args.target)
        else:
            p.print_help()
            sys.exit(2)
    except (RigError, RuntimeError) as e:
        print(f"error: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == '__main__':
    main()
