"""ToneOut Pi Feed Node — Admin API + Pi management"""

from flask import Flask, jsonify, abort, Response, request, send_file
import json, os, re, time, psutil, subprocess, zipfile, io, urllib.request
import signal, socket, shutil, tarfile
from datetime import datetime, timezone
from pathlib import Path

app = Flask(__name__)

RECORDINGS_DIR = Path("/var/lib/toneout/recordings")
ICECAST_STATUS_URL = "http://127.0.0.1:8000/status-json.xsl"
RTL_AIRBAND_CONFIG = "/etc/rtl_airband/rtl_airband.conf"
RTL_AIRBAND_BACKUP = "/etc/rtl_airband/rtl_airband.conf.bak"
TOKEN_PATH = "/etc/toneout/api-token"
NODE_NAME = "toneout-node-01"
NODE_LOCATION = "Glastonbury, CT"
SDR_BANDWIDTH_HZ = 2_000_000

CTCSS_TONES = [
    67.0, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5, 94.8,
    97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, 131.8,
    136.5, 141.3, 146.2, 151.4, 156.7, 159.8, 162.2, 165.5, 167.9, 171.3,
    173.8, 177.3, 179.9, 183.5, 186.2, 189.9, 192.8, 196.6, 199.5, 203.5,
    206.5, 210.7, 218.1, 225.7, 229.1, 233.6, 241.8, 250.3, 254.1,
]

# Pi management additions
RUN_DIR = Path("/run/toneout-admin")
SURVEY_STATE = RUN_DIR / "survey.state"
IMAGE_BACKUP_STATE = RUN_DIR / "image-backup.state"
USB_BACKUP_DIR = Path("/mnt/usb-backup")
CONFIG_SNAPSHOT_DIR = USB_BACKUP_DIR / "config-snapshots"
IMAGE_BACKUP_DIR = USB_BACKUP_DIR / "sd-images"
LOCAL_SNAPSHOT_DIR = Path("/var/lib/toneout/snapshots")
RTL_TCP_PORT = 1234
BACKUP_IMAGE_SCRIPT = Path("/opt/toneout-api/scripts/backup-image.sh")
CONFIG_SNAPSHOT_PATHS = [
    "/etc/rtl_airband/rtl_airband.conf",
    "/etc/icecast2/icecast.xml",
    "/etc/nginx/sites-enabled/default",
    "/etc/systemd/system/toneout-api.service",
    "/etc/systemd/system/rtl_airband.service",
    "/etc/sudoers.d/toneout-admin",
    "/opt/toneout-api/app.py",
    "/var/www/toneout/index.html",
]


def load_token():
    try:
        with open(TOKEN_PATH) as f: return f.read().strip()
    except Exception: return None


def require_token():
    token = request.headers.get("X-API-Token") or request.args.get("token")
    expected = load_token()
    if not expected or token != expected:
        abort(401, "invalid or missing token")


def parse_rtl_config():
    try:
        with open(RTL_AIRBAND_CONFIG) as f: content = f.read()
    except (IOError, OSError): return []
    channels = []
    blocks = re.split(r'\bfreq\s*=\s*', content)[1:]
    for block in blocks:
        freq_m = re.match(r'([\d.]+)', block)
        label_m = re.search(r'label\s*=\s*"([^"]+)"', block[:500])
        ctcss_m = re.search(r'^\s*ctcss\s*=\s*([\d.]+)', block[:500], re.MULTILINE)
        sq_m = re.search(r'^\s*squelch_threshold\s*=\s*(-?[\d.]+)', block[:500], re.MULTILINE)
        mp_m = re.search(r'mountpoint\s*=\s*"([^"]+)"', block[:1000])
        if freq_m and mp_m:
            channels.append({
                "id": mp_m.group(1),
                "label": label_m.group(1) if label_m else "Unknown",
                "frequency_mhz": float(freq_m.group(1)),
                "ctcss_hz": float(ctcss_m.group(1)) if ctcss_m else None,
                "squelch_db": int(float(sq_m.group(1))) if sq_m else None,
            })
    return [c for c in channels if "fire-ems" not in c["id"]]


def get_icecast_sources():
    try:
        with urllib.request.urlopen(ICECAST_STATUS_URL, timeout=2) as r:
            data = json.loads(r.read())
    except Exception: return []
    sources = data.get("icestats", {}).get("source", [])
    if isinstance(sources, dict): sources = [sources]
    return sources


