#!/usr/bin/env python3
"""
op0p_catbridge.py - Pont CAT local pour le cluster OP0P (cluster.op0p.be)
==========================================================================

A quoi ca sert ?
-----------------
Un navigateur ne peut PAS piloter un port serie/COM: seul un programme qui
tourne sur VOTRE ordinateur le peut. Ce petit script (bibliotheque standard
Python uniquement, aucune dependance a installer) fait deux choses :

  1. Il sert de "miroir" local du site https://cluster.op0p.be : il recoit
     toutes les requetes que votre navigateur ferait normalement vers le
     site et les relaie telles quelles vers le vrai serveur, puis renvoie
     la reponse. Vu de votre navigateur, http://127.0.0.1:5005/ est donc
     IDENTIQUE au vrai site.
  2. Dans chaque page HTML renvoyee, il ajoute un petit indicateur
     (window.OP0P_CATBRIDGE = true) que le Javascript du site sait
     detecter. Quand ce drapeau est present, cliquer sur la frequence
     d'un spot envoie une requete a ce script (sur /catbridge/tune), qui la
     transmet a votre radio via rigctld (Hamlib).

C'est exactement le principe utilise par Holy Cluster (holycluster.iarc.org)
avec son "CAT server" local - voir https://github.com/iarc-il/HolyCluster.
On evite ainsi le blocage "contenu mixte" des navigateurs recents, qui
empechent de plus en plus une page HTTPS publique de contacter directement
votre http://127.0.0.1 (Private Network Access).

Hamlib (rigctld) sait piloter la quasi-totalite des transceivers du marche
(Icom, Yaesu, Kenwood, Elecraft, Ten-Tec, Flex, Elad, ...) : c'est pour ca
qu'il a ete choisi comme pont vers la radio, plutot qu'un backend limite a
une seule marque.

Mode graphique (recommande, pour tout le monde)
-------------------------------------------------
Lancez simplement :

    python3 op0p_catbridge.py

(ou double-cliquez sur le fichier si Python est associe aux fichiers .py)
Une petite fenetre s'ouvre : choisissez la MARQUE puis le MODELE de votre
radio dans les listes deroulantes (elles viennent directement de Hamlib,
donc TOUTES les marques supportees par Hamlib apparaissent), choisissez le
port serie/COM (une liste de ports detectes est proposee, modifiable), puis
cliquez sur "Demarrer". Le script lance rigctld pour vous, demarre le pont,
et ouvre votre navigateur automatiquement. Vos choix sont memorises pour la
prochaine fois.

Pre-requis sur votre ordinateur pour ce mode : Python 3.8+ avec tkinter
(inclus par defaut sous Windows/Mac ; sous Debian/Ubuntu, si la fenetre ne
s'ouvre pas : `sudo apt install python3-tk`), et Hamlib installe (le
programme `rigctld` doit etre trouvable - voir plus bas selon votre OS).

Mode avance / ligne de commande (sans fenetre, ex: Raspberry Pi headless)
---------------------------------------------------------------------------
Si vous preferez tout faire a la main (ou si vous lancez ce script sans
ecran), passez n'importe quelle option en ligne de commande pour desactiver
la fenetre, par exemple :

    rigctld -m <numero_modele> -r /dev/ttyUSB0 -s 9600 &
    python3 op0p_catbridge.py --no-gui

Options (toutes facultatives) :
    --gui / --no-gui          force ou desactive la fenetre (par defaut :
                               fenetre si aucune autre option n'est donnee)
    --port PORT               port local du pont (defaut: 5005)
    --upstream URL            site cluster a relayer (defaut: https://cluster.op0p.be)
    --rigctld-host HOST       adresse de rigctld (defaut: 127.0.0.1)
    --rigctld-port PORT       port de rigctld (defaut: 4532, le defaut Hamlib)

Installer Hamlib (fournit rigctl et rigctld)
-----------------------------------------------
    Windows      : https://github.com/Hamlib/Hamlib/releases (archive w32/w64)
    Debian/Ubuntu: sudo apt install libhamlib-utils
    Fedora       : sudo dnf install hamlib
    macOS        : brew install hamlib

Limite connue (volontaire, pour la fiabilite) : seule la FREQUENCE est
envoyee a la radio. Le mode (CW/SSB/FT8/...) n'est pas toujours fiable dans
les donnees du cluster, donc il reste a regler manuellement sur votre poste.

Licence : fourni tel quel pour les utilisateurs de cluster.op0p.be (ON6DP).
"""
import argparse
import glob
import http.server
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request