def update_config_freqs(channels):
    with open(RTL_AIRBAND_CONFIG) as f:
        content = f.read()
    freqs_hz = [int(c["frequency_mhz"] * 1_000_000) for c in channels]
    centerfreq_hz = (min(freqs_hz) + max(freqs_hz)) // 2
    span_hz = max(freqs_hz) - min(freqs_hz)
    if span_hz > SDR_BANDWIDTH_HZ:
        raise ValueError(f"span {span_hz/1e6:.3f} MHz > SDR bandwidth {SDR_BANDWIDTH_HZ/1e6} MHz")
    content = re.sub(r'centerfreq\s*=\s*\d+', f'centerfreq = {centerfreq_hz}', content, count=1)
    for ch in channels:
        mp = ch["id"]
        new_freq = ch["frequency_mhz"]
        new_ctcss = ch.get("ctcss_hz")
        new_squelch = ch.get("squelch_db")
        mp_pat = re.compile(rf'mountpoint\s*=\s*"{re.escape(mp)}"')
        mp_match = mp_pat.search(content)
        if not mp_match:
            raise ValueError(f"mountpoint '{mp}' not found")
        prefix = content[:mp_match.start()]
        freq_pat = re.compile(r'freq\s*=\s*[\d.]+\s*;')
        freqs = list(freq_pat.finditer(prefix))
        if not freqs:
            raise ValueError(f"freq line not found for '{mp}'")
        fm = freqs[-1]
        content = content[:fm.start()] + f'freq = {new_freq};' + content[fm.end():]
        mp_match = mp_pat.search(content)
        prefix = content[:mp_match.start()]
        fm = list(freq_pat.finditer(prefix))[-1]
        next_freq = freq_pat.search(content, mp_match.end())
        block_end = next_freq.start() if next_freq else mp_match.end() + 500
        block_start = fm.start()
        block = content[block_start:block_end]
        block = re.sub(r'^\s*ctcss\s*=\s*[\d.]+\s*;\s*\n', '', block, flags=re.MULTILINE)
        block = re.sub(r'^\s*notch\s*=\s*[\d.]+\s*;\s*\n', '', block, flags=re.MULTILINE)
        if new_ctcss:
            freq_in_block = freq_pat.search(block)
            nl = block.find('\n', freq_in_block.end())
            if nl == -1: nl = freq_in_block.end()
            indent = '            '
            block = block[:nl+1] + f'{indent}ctcss = {new_ctcss};\n{indent}notch = {new_ctcss};\n' + block[nl+1:]
        if new_squelch is not None:
            sq_pat = re.compile(r'squelch_threshold\s*=\s*-?[\d.]+\s*;')
            if sq_pat.search(block):
                block = sq_pat.sub(f'squelch_threshold = {int(new_squelch)};', block)
            else:
                freq_in_block = freq_pat.search(block)
                nl = block.find('\n', freq_in_block.end())
                if nl == -1: nl = freq_in_block.end()
                indent = '            '
                block = block[:nl+1] + f'{indent}squelch_threshold = {int(new_squelch)};\n' + block[nl+1:]
        content = content[:block_start] + block + content[block_end:]
    return content


# ===== New helpers: storage =====

def dir_size(path):
    total = 0
    p = Path(path)
    if not p.exists(): return 0
    try:
        for f in p.rglob("*"):
            if f.is_file():
                try: total += f.stat().st_size
                except OSError: pass
    except OSError: pass
    return total


def storage_breakdown():
    total, used, free = shutil.disk_usage("/")
    return {
        "total_bytes": total, "used_bytes": used, "free_bytes": free,
        "breakdown": {
            "system_os": dir_size("/usr"),
            "icecast_logs": dir_size("/var/log/icecast2"),
            "rtl_airband_logs": dir_size("/var/log/rtl_airband"),
            "apt_cache": dir_size("/var/cache/apt"),
            "systemd_journal": dir_size("/var/log/journal"),
            "recordings": dir_size("/var/lib/toneout/recordings"),
        },
    }


def cleanup_rotate_logs():
    before = sum(dir_size(p) for p in ["/var/log/icecast2", "/var/log/rtl_airband"])
    subprocess.run(["sudo", "/usr/sbin/logrotate", "-f", "/etc/logrotate.conf"],
                   check=False, timeout=60)
    after = sum(dir_size(p) for p in ["/var/log/icecast2", "/var/log/rtl_airband"])
    return {"bytes_freed": max(0, before - after)}


def cleanup_apt_cache():
    before = dir_size("/var/cache/apt/archives")
    subprocess.run(["sudo", "/usr/bin/apt-get", "clean"], check=False, timeout=60)
    subprocess.run(["sudo", "/usr/bin/apt-get", "autoremove", "-y"], check=False, timeout=300)
    after = dir_size("/var/cache/apt/archives")
    return {"bytes_freed": max(0, before - after)}