# En-tetes qui ne doivent jamais etre transmis tels quels d'un bout a
# l'autre d'un proxy HTTP (RFC 7230 section 6.1 - en-tetes "hop-by-hop").
HOP_BY_HOP_HEADERS = {
    "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
    "te", "trailers", "transfer-encoding", "upgrade", "host",
    # On retire aussi accept-encoding : on prefere recevoir du HTML en clair
    # (non compresse) du vrai serveur, pour pouvoir y inserer notre petit
    # script sans avoir a decompresser/recompresser du gzip.
    "accept-encoding",
}

RIGCTLD_TIMEOUT = 3.0  # secondes
INJECT_SNIPPET = b"<script>window.OP0P_CATBRIDGE = true;</script></body>"
CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".op0p_catbridge.json")


def rigctld_command(host, port, command):
    """Envoie une commande texte Hamlib ('F 14195000', 'f', ...) a rigctld
    et renvoie la ou les lignes de reponse. Ouvre une connexion TCP courte
    a chaque appel (rigctld supporte tres bien ca) pour ne jamais rester
    bloque sur une connexion morte entre deux clics."""
    with socket.create_connection((host, port), timeout=RIGCTLD_TIMEOUT) as sock:
        sock.sendall((command.strip() + "\n").encode("ascii"))
        sock.settimeout(RIGCTLD_TIMEOUT)
        data = b""
        try:
            while True:
                chunk = sock.recv(4096)
                if not chunk:
                    break
                data += chunk
                # rigctld repond une ligne (+ "RPRT 0" en fin de commande
                # d'ecriture) ; on arrete des qu'on a recu quelque chose et
                # qu'il n'y a plus rien d'immediatement disponible.
                if b"\n" in data:
                    break
        except socket.timeout:
            pass
    return data.decode("ascii", errors="replace").strip()