def cleanup_journal(keep="200M"):
    before = dir_size("/var/log/journal")
    subprocess.run(["sudo", "/usr/bin/journalctl", f"--vacuum-size={keep}"],
                   check=False, timeout=60)
    after = dir_size("/var/log/journal")
    return {"bytes_freed": max(0, before - after)}


def cleanup_purge_recordings(directory, max_age_days):
    target = Path(directory)
    if not target.exists():
        return {"error": "directory not found", "bytes_freed": 0, "files_removed": 0}
    cutoff = time.time() - (max_age_days * 86400)
    freed = 0
    removed = 0
    for f in target.rglob("*"):
        if not f.is_file(): continue
        try:
            stat = f.stat()
            if stat.st_mtime < cutoff:
                size = stat.st_size
                f.unlink()
                freed += size
                removed += 1
        except OSError: continue
    return {"bytes_freed": freed, "files_removed": removed}


def merge_day_recordings(channel, day=None):
    """Concatenate all of one channel's mp3 clips from a given day into a single
    daily_YYYYMMDD.mp3, then delete the source clips. `day` is 'YYYYMMDD' or None
    for today (node local time). Returns a summary dict."""
    d = RECORDINGS_DIR / channel
    if not d.is_dir():
        return {"error": "channel not found"}

    if day is None:
        day = time.strftime("%Y%m%d")
    if not re.match(r'^\d{8}$', day):
        return {"error": "day must be YYYYMMDD"}

    try:
        start_epoch = time.mktime(time.strptime(day, "%Y%m%d"))
    except ValueError:
        return {"error": "invalid day"}
    end_epoch = start_epoch + 86400

    out_name = f"daily_{day}.mp3"
    out_path = d / out_name

    parts = []
    for f in d.iterdir():
        if not f.is_file() or f.suffix != ".mp3":
            continue
        if f.name == out_name:
            continue
        try:
            mt = f.stat().st_mtime
        except OSError:
            continue
        if start_epoch <= mt < end_epoch:
            parts.append((mt, f))
    parts.sort(key=lambda x: x[0])

    if not parts:
        return {"error": f"no recordings found for {day}", "files_merged": 0}

    part_paths = [p for _, p in parts]

    concat_inputs = []
    existing_daily = out_path if out_path.exists() else None
    if existing_daily:
        concat_inputs.append(existing_daily)
    concat_inputs.extend(part_paths)

    if len(concat_inputs) == 1 and not existing_daily:
        try:
            concat_inputs[0].rename(out_path)
        except OSError as e:
            return {"error": f"rename failed: {e}", "files_merged": 0}
        try:
            size = out_path.stat().st_size
        except OSError:
            size = 0
        return {"ok": True, "channel": channel, "day": day,
                "files_merged": 1, "output": out_name, "size_bytes": size}

    import tempfile
    tmp_out = d / f".daily_{day}.building.mp3"
    listfd, listpath = tempfile.mkstemp(suffix=".txt")
    try:
        with os.fdopen(listfd, "w") as lf:
            for p in concat_inputs:
                safe = str(p).replace("'", "'\\''")
                lf.write(f"file '{safe}'\n")
        proc = subprocess.run(
            ["ffmpeg", "-loglevel", "error", "-f", "concat", "-safe", "0",
             "-i", listpath, "-c", "copy", "-y", str(tmp_out)],
            capture_output=True, text=True, timeout=300,
        )
        if proc.returncode != 0:
            try: tmp_out.unlink()
            except OSError: pass
            return {"error": "ffmpeg concat failed", "detail": proc.stderr[:400]}
    except subprocess.TimeoutExpired:
        try: tmp_out.unlink()
        except OSError: pass
        return {"error": "ffmpeg timed out"}
    finally:
        try: os.unlink(listpath)
        except OSError: pass

    try:
        tmp_out.rename(out_path)
    except OSError as e:
        try: tmp_out.unlink()
        except OSError: pass
        return {"error": f"could not finalize daily file: {e}"}

    removed = 0
    for p in part_paths:
        try:
            p.unlink()
            removed += 1
        except OSError:
            pass

    try:
        size = out_path.stat().st_size
    except OSError:
        size = 0

    return {"ok": True, "channel": channel, "day": day,
            "files_merged": removed, "output": out_name, "size_bytes": size}


# ===== New helpers: survey mode =====

def survey_status():
    if not SURVEY_STATE.exists(): return {"active": False}
    try: state = json.loads(SURVEY_STATE.read_text())
    except (json.JSONDecodeError, OSError): return {"active": False}
    pid = state.get("pid")
    if pid and not Path(f"/proc/{pid}").exists():
        try: SURVEY_STATE.unlink()
        except OSError: pass
        return {"active": False, "stale": True}
    state["active"] = True
    return state


def survey_start(dongle_index=0):
    if survey_status().get("active"):
        raise RuntimeError("Survey mode already active")
    subprocess.run(["sudo", "/usr/bin/systemctl", "stop", "rtl_airband.service"],
                   check=True, timeout=10)
    time.sleep(0.5)
    proc = subprocess.Popen(
        ["rtl_tcp", "-a", "0.0.0.0", "-p", str(RTL_TCP_PORT), "-d", str(dongle_index)],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
    )
    time.sleep(0.6)
    if proc.poll() is not None:
        subprocess.run(["sudo", "/usr/bin/systemctl", "start", "rtl_airband.service"],
                       check=False, timeout=10)
        raise RuntimeError(f"rtl_tcp exited (code {proc.returncode}). Dongle index may be wrong.")
    state = {
        "pid": proc.pid, "dongle_index": dongle_index, "port": RTL_TCP_PORT,
        "started_at": int(time.time()), "host": socket.gethostname(),
    }
    RUN_DIR.mkdir(parents=True, exist_ok=True)
    SURVEY_STATE.write_text(json.dumps(state))
    return state


def survey_stop():
    state = None
    if SURVEY_STATE.exists():
        try: state = json.loads(SURVEY_STATE.read_text())
        except (json.JSONDecodeError, OSError): pass
    if state and state.get("pid"):
        try:
            os.kill(state["pid"], signal.SIGTERM)
            for _ in range(20):
                if not Path(f"/proc/{state['pid']}").exists(): break
                time.sleep(0.1)
            else:
                try: os.kill(state["pid"], signal.SIGKILL)
                except ProcessLookupError: pass
        except (ProcessLookupError, PermissionError): pass
    try: SURVEY_STATE.unlink()
    except FileNotFoundError: pass
    subprocess.run(["sudo", "/usr/bin/systemctl", "start", "rtl_airband.service"],
                   check=True, timeout=15)
    return {"active": False}


# ===== New helpers: backup =====

def usb_available():
    return USB_BACKUP_DIR.exists() and os.access(USB_BACKUP_DIR, os.W_OK)


def take_config_snapshot():
    target = CONFIG_SNAPSHOT_DIR if usb_available() else LOCAL_SNAPSHOT_DIR
    target.mkdir(parents=True, exist_ok=True)
    timestamp = time.strftime("%Y%m%d-%H%M%S")
    name = f"toneout-config-{timestamp}.tar.gz"
    out = target / name
    with tarfile.open(out, "w:gz") as tar:
        for src in CONFIG_SNAPSHOT_PATHS:
            p = Path(src)
            if p.exists():
                try: tar.add(p, arcname=str(p.relative_to("/")))
                except (PermissionError, OSError): pass
    return {
        "filename": name, "path": str(out),
        "size_bytes": out.stat().st_size,
        "created_at": int(time.time()),
        "on_usb": usb_available(),
    }


def list_config_snapshots():
    files = []
    for d in [CONFIG_SNAPSHOT_DIR, LOCAL_SNAPSHOT_DIR]:
        if not d.exists(): continue
        for p in d.glob("*.tar.gz"):
            try:
                stat = p.stat()
                files.append({
                    "filename": p.name, "path": str(p),
                    "size_bytes": stat.st_size, "created_at": int(stat.st_mtime),
                    "on_usb": str(d) == str(CONFIG_SNAPSHOT_DIR),
                })
            except OSError: continue
    files.sort(key=lambda f: f["created_at"], reverse=True)
    return files


def latest_config_snapshot_path():
    snaps = list_config_snapshots()
    if snaps: return Path(snaps[0]["path"])
    return None


def image_backup_status():
    if not IMAGE_BACKUP_STATE.exists(): return {"running": False}
    try: return json.loads(IMAGE_BACKUP_STATE.read_text())
    except (json.JSONDecodeError, OSError):
        return {"running": False, "error": "could not read state"}


def start_image_backup():
    if not usb_available():
        raise RuntimeError(f"USB backup not mounted at {USB_BACKUP_DIR}")
    state = image_backup_status()
    if state.get("running"):
        raise RuntimeError("Image backup already in progress")
    if not BACKUP_IMAGE_SCRIPT.exists():
        raise FileNotFoundError(str(BACKUP_IMAGE_SCRIPT))
    IMAGE_BACKUP_DIR.mkdir(parents=True, exist_ok=True)
    RUN_DIR.mkdir(parents=True, exist_ok=True)
    subprocess.Popen(
        ["sudo", str(BACKUP_IMAGE_SCRIPT), str(IMAGE_BACKUP_DIR), str(IMAGE_BACKUP_STATE)],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
    )
    return {"started_at": int(time.time())}