class CatBridgeHandler(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    server_version = "op0p-catbridge/1.0"

    # ---- endpoints locaux (jamais relayes vers le vrai site) ----

    def _send_json(self, obj, status=200):
        body = json.dumps(obj).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(body)

    def _handle_tune(self):
        length = int(self.headers.get("Content-Length", "0") or "0")
        raw = self.rfile.read(length) if length else b"{}"
        try:
            payload = json.loads(raw.decode("utf-8") or "{}")
        except json.JSONDecodeError:
            self._send_json({"ok": False, "error": "JSON invalide"}, status=400)
            return
        freq_khz = payload.get("freq_khz")
        if freq_khz is None:
            self._send_json({"ok": False, "error": "champ 'freq_khz' manquant"}, status=400)
            return
        try:
            freq_hz = int(round(float(freq_khz) * 1000))
        except (TypeError, ValueError):
            self._send_json({"ok": False, "error": "freq_khz invalide"}, status=400)
            return
        try:
            reply = rigctld_command(self.server.rigctld_host, self.server.rigctld_port,
                                     "F %d" % freq_hz)
            ok = ("RPRT 0" in reply) or (reply == "")
            self._send_json({"ok": ok, "freq_hz": freq_hz, "rigctld_reply": reply})
        except OSError as e:
            self._send_json({"ok": False, "error": "rigctld injoignable: %s" % e}, status=502)

    def _handle_status(self):
        try:
            reply = rigctld_command(self.server.rigctld_host, self.server.rigctld_port, "f")
            freq_hz = int(reply.splitlines()[0]) if reply.splitlines() and reply.splitlines()[0].lstrip("-").isdigit() else None
            self._send_json({"ok": True, "freq_hz": freq_hz})
        except OSError as e:
            self._send_json({"ok": False, "error": "rigctld injoignable: %s" % e}, status=502)

    # ---- proxy generique vers le site cluster ----

    def _proxy(self, method):
        url = self.server.upstream + self.path
        length = int(self.headers.get("Content-Length", "0") or "0")
        body = self.rfile.read(length) if length else None

        headers = {k: v for k, v in self.headers.items() if k.lower() not in HOP_BY_HOP_HEADERS}
        req = urllib.request.Request(url, data=body, headers=headers, method=method)
        try:
            with urllib.request.urlopen(req, timeout=15) as resp:
                status = resp.status
                resp_headers = resp.getheaders()
                resp_body = resp.read()
        except urllib.error.HTTPError as e:
            status = e.code
            resp_headers = e.headers.items() if e.headers else []
            resp_body = e.read() or b""
        except urllib.error.URLError as e:
            self._send_json(
                {"ok": False, "error": "Impossible de contacter %s : %s" % (self.server.upstream, e)},
                status=502,
            )
            return

        content_type = ""
        for k, v in resp_headers:
            if k.lower() == "content-type":
                content_type = v
                break

        if "text/html" in content_type and b"</body>" in resp_body:
            resp_body = resp_body.replace(b"</body>", INJECT_SNIPPET, 1)

        self.send_response(status)
        for k, v in resp_headers:
            if k.lower() in HOP_BY_HOP_HEADERS or k.lower() == "content-length":
                continue
            self.send_header(k, v)
        self.send_header("Content-Length", str(len(resp_body)))
        self.end_headers()
        if method != "HEAD":
            self.wfile.write(resp_body)

    def do_GET(self):
        if self.path == "/catbridge/status":
            self._handle_status()
        else:
            self._proxy("GET")

    def do_POST(self):
        if self.path == "/catbridge/tune":
            self._handle_tune()
        else:
            self._proxy("POST")

    def do_HEAD(self):
        self._proxy("HEAD")

    def log_message(self, fmt, *args):
        sys.stderr.write("[op0p_catbridge] " + (fmt % args) + "\n")


class CatBridgeServer(http.server.ThreadingHTTPServer):
    daemon_threads = True

    def __init__(self, addr, upstream, rigctld_host, rigctld_port):
        super().__init__(addr, CatBridgeHandler)
        self.upstream = upstream.rstrip("/")
        self.rigctld_host = rigctld_host
        self.rigctld_port = rigctld_port


# =====================================================================
# Aide "mode graphique" : decouverte des modeles Hamlib, des ports serie,
# et petite configuration persistante. Tout ceci est volontairement
# independant de tkinter (fonctions pures, testables sans fenetre) - seule
# run_gui() importe tkinter, et seulement au moment ou on en a besoin.
# =====================================================================

def find_rigctld_binary():
    """Cherche le programme rigctld dans le PATH (et quelques emplacements
    Windows usuels ou Hamlib s'installe parfois sans modifier le PATH)."""
    found = shutil.which("rigctld")
    if found:
        return found
    if sys.platform.startswith("win"):
        candidates = glob.glob(r"C:\Program Files*\hamlib*\bin\rigctld.exe") + \
            glob.glob(r"C:\hamlib*\bin\rigctld.exe")
        if candidates:
            return candidates[0]
    return None


def find_rigctl_binary():
    """Meme chose pour rigctl (utilise seulement pour lister les modeles)."""
    found = shutil.which("rigctl")
    if found:
        return found
    if sys.platform.startswith("win"):
        candidates = glob.glob(r"C:\Program Files*\hamlib*\bin\rigctl.exe") + \
            glob.glob(r"C:\hamlib*\bin\rigctl.exe")
        if candidates:
            return candidates[0]
    return None


_RIGCTL_LIST_ROW = re.compile(r"^\s*(\d+)\s{2,}")


def list_hamlib_rigs():
    """Interroge `rigctl -l` (liste livree par VOTRE installation de Hamlib
    - donc toujours a jour, quelle que soit la version) et renvoie la liste
    des radios supportees sous la forme [{"id": 311, "mfg": "Yaesu",
    "model": "FT-847"}, ...]. Toutes les marques que Hamlib connait
    (Icom, Yaesu, Kenwood, Elecraft, Ten-Tec, Flex, Elad, ...) ressortent
    de la meme facon, sans liste a maintenir nous-memes.

    Renvoie (rigs, error) : error est None si tout s'est bien passe, sinon
    un message explicatif (binaire absent, erreur de lancement, ...).
    """
    rigctl_bin = find_rigctl_binary()
    if not rigctl_bin:
        return [], "Le programme 'rigctl' (fourni par Hamlib) est introuvable sur cet ordinateur."
    try:
        proc = subprocess.run([rigctl_bin, "-l"], capture_output=True, text=True, timeout=10)
    except Exception as e:
        return [], "Impossible de lancer rigctl -l : %s" % e

    rigs = []
    for line in proc.stdout.splitlines():
        if not _RIGCTL_LIST_ROW.match(line):
            continue  # ligne d'en-tete ou vide
        fields = re.split(r"\s{2,}", line.strip())
        # format attendu (varie legerement selon les versions de Hamlib) :
        # [id, mfg, model, version, status, macro(optionnel)]
        if len(fields) < 3:
            continue
        rig_id, mfg, model = fields[0], fields[1], fields[2]
        if not rig_id.isdigit():
            continue
        rigs.append({"id": int(rig_id), "mfg": mfg.strip(), "model": model.strip()})
    if not rigs:
        return [], "rigctl -l n'a renvoye aucun modele exploitable (sortie inattendue)."
    return rigs, None


def scan_serial_ports():
    """Liste best-effort des ports serie/COM probables - juste pour
    pre-remplir la liste deroulante, l'utilisateur peut toujours taper
    autre chose a la main. Aucune dependance externe (pas de pyserial)."""
    ports = []
    if sys.platform.startswith("win"):
        try:
            out = subprocess.run(
                ["powershell", "-NoProfile", "-Command", "[System.IO.Ports.SerialPort]::getportnames()"],
                capture_output=True, text=True, timeout=5,
            )
            ports = [p.strip() for p in out.stdout.splitlines() if p.strip()]
        except Exception:
            ports = []
    elif sys.platform == "darwin":
        ports = sorted(glob.glob("/dev/tty.*") + glob.glob("/dev/cu.*"))
    else:  # Linux et assimiles
        ports = sorted(
            glob.glob("/dev/ttyUSB*") + glob.glob("/dev/ttyACM*") +
            glob.glob("/dev/serial/by-id/*")
        )
    return ports


def load_config():
    try:
        with open(CONFIG_PATH, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return {}


def save_config(cfg):
    try:
        with open(CONFIG_PATH, "w", encoding="utf-8") as f:
            json.dump(cfg, f)
    except Exception:
        pass  # la config est un simple confort, jamais bloquant


def wait_tcp_open(host, port, timeout_total=5.0):
    """Attend qu'une connexion TCP soit possible (rigctld pret a repondre),
    jusqu'a timeout_total secondes. Renvoie True/False."""
    deadline = time.time() + timeout_total
    while time.time() < deadline:
        try:
            with socket.create_connection((host, port), timeout=0.5):
                return True
        except OSError:
            time.sleep(0.2)
    return False


# =====================================================================
# Mode graphique
# =====================================================================

def run_gui(default_bridge_port, default_upstream, default_rigctld_host, default_rigctld_port):
    import tkinter as tk
    from tkinter import ttk, messagebox, scrolledtext

    root = tk.Tk()  # leve TclError ici si aucun affichage disponible
    root.title("Pont CAT OP0P - cluster.op0p.be")
    root.geometry("560x480")

    state = {
        "rigctld_proc": None,
        "bridge_server": None,
        "rigs": [],
        "rigs_by_mfg": {},
    }
    cfg = load_config()

    frm = ttk.Frame(root, padding=10)
    frm.pack(fill="both", expand=True)

    ttk.Label(frm, text="1. Marque de votre radio :").grid(row=0, column=0, sticky="w", pady=2)
    mfg_var = tk.StringVar(value=cfg.get("mfg", ""))
    mfg_combo = ttk.Combobox(frm, textvariable=mfg_var, state="readonly", width=40)
    mfg_combo.grid(row=0, column=1, sticky="ew", pady=2)

    ttk.Label(frm, text="2. Modele :").grid(row=1, column=0, sticky="w", pady=2)
    model_var = tk.StringVar(value=cfg.get("model_label", ""))
    model_combo = ttk.Combobox(frm, textvariable=model_var, state="readonly", width=40)
    model_combo.grid(row=1, column=1, sticky="ew", pady=2)

    ttk.Label(frm, text="   (ou numero de modele Hamlib manuel :)").grid(row=2, column=0, sticky="w")
    manual_id_var = tk.StringVar(value="")
    ttk.Entry(frm, textvariable=manual_id_var, width=10).grid(row=2, column=1, sticky="w")

    ttk.Label(frm, text="3. Port serie / COM :").grid(row=3, column=0, sticky="w", pady=2)
    port_var = tk.StringVar(value=cfg.get("port", ""))
    port_combo = ttk.Combobox(frm, textvariable=port_var, width=40)
    port_combo.grid(row=3, column=1, sticky="ew", pady=2)

    ttk.Label(frm, text="4. Vitesse (bauds) :").grid(row=4, column=0, sticky="w", pady=2)
    baud_var = tk.StringVar(value=str(cfg.get("baud", 9600)))
    baud_combo = ttk.Combobox(frm, textvariable=baud_var, width=40,
                               values=["1200", "2400", "4800", "9600", "19200", "38400", "57600", "115200"])
    baud_combo.grid(row=4, column=1, sticky="ew", pady=2)

    frm.columnconfigure(1, weight=1)

    status_var = tk.StringVar(value="Pret.")
    status_lbl = ttk.Label(frm, textvariable=status_var, foreground="#444")
    status_lbl.grid(row=5, column=0, columnspan=2, sticky="w", pady=(8, 2))

    log_box = scrolledtext.ScrolledText(frm, height=14, state="disabled", font=("Courier", 9))
    log_box.grid(row=7, column=0, columnspan=2, sticky="nsew", pady=(4, 8))
    frm.rowconfigure(7, weight=1)

    def log(msg):
        def _do():
            log_box.configure(state="normal")
            log_box.insert("end", msg + "\n")
            log_box.see("end")
            log_box.configure(state="disabled")
        root.after(0, _do)

    def set_status(msg, color="#444"):
        status_var.set(msg)
        status_lbl.configure(foreground=color)

    def populate_rigs():
        rigs, error = list_hamlib_rigs()
        if error:
            log("Attention : " + error)
            log("Vous pouvez quand meme saisir un numero de modele Hamlib a la main ci-dessus.")
            return
        state["rigs"] = rigs
        by_mfg = {}
        for r in rigs:
            by_mfg.setdefault(r["mfg"], []).append(r)
        state["rigs_by_mfg"] = by_mfg

        def _apply():
            mfg_combo["values"] = sorted(by_mfg.keys())
            if mfg_var.get() in by_mfg:
                update_models()
            log("%d modeles Hamlib charges (%d marques)." % (len(rigs), len(by_mfg)))
        root.after(0, _apply)

    def update_models(*_args):
        mfg = mfg_var.get()
        rigs = state["rigs_by_mfg"].get(mfg, [])
        labels = ["%s (#%d)" % (r["model"], r["id"]) for r in rigs]
        model_combo["values"] = labels
        if cfg.get("mfg") == mfg and cfg.get("model_label") in labels:
            model_var.set(cfg.get("model_label"))
        elif labels:
            model_var.set(labels[0])

    mfg_combo.bind("<<ComboboxSelected>>", update_models)

    def populate_ports():
        ports = scan_serial_ports()

        def _apply():
            port_combo["values"] = ports
            if not port_var.get() and ports:
                port_var.set(ports[0])
            if ports:
                log("Ports detectes : " + ", ".join(ports))
            else:
                log("Aucun port detecte automatiquement - saisissez-le a la main (ex: COM3 ou /dev/ttyUSB0).")
        root.after(0, _apply)

    threading.Thread(target=populate_rigs, daemon=True).start()
    threading.Thread(target=populate_ports, daemon=True).start()

    def resolve_model_id():
        manual = manual_id_var.get().strip()
        if manual:
            if not manual.isdigit():
                raise ValueError("Le numero de modele manuel doit etre un nombre.")
            return int(manual)
        mfg = mfg_var.get()
        label = model_var.get()
        for r in state["rigs_by_mfg"].get(mfg, []):
            if "%s (#%d)" % (r["model"], r["id"]) == label:
                return r["id"]
        raise ValueError("Choisissez une marque et un modele (ou un numero manuel).")

    def on_start():
        try:
            model_id = resolve_model_id()
        except ValueError as e:
            messagebox.showerror("Pont CAT OP0P", str(e))
            return
        port = port_var.get().strip()
        if not port:
            messagebox.showerror("Pont CAT OP0P", "Indiquez le port serie/COM de votre radio.")
            return
        try:
            baud = int(baud_var.get().strip())
        except ValueError:
            messagebox.showerror("Pont CAT OP0P", "La vitesse (bauds) doit etre un nombre.")
            return

        save_config({"mfg": mfg_var.get(), "model_label": model_var.get(), "port": port, "baud": baud})

        rigctld_bin = find_rigctld_binary()
        if not rigctld_bin:
            messagebox.showerror(
                "Pont CAT OP0P",
                "Le programme 'rigctld' (fourni par Hamlib) est introuvable.\n\n"
                "Installez Hamlib puis reessayez :\n"
                "  Windows : https://github.com/Hamlib/Hamlib/releases\n"
                "  Debian/Ubuntu : sudo apt install libhamlib-utils\n"
                "  Fedora : sudo dnf install hamlib\n"
                "  macOS : brew install hamlib",
            )
            return

        start_btn.configure(state="disabled")
        set_status("Demarrage de rigctld...", "#b8860b")
        log("Lancement : %s -m %d -r %s -s %d" % (rigctld_bin, model_id, port, baud))

        try:
            proc = subprocess.Popen(
                [rigctld_bin, "-m", str(model_id), "-r", port, "-s", str(baud)],
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
                bufsize=1,
            )
        except Exception as e:
            messagebox.showerror("Pont CAT OP0P", "Impossible de lancer rigctld : %s" % e)
            start_btn.configure(state="normal")
            set_status("Echec.", "#b00020")
            return
        state["rigctld_proc"] = proc

        def read_rigctld_output():
            if proc.stdout:
                for line in proc.stdout:
                    log("[rigctld] " + line.rstrip())
        threading.Thread(target=read_rigctld_output, daemon=True).start()

        def finish_start():
            if proc.poll() is not None:
                log("rigctld s'est arrete immediatement (code %s) - verifiez le port et le modele choisis."
                    % proc.returncode)
                set_status("Echec : rigctld ne demarre pas.", "#b00020")
                start_btn.configure(state="normal")
                return
            if not wait_tcp_open(default_rigctld_host, default_rigctld_port, timeout_total=5.0):
                log("rigctld ne repond pas sur %s:%d apres 5s." % (default_rigctld_host, default_rigctld_port))
                set_status("Echec : rigctld ne repond pas.", "#b00020")
                start_btn.configure(state="normal")
                return

            try:
                server = CatBridgeServer(("127.0.0.1", default_bridge_port), default_upstream,
                                          default_rigctld_host, default_rigctld_port)
            except OSError as e:
                log("Impossible de demarrer le pont local : %s" % e)
                set_status("Echec : port local %d deja utilise ?" % default_bridge_port, "#b00020")
                start_btn.configure(state="normal")
                return
            state["bridge_server"] = server
            threading.Thread(target=server.serve_forever, daemon=True).start()

            import webbrowser
            url = "http://127.0.0.1:%d/" % default_bridge_port
            log("Pont pret - ouverture de " + url)
            webbrowser.open(url)
            set_status("Connecte - radio prete (" + url + ")", "#0a7d28")
            stop_btn.configure(state="normal")

        threading.Thread(target=finish_start, daemon=True).start()

    def on_stop():
        server = state.get("bridge_server")
        if server:
            server.shutdown()
            server.server_close()
            state["bridge_server"] = None
        proc = state.get("rigctld_proc")
        if proc and proc.poll() is None:
            proc.terminate()
            try:
                proc.wait(timeout=3)
            except subprocess.TimeoutExpired:
                proc.kill()
        state["rigctld_proc"] = None
        set_status("Arrete.", "#444")
        log("Pont CAT arrete.")
        start_btn.configure(state="normal")
        stop_btn.configure(state="disabled")

    btn_frame = ttk.Frame(frm)
    btn_frame.grid(row=6, column=0, columnspan=2, sticky="ew", pady=(4, 4))
    start_btn = ttk.Button(btn_frame, text="Demarrer", command=on_start)
    start_btn.pack(side="left")
    stop_btn = ttk.Button(btn_frame, text="Arreter", command=on_stop, state="disabled")
    stop_btn.pack(side="left", padx=(8, 0))

    def on_close():
        on_stop()
        root.destroy()

    root.protocol("WM_DELETE_WINDOW", on_close)
    log("Bienvenue. Choisissez votre radio et son port, puis cliquez sur Demarrer.")
    log("(Hamlib/rigctld sera lance automatiquement pour vous.)")
    root.mainloop()


def main():
    parser = argparse.ArgumentParser(description="Pont CAT local pour cluster.op0p.be")
    parser.add_argument("--gui", action="store_true", help="force l'ouverture de la fenetre")
    parser.add_argument("--no-gui", action="store_true", help="desactive la fenetre (mode ligne de commande)")
    parser.add_argument("--port", type=int, default=5005, help="port local du pont (defaut: 5005)")
    parser.add_argument("--upstream", default="https://cluster.op0p.be",
                         help="site cluster a relayer (defaut: https://cluster.op0p.be)")
    parser.add_argument("--rigctld-host", default="127.0.0.1", help="adresse de rigctld (defaut: 127.0.0.1)")
    parser.add_argument("--rigctld-port", type=int, default=4532, help="port de rigctld (defaut: 4532)")
    args = parser.parse_args()

    want_gui = args.gui or (not args.no_gui and len(sys.argv) == 1)

    if want_gui:
        try:
            run_gui(args.port, args.upstream, args.rigctld_host, args.rigctld_port)
            return
        except Exception as e:
            print("Interface graphique indisponible (%s) - bascule en mode ligne de commande." % e)
            print("(pas d'ecran, ou tkinter manquant - sous Debian/Ubuntu : sudo apt install python3-tk)")
            print("Dans ce mode, lancez rigctld vous-meme avant de relancer ce script.")

    server = CatBridgeServer(("127.0.0.1", args.port), args.upstream, args.rigctld_host, args.rigctld_port)

    print("=" * 70)
    print(" Pont CAT OP0P pret (mode ligne de commande).")
    print(" Ouvrez cette adresse dans votre navigateur (pas cluster.op0p.be) :")
    print("   http://127.0.0.1:%d/" % args.port)
    print()
    print(" Verification : rigctld doit deja tourner sur %s:%d" %
          (args.rigctld_host, args.rigctld_port))
    print(" (sinon : voir les instructions sur cluster.op0p.be, page CAT)")
    print(" Ctrl+C pour arreter.")
    print("=" * 70)

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nArret du pont CAT.")
        server.shutdown()


if __name__ == "__main__":
    main()