def list_image_backups():
    if not IMAGE_BACKUP_DIR.exists(): return []
    files = []
    for p in IMAGE_BACKUP_DIR.glob("*.img.gz"):
        try:
            stat = p.stat()
            files.append({
                "filename": p.name, "size_bytes": stat.st_size,
                "created_at": int(stat.st_mtime),
            })
        except OSError: continue
    files.sort(key=lambda f: f["created_at"], reverse=True)
    return files


def latest_image_backup_path():
    files = list_image_backups()
    if files: return IMAGE_BACKUP_DIR / files[0]["filename"]
    return None


# ===== Routes =====

@app.route("/")
def index():
    return jsonify({"node": NODE_NAME, "location": NODE_LOCATION,
        "endpoints": [
            "/api/streams", "/api/status", "/api/recordings/<channel>",
            "/api/recordings/<channel>/zip", "/api/config", "/api/tune (POST)",
            "/api/survey/status", "/api/survey/start (POST)", "/api/survey/stop (POST)",
            "/api/storage/breakdown", "/api/storage/cleanup/<op> (POST)",
            "/api/backup/config (GET/POST)", "/api/backup/config/latest/download",
            "/api/backup/image (GET/POST)", "/api/backup/image/status",
            "/api/backup/image/latest/download",
        ]})


@app.route("/streams")
def streams():
    config_channels = {c["id"]: c for c in parse_rtl_config()}
    icecast_sources = get_icecast_sources()
    result = []
    for src in icecast_sources:
        listenurl = src.get("listenurl", "")
        mp = listenurl.rsplit("/", 1)[-1] if listenurl else ""
        cfg = config_channels.get(mp, {})
        result.append({
            "id": mp,
            "name": src.get("server_name", mp),
            "description": src.get("server_description", ""),
            "url": f"http://{NODE_NAME}/stream/{mp}",
            "frequency_mhz": cfg.get("frequency_mhz"),
            "ctcss_hz": cfg.get("ctcss_hz"),
            "is_mixer": "fire-ems" in mp,
            "listeners": src.get("listeners", 0),
            "started": src.get("stream_start_iso8601"),
            "location": NODE_LOCATION,
        })
    return jsonify({"node": NODE_NAME, "location": NODE_LOCATION,
        "stream_count": len(result), "streams": result})


@app.route("/status")
def status():
    cpu_temp = None
    try:
        with open("/sys/class/thermal/thermal_zone0/temp") as f:
            cpu_temp = round(int(f.read().strip()) / 1000, 1)
    except Exception: pass
    disk = psutil.disk_usage(str(RECORDINGS_DIR))
    def is_active(svc):
        try:
            r = subprocess.run(["systemctl", "is-active", svc], capture_output=True, text=True, timeout=2)
            return r.stdout.strip() == "active"
        except Exception: return False
    return jsonify({
        "node": NODE_NAME, "location": NODE_LOCATION,
        "uptime_seconds": int(time.time() - psutil.boot_time()),
        "cpu_percent": psutil.cpu_percent(interval=0.5),
        "cpu_temp_c": cpu_temp,
        "memory_percent": psutil.virtual_memory().percent,
        "disk_used_gb": round(disk.used / 1e9, 1),
        "disk_total_gb": round(disk.total / 1e9, 1),
        "disk_percent": round(disk.used / disk.total * 100, 1),
        "load_average": list(os.getloadavg()),
        "services": {"rtl_airband": is_active("rtl_airband.service"),
                     "icecast": is_active("icecast2.service")},
        "timestamp": datetime.now(timezone.utc).isoformat(),
    })


@app.route("/recordings/<channel>")
def recordings_list(channel):
    if not re.match(r'^[a-z0-9_-]+$', channel): abort(400, "invalid channel name")
    d = RECORDINGS_DIR / channel
    if not d.is_dir(): abort(404, "channel not found")
    files = []
    for f in d.iterdir():
        if f.suffix == ".mp3" and f.is_file():
            stat = f.stat()
            files.append({
                "filename": f.name, "size_bytes": stat.st_size,
                "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
                "url": f"/recordings/{channel}/{f.name}",
            })
    files.sort(key=lambda x: x["modified"], reverse=True)
    return jsonify({"channel": channel, "count": len(files), "recordings": files[:200]})


@app.route("/recordings/<channel>/zip")
def recordings_zip(channel):
    if not re.match(r'^[a-z0-9_-]+$', channel): abort(400, "invalid channel name")
    d = RECORDINGS_DIR / channel
    if not d.is_dir(): abort(404, "channel not found")
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
        for f in sorted(d.iterdir()):
            if f.suffix == ".mp3": zf.write(f, arcname=f.name)
    buf.seek(0)
    return Response(buf.read(), mimetype="application/zip",
        headers={"Content-Disposition": f"attachment; filename={channel}-recordings.zip"})


@app.route("/merge-day", methods=["POST"])
def merge_day_route():
    # Note: no require_token() — matches the other dashboard-driven POST routes
    # (/storage/cleanup/*, /backup/*). Only /tune is token-gated (tune.html).
    body = request.get_json(silent=True) or {}
    channel = body.get("channel", "")
    if not re.match(r'^[a-z0-9_-]+$', channel):
        abort(400, "invalid channel name")
    day = body.get("day")  # optional 'YYYYMMDD'; None -> today
    result = merge_day_recordings(channel, day)
    status = 200 if result.get("ok") else 400
    return jsonify(result), status


@app.route("/config")
def get_config():
    channels = parse_rtl_config()
    centerfreq_hz = None
    try:
        with open(RTL_AIRBAND_CONFIG) as f:
            m = re.search(r'centerfreq\s*=\s*(\d+)', f.read())
            if m: centerfreq_hz = int(m.group(1))
    except Exception: pass
    return jsonify({
        "centerfreq_mhz": centerfreq_hz / 1e6 if centerfreq_hz else None,
        "channels": channels,
        "ctcss_options": CTCSS_TONES,
        "sdr_bandwidth_mhz": SDR_BANDWIDTH_HZ / 1e6,
    })


@app.route("/tune", methods=["POST"])
def post_tune():
    require_token()
    body = request.get_json(silent=True)
    if not body or "channels" not in body: abort(400, "missing channels in body")
    channels = body["channels"]
    if not isinstance(channels, list) or len(channels) != 2: abort(400, "exactly 2 channels required")
    for c in channels:
        if not isinstance(c.get("frequency_mhz"), (int, float)): abort(400, "frequency_mhz must be a number")
        if not (25.0 <= c["frequency_mhz"] <= 1700.0): abort(400, f"frequency {c['frequency_mhz']} out of range")
        ctcss = c.get("ctcss_hz")
        if ctcss is not None:
            if not isinstance(ctcss, (int, float)): abort(400, "ctcss_hz must be number or null")
            if not any(abs(ctcss - t) < 0.5 for t in CTCSS_TONES):
                abort(400, f"ctcss {ctcss} is not standard")
        sq = c.get("squelch_db")
        if sq is not None:
            if not isinstance(sq, (int, float)): abort(400, "squelch_db must be number or null")
            if not (-90 <= sq <= 0): abort(400, f"squelch_db {sq} out of range (-90 to 0)")
        if not c.get("id") or not re.match(r'^[a-z0-9_-]+$', c["id"]): abort(400, "channel id missing/invalid")
    try:
        new_config = update_config_freqs(channels)
    except ValueError as e:
        return jsonify({"error": str(e)}), 400
    try:
        subprocess.run(["sudo", "/usr/bin/cp", RTL_AIRBAND_CONFIG, RTL_AIRBAND_BACKUP],
            check=True, timeout=5)
    except Exception as e:
        return jsonify({"error": "backup failed", "detail": str(e)}), 500
    try:
        proc = subprocess.run(["sudo", "/usr/bin/tee", RTL_AIRBAND_CONFIG],
            input=new_config, text=True, capture_output=True, timeout=5)
        if proc.returncode != 0:
            return jsonify({"error": "config write failed", "detail": proc.stderr}), 500
    except Exception as e:
        return jsonify({"error": "config write exception", "detail": str(e)}), 500
    try:
        subprocess.run(["sudo", "/usr/bin/systemctl", "restart", "rtl_airband.service"],
            check=True, timeout=15)
    except Exception:
        subprocess.run(["sudo", "/usr/bin/cp", RTL_AIRBAND_BACKUP, RTL_AIRBAND_CONFIG], timeout=5)
        subprocess.run(["sudo", "/usr/bin/systemctl", "restart", "rtl_airband.service"], timeout=15)
        return jsonify({"error": "restart failed, backup restored", "active": False}), 500
    time.sleep(3)
    try:
        r = subprocess.run(["sudo", "/usr/bin/systemctl", "is-active", "rtl_airband.service"],
            capture_output=True, text=True, timeout=5)
        is_active = r.stdout.strip() == "active"
    except Exception:
        is_active = False
    if not is_active:
        subprocess.run(["sudo", "/usr/bin/cp", RTL_AIRBAND_BACKUP, RTL_AIRBAND_CONFIG], timeout=5)
        subprocess.run(["sudo", "/usr/bin/systemctl", "restart", "rtl_airband.service"], timeout=15)
        return jsonify({"error": "rtl_airband failed with new config — backup restored", "active": False}), 500
    return jsonify({"ok": True, "active": True, "channels": channels})


# ===== New routes: survey mode =====

@app.route("/survey/status")
def survey_status_route():
    return jsonify(survey_status())


@app.route("/survey/start", methods=["POST"])
def survey_start_route():
    body = request.get_json(silent=True) or {}
    try:
        dongle = int(body.get("dongle_index", 0))
        return jsonify(survey_start(dongle))
    except (ValueError, RuntimeError) as e:
        return jsonify({"error": str(e)}), 409
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/survey/stop", methods=["POST"])
def survey_stop_route():
    try: return jsonify(survey_stop())
    except Exception as e: return jsonify({"error": str(e)}), 500


# ===== New routes: storage / cleanup =====

@app.route("/storage/breakdown")
def storage_breakdown_route():
    return jsonify(storage_breakdown())


@app.route("/storage/cleanup/logs", methods=["POST"])
def cleanup_logs_route():
    return jsonify(cleanup_rotate_logs())


@app.route("/storage/cleanup/apt", methods=["POST"])
def cleanup_apt_route():
    return jsonify(cleanup_apt_cache())


@app.route("/storage/cleanup/journal", methods=["POST"])
def cleanup_journal_route():
    body = request.get_json(silent=True) or {}
    return jsonify(cleanup_journal(keep=body.get("keep_size", "200M")))


@app.route("/storage/cleanup/recordings", methods=["POST"])
def cleanup_recordings_route():
    body = request.get_json(silent=True) or {}
    directory = body.get("directory", "/var/lib/toneout/recordings")
    try:
        days = int(body.get("max_age_days", 7))
    except (TypeError, ValueError):
        return jsonify({"error": "max_age_days must be int"}), 400
    return jsonify(cleanup_purge_recordings(directory, days))


# ===== New routes: backup =====

@app.route("/backup/config", methods=["GET", "POST"])
def backup_config_route():
    if request.method == "POST":
        try: return jsonify(take_config_snapshot())
        except Exception as e: return jsonify({"error": str(e)}), 500
    return jsonify({"snapshots": list_config_snapshots()})


@app.route("/backup/config/latest/download")
def backup_config_download():
    p = latest_config_snapshot_path()
    if not p or not p.exists(): abort(404)
    return send_file(p, as_attachment=True)


@app.route("/backup/image", methods=["GET", "POST"])
def backup_image_route():
    if request.method == "POST":
        try: return jsonify(start_image_backup())
        except (RuntimeError, FileNotFoundError) as e:
            return jsonify({"error": str(e)}), 409
        except Exception as e:
            return jsonify({"error": str(e)}), 500
    return jsonify({
        "backups": list_image_backups(),
        "usb_available": usb_available(),
        "usb_path": str(USB_BACKUP_DIR),
    })


@app.route("/backup/image/status")
def backup_image_status_route():
    return jsonify(image_backup_status())


@app.route("/backup/image/latest/download")
def backup_image_download():
    p = latest_image_backup_path()
    if not p or not p.exists(): abort(404)
    return send_file(p, as_attachment=True)



# ---------------------------------------------------------------------------
#  Tone Alerts — device registration + push (added for v1.1)
# ---------------------------------------------------------------------------
# Devices POST here to register their APNs token and which departments they're
# subscribed to. The tone detector reads this DB and pushes on a match.
try:
    import sys as _sys
    _sys.path.insert(0, "/opt/toneout-api/scripts")
    from toneout_apns import (RegistrationStore, APNsClient, send_department_alert,
                              log_tone_event, recent_tone_events)
    _reg_store = RegistrationStore()
    _apns_available = True
except Exception as _e:
    _apns_available = False
    _apns_import_error = str(_e)


@app.route("/register", methods=["POST"])
def register_device():
    if not _apns_available:
        return jsonify({"error": f"apns module unavailable: {_apns_import_error}"}), 503
    body = request.get_json(silent=True) or {}
    token = body.get("device_token", "").strip()
    if not token or not re.match(r'^[A-Za-z0-9]+$', token):
        abort(400, "valid device_token required")
    platform = body.get("platform", "ios")
    subs = body.get("subscriptions", [])
    if not isinstance(subs, list):
        abort(400, "subscriptions must be a list")
    subs = [s for s in subs if isinstance(s, str) and re.match(r'^[a-z0-9_-]+$', s)]
    result = _reg_store.register(token, platform, subs)
    return jsonify({"ok": True, **result})


@app.route("/unregister", methods=["POST"])
def unregister_device():
    if not _apns_available:
        return jsonify({"error": "apns module unavailable"}), 503
    body = request.get_json(silent=True) or {}
    token = body.get("device_token", "").strip()
    if not token:
        abort(400, "device_token required")
    n = _reg_store.unregister(token)
    return jsonify({"ok": True, "removed": n})


@app.route("/registrations", methods=["GET"])
def list_registrations():
    if not _apns_available:
        return jsonify({"error": "apns module unavailable"}), 503
    regs = _reg_store.all_registrations()
    # Don't echo full tokens in the clear; show a prefix for debugging
    for r in regs:
        t = r["device_token"]
        r["device_token"] = (t[:8] + "…" + t[-4:]) if len(t) > 14 else t
    return jsonify({"count": len(regs), "registrations": regs})


@app.route("/push-test", methods=["POST"])
def push_test():
    """Send a test push to one device token, to verify APNs end-to-end without
    waiting for a real tone-out. Body: { device_token, title?, body? }."""
    if not _apns_available:
        return jsonify({"error": "apns module unavailable"}), 503
    body = request.get_json(silent=True) or {}
    token = body.get("device_token", "").strip()
    if not token:
        abort(400, "device_token required")
    title = body.get("title", "ToneOut test")
    msg = body.get("body", "APNs is working. This is a test push.")
    sound = body.get("sound", "toneout_pager_warble.caf")
    ok, status, reason = APNsClient().send(token, title, msg, sound=sound)
    return jsonify({"ok": ok, "status": status, "reason": reason})


@app.route("/test-tone", methods=["POST"])
def test_tone():
    """Send a clearly-labeled TEST TONE push. On-demand (beta demos) or from the
    daily 6pm timer. Body (all optional):
      { "department_id": "glastonbury-co-1",   # omit -> ALL registered devices
        "message": "custom text" }
    """
    if not _apns_available:
        return jsonify({"error": "apns module unavailable"}), 503
    body = request.get_json(silent=True) or {}
    dept = body.get("department_id")
    msg = body.get("message",
                   "This is a ToneOut test tone. No action required.")

    # Map department id -> display label for the notification text
    DEPT_LABELS = {
        "glastonbury-co-1": "Company 1",
        "glastonbury-co-2": "Company 2",
        "glastonbury-co-3": "Company 3",
        "glastonbury-co-4": "Company 4",
        "glastonbury-allcall": "All-call",
    }

    if dept:
        if not re.match(r'^[a-z0-9_-]+$', dept):
            abort(400, "invalid department_id")
        tokens = _reg_store.tokens_for_department(dept)
        scope = dept
        dept_label = DEPT_LABELS.get(dept, dept)
    else:
        tokens = [r["device_token"] for r in _reg_store.all_registrations()]
        scope = "all devices"
        dept_label = "All-call"   # a no-department test is an all-call test

    title = f"Test Tone {dept_label}"
    # Default body names the department unless the caller supplied a custom message
    if not body.get("message"):
        msg = f"Test tone for {dept_label}. No action required."

    if not tokens:
        return jsonify({"ok": True, "sent": 0, "scope": scope, "label": dept_label,
                        "note": "no registered devices"})

    client = APNsClient()
    sent = 0
    errors = []
    for tok in tokens:
        ok, status, reason = client.send(
            tok, title, msg,
            sound="toneout_pager_warble.caf",
            collapse_id="toneout-test-tone")
        if ok:
            sent += 1
        else:
            errors.append({"status": status, "reason": reason})
            if status == 410 or reason == "Unregistered":
                _reg_store.unregister(tok)
    log_tone_event(dept or "glastonbury-allcall", f"Test Tone {dept_label}",
                   kind="test", detail=msg[:120])
    return jsonify({"ok": True, "scope": scope, "label": dept_label,
                    "devices": len(tokens), "sent": sent, "errors": errors[:5]})


@app.route("/tone-history", methods=["GET"])
def tone_history():
    """Recent tone-outs and tests, newest first. Query params:
       ?limit=50            (max 200)
       ?departments=a,b,c   (optional filter by department ids)"""
    if not _apns_available:
        return jsonify({"error": "apns module unavailable"}), 503
    try:
        limit = min(int(request.args.get("limit", 50)), 200)
    except ValueError:
        limit = 50
    depts = request.args.get("departments", "").strip()
    dept_list = [d for d in depts.split(",") if re.match(r'^[a-z0-9_-]+$', d)] if depts else None
    events = recent_tone_events(limit=limit, department_ids=dept_list)
    return jsonify({"count": len(events), "events": events})


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5050)
