# Copyright 2026, Jiri Eischmann <jiri@eischmann.cz> and the meshy contributors
# SPDX-License-Identifier: GPL-3.0-or-later

"""Settings view for radio configuration and device settings."""

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gio, GLib, Gtk
from meshy.models import DeviceInfo

# (name, freq_mhz, bw_hz, sf, cr, tx_dbm)
RADIO_PRESETS = [
    ("Australia", 915.8, 250000, 10, 5, 20),
    ("Australia (Narrow)", 916.575, 62500, 7, 5, 20),
    ("Australia SA, WA, QLD", 923.125, 62500, 8, 5, 20),
    ("Czech Republic", 869.432, 62500, 7, 5, 14),
    ("EU 433MHz", 433.650, 250000, 11, 5, 20),
    ("EU/UK (Long Range)", 869.525, 250000, 11, 5, 14),
    ("EU/UK (Medium Range)", 869.525, 250000, 10, 5, 14),
    ("EU/UK (Narrow)", 869.618, 62500, 8, 5, 14),
    ("New Zealand", 917.375, 250000, 11, 5, 20),
    ("New Zealand (Narrow)", 917.375, 62500, 7, 5, 20),
    ("Portugal 433", 433.375, 62500, 9, 5, 20),
    ("Portugal 869", 869.618, 62500, 7, 5, 14),
    ("Switzerland", 869.618, 62500, 8, 5, 14),
    ("USA Arizona", 908.205, 62500, 10, 5, 20),
    ("USA/Canada", 910.525, 62500, 7, 5, 20),
    ("Vietnam", 920.250, 250000, 11, 5, 20),
    ("Off-Grid 433", 433.0, 250000, 11, 5, 20),
    ("Off-Grid 869", 869.0, 250000, 11, 5, 14),
    ("Off-Grid 918", 918.0, 250000, 11, 5, 20),
]


def _off_grid_freq_for(freq_mhz: float) -> float:
    """Pick the off-grid frequency matching the current band."""
    if freq_mhz < 500:
        return 433.0
    if freq_mhz < 900:
        return 869.0
    return 918.0


def _off_grid_preset_for(freq_mhz: float) -> int:
    """Return RADIO_PRESETS index for the matching Off-Grid preset."""
    target = _off_grid_freq_for(freq_mhz)
    for i, (name, freq, *_rest) in enumerate(RADIO_PRESETS):
        if name.startswith("Off-Grid") and abs(freq - target) < 0.01:
            return i
    return -1


@Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/settings-view.ui")
class SettingsView(Gtk.Box):
    __gtype_name__ = "MeshySettingsView"

    _pref_page = Gtk.Template.Child("pref_page")
    _app_group = Gtk.Template.Child("app_group")
    _style_row = Gtk.Template.Child("style_row")
    _theme_row = Gtk.Template.Child("theme_row")
    _channel_notif_row = Gtk.Template.Child("channel_notif_row")
    _background_switch = Gtk.Template.Child("background_switch")
    _autostart_switch = Gtk.Template.Child("autostart_switch")
    _advert_group = Gtk.Template.Child("advert_group")
    _name_entry = Gtk.Template.Child("name_entry")
    _advert_loc_switch = Gtk.Template.Child("advert_loc_switch")
    _loc_group = Gtk.Template.Child("loc_group")
    _gps_switch = Gtk.Template.Child("gps_switch")
    _lat_entry = Gtk.Template.Child("lat_entry")
    _lon_entry = Gtk.Template.Child("lon_entry")
    _locate_btn = Gtk.Template.Child("locate_btn")
    _pick_map_btn = Gtk.Template.Child("pick_map_btn")
    _radio_group = Gtk.Template.Child("radio_group")
    _preset_row = Gtk.Template.Child("preset_row")
    _repeat_switch = Gtk.Template.Child("repeat_switch")
    _radio_advanced = Gtk.Template.Child("radio_advanced")
    _freq_entry = Gtk.Template.Child("freq_entry")
    _bw_entry = Gtk.Template.Child("bw_entry")
    _sf_spin = Gtk.Template.Child("sf_spin")
    _cr_spin = Gtk.Template.Child("cr_spin")
    _tx_spin = Gtk.Template.Child("tx_spin")
    _path_hash_combo = Gtk.Template.Child("path_hash_combo")
    _routing_group = Gtk.Template.Child("routing_group")
    _multi_ack_spin = Gtk.Template.Child("multi_ack_spin")
    _auto_add_group = Gtk.Template.Child("auto_add_group")
    _auto_add_switch = Gtk.Template.Child("auto_add_switch")
    _auto_add_expander = Gtk.Template.Child("auto_add_expander")
    _auto_chat_switch = Gtk.Template.Child("auto_chat_switch")
    _auto_repeater_switch = Gtk.Template.Child("auto_repeater_switch")
    _auto_room_switch = Gtk.Template.Child("auto_room_switch")
    _auto_sensor_switch = Gtk.Template.Child("auto_sensor_switch")
    _auto_overwrite_switch = Gtk.Template.Child("auto_overwrite_switch")
    _auto_hop_limit_entry = Gtk.Template.Child("auto_hop_limit_entry")
    _tele_group = Gtk.Template.Child("tele_group")
    _tele_base_combo = Gtk.Template.Child("tele_base_combo")
    _tele_loc_combo = Gtk.Template.Child("tele_loc_combo")
    _tele_env_combo = Gtk.Template.Child("tele_env_combo")
    _regions_group = Gtk.Template.Child("regions_group")
    _add_region_entry = Gtk.Template.Child("add_region_entry")
    _default_scope_combo = Gtk.Template.Child("default_scope_combo")
    _bt_group = Gtk.Template.Child("bt_group")
    _pin_entry = Gtk.Template.Child("pin_entry")

    def __init__(self, window, **kwargs):
        super().__init__(**kwargs)
        self._window = window
        self._apply_btn: Gtk.Button | None = None
        self._clean_snapshot: dict | None = None
        self._suppressing_changes = False
        self._applying = False  # True while Apply commands are in flight
        self._applying_preset = False  # True while preset is populating fields
        self._pre_repeat_snapshot: dict | None = None  # radio params before repeat on
        self._region_rows: dict[str, Adw.ActionRow] = {}

        self._section_widgets = [
            (_("Application"), self._app_group),
            (_("Advert"), self._advert_group),
            (_("Routing and Messaging"), self._routing_group),
            (_("Location"), self._loc_group),
            (_("Radio"), self._radio_group),
            (_("Auto-Add Contacts"), self._auto_add_group),
            (_("Telemetry Privacy"), self._tele_group),
            (_("Regions"), self._regions_group),
            (_("Bluetooth"), self._bt_group),
        ]

        # Preset ComboRow model — built from RADIO_PRESETS data
        preset_model = Gtk.StringList()
        preset_model.append(_("Custom"))
        for name, *_rest in RADIO_PRESETS:
            preset_model.append(name)
        self._preset_row.set_model(preset_model)
        self._preset_row.connect("notify::selected", self._on_preset_selected)
        self._repeat_switch.connect("notify::active", self._on_repeat_toggled)

        # Telemetry combo models — shared label set
        tele_labels = Gtk.StringList.new([_("Deny All"), _("Allow per Contact"), _("Allow All")])
        self._tele_base_combo.set_model(tele_labels)
        self._tele_loc_combo.set_model(tele_labels)
        self._tele_env_combo.set_model(tele_labels)

        # Default scope combo — starts with "None", regions added dynamically
        self._scope_model = Gtk.StringList()
        self._scope_model.append(_("None"))
        self._default_scope_combo.set_model(self._scope_model)
        self._default_scope_combo.connect("notify::selected", self._on_default_scope_changed)

        # Signal connections
        self._gps_switch.connect("notify::active", self._on_gps_toggled)
        self._locate_btn.connect("clicked", self._on_locate_clicked)
        self._pick_map_btn.connect("clicked", self._on_pick_map_clicked)
        self._add_region_entry.connect("apply", self._on_add_region)

        # Connect change signals for dirty tracking
        for entry in (self._name_entry, self._lat_entry, self._lon_entry):
            entry.connect("changed", lambda *_: self._check_dirty())
        self._pin_entry.connect("changed", self._on_pin_changed)

        # Radio preset params — also reset preset to Custom when edited manually
        def _on_radio_param_changed(*_args):
            if (
                not self._suppressing_changes
                and not self._applying_preset
                and self._preset_row.get_selected() != 0
            ):
                self._preset_row.set_selected(0)
            self._check_dirty()

        for entry in (self._freq_entry, self._bw_entry):
            entry.connect("changed", _on_radio_param_changed)
        for spin in (self._sf_spin, self._cr_spin):
            spin.connect("notify::value", _on_radio_param_changed)
        self._tx_spin.connect("notify::value", lambda *_: self._check_dirty())
        self._multi_ack_spin.connect("notify::value", lambda *_: self._check_dirty())
        self._auto_add_switch.connect("notify::active", self._on_auto_add_toggled)
        for switch in (
            self._advert_loc_switch,
            self._repeat_switch,
            self._gps_switch,
            self._auto_add_switch,
            self._auto_chat_switch,
            self._auto_repeater_switch,
            self._auto_room_switch,
            self._auto_sensor_switch,
            self._auto_overwrite_switch,
        ):
            switch.connect("notify::active", lambda *_: self._check_dirty())
        self._auto_hop_limit_entry.connect("changed", lambda *_: self._check_dirty())
        self._preset_row.connect("notify::selected", lambda *_: self._check_dirty())
        self._path_hash_combo.connect("notify::selected", lambda *_: self._check_dirty())
        for combo in (self._tele_base_combo, self._tele_loc_combo, self._tele_env_combo):
            combo.connect("notify::selected", lambda *_: self._check_dirty())

        self._init_style_settings()

    def _init_style_settings(self):
        from meshy.theme_manager import THEMES

        settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy")
        self._app_settings = settings

        mode = settings.get_string("color-scheme")
        scheme_options = ["system", "light", "dark", "custom"]
        idx = scheme_options.index(mode) if mode in scheme_options else 0
        self._style_row.set_selected(idx)
        self._theme_row.set_visible(idx == 3)

        theme_id = settings.get_string("custom-theme")
        theme = THEMES.get(theme_id)
        if theme:
            self._theme_row.set_subtitle(theme["name"])

        self._style_row.connect("notify::selected", self._on_style_changed)
        self._theme_row.connect("activated", self._on_theme_row_activated)

        notif_level = settings.get_int("default-channel-notification-level")
        self._channel_notif_row.set_selected(max(0, min(notif_level, 2)))
        self._channel_notif_row.connect("notify::selected", self._on_channel_notif_changed)

        self._background_switch.set_active(settings.get_boolean("run-in-background"))
        self._background_switch.connect("notify::active", self._on_background_changed)

        self._autostart_switch.set_active(settings.get_boolean("autostart"))
        self._autostart_switch.connect("notify::active", self._on_autostart_changed)

    def _on_background_changed(self, row, pspec):
        enabled = row.get_active()
        self._app_settings.set_boolean("run-in-background", enabled)
        app = self._window.get_application()
        if app:
            app.update_background_mode(enabled)

    def _on_autostart_changed(self, row, pspec):
        enabled = row.get_active()
        self._app_settings.set_boolean("autostart", enabled)
        app = self._window.get_application()
        if app:
            app.update_autostart(enabled)

    def _on_channel_notif_changed(self, row, pspec):
        self._app_settings.set_int("default-channel-notification-level", row.get_selected())

    def _on_style_changed(self, row, pspec):
        from meshy.theme_manager import ThemeManager

        scheme_options = ["system", "light", "dark", "custom"]
        idx = row.get_selected()
        mode = scheme_options[idx] if idx < len(scheme_options) else "system"
        manager = ThemeManager.get_default()
        if mode == "custom":
            theme_id = self._app_settings.get_string("custom-theme")
            manager.set_custom_theme(theme_id)
        else:
            manager.set_color_scheme(mode)
        self._theme_row.set_visible(idx == 3)

    def _on_theme_row_activated(self, row):
        from meshy.theme_chooser_dialog import ThemeChooserDialog

        dialog = ThemeChooserDialog(self._app_settings, self._on_custom_theme_changed)
        dialog.present(self._window)

    def _on_custom_theme_changed(self):
        from meshy.theme_manager import THEMES

        theme_id = self._app_settings.get_string("custom-theme")
        theme = THEMES.get(theme_id)
        if theme:
            self._theme_row.set_subtitle(theme["name"])

    def create_apply_button(self) -> Gtk.Button:
        """Create an Apply button to be placed in the headerbar."""
        btn = Gtk.Button(label=_("Apply"))
        btn.add_css_class("suggested-action")
        btn.set_sensitive(False)
        btn.connect("clicked", self._on_apply_all)
        self._apply_btn = btn
        return btn

    @property
    def sections(self) -> list[str]:
        return [name for name, _w in self._section_widgets]

    def scroll_to_section(self, section_name: str):
        for name, widget in self._section_widgets:
            if name == section_name:
                GLib.idle_add(self._do_scroll_to, widget)
                break

    def _do_scroll_to(self, widget):
        scrolled = widget.get_ancestor(Gtk.ScrolledWindow)
        if scrolled:
            success, x, y = widget.translate_coordinates(scrolled.get_child(), 0, 0)
            if success:
                adj = scrolled.get_vadjustment()
                adj.set_value(y)
        return False

    def get_auto_add_config(self) -> dict:
        return {
            "auto_add_chat": self._auto_chat_switch.get_active(),
            "auto_add_repeater": self._auto_repeater_switch.get_active(),
            "auto_add_room_server": self._auto_room_switch.get_active(),
            "auto_add_sensor": self._auto_sensor_switch.get_active(),
            "overwrite_oldest": self._auto_overwrite_switch.get_active(),
            "max_hops": int(self._auto_hop_limit_entry.get_text() or "0"),
        }

    def restore_snapshot(self):
        """Revert all widgets to the last clean state (discard unapplied changes)."""
        snap = self._clean_snapshot
        if not snap:
            return
        self._suppressing_changes = True
        self._name_entry.set_text(snap.get("name", ""))
        self._advert_loc_switch.set_active(snap.get("advert_loc", False))
        self._lat_entry.set_text(snap.get("lat", ""))
        self._lon_entry.set_text(snap.get("lon", ""))
        self._freq_entry.set_text(snap.get("freq", ""))
        self._bw_entry.set_text(snap.get("bw", ""))
        self._sf_spin.set_value(snap.get("sf", 7))
        self._cr_spin.set_value(snap.get("cr", 5))
        self._tx_spin.set_value(snap.get("tx", 20))
        self._preset_row.set_selected(snap.get("preset", 0))
        self._repeat_switch.set_active(snap.get("repeat", False))
        self._path_hash_combo.set_selected(snap.get("path_hash", 0))
        self._multi_ack_spin.set_value(snap.get("multi_ack", 1))
        self._auto_add_switch.set_active(snap.get("auto_add", False))
        self._auto_add_expander.set_sensitive(snap.get("auto_add", False))
        self._auto_chat_switch.set_active(snap.get("auto_chat", False))
        self._auto_repeater_switch.set_active(snap.get("auto_repeater", False))
        self._auto_room_switch.set_active(snap.get("auto_room", False))
        self._auto_sensor_switch.set_active(snap.get("auto_sensor", False))
        self._auto_overwrite_switch.set_active(snap.get("auto_overwrite", False))
        self._auto_hop_limit_entry.set_text(snap.get("auto_hop_limit", ""))
        self._tele_base_combo.set_selected(snap.get("tele_base", 0))
        self._tele_loc_combo.set_selected(snap.get("tele_loc", 0))
        self._tele_env_combo.set_selected(snap.get("tele_env", 0))
        self._gps_switch.set_active(snap.get("gps", self._gps_switch.get_active()))
        self._pin_entry.set_text(snap.get("pin", ""))
        self._suppressing_changes = False
        if self._apply_btn:
            self._apply_btn.set_sensitive(False)

    def _take_snapshot(self):
        """Capture current widget values as the 'clean' state."""
        self._clean_snapshot = {
            "name": self._name_entry.get_text(),
            "advert_loc": self._advert_loc_switch.get_active(),
            "lat": self._lat_entry.get_text(),
            "lon": self._lon_entry.get_text(),
            "freq": self._freq_entry.get_text(),
            "bw": self._bw_entry.get_text(),
            "sf": self._sf_spin.get_value(),
            "cr": self._cr_spin.get_value(),
            "tx": self._tx_spin.get_value(),
            "preset": self._preset_row.get_selected(),
            "repeat": self._repeat_switch.get_active(),
            "path_hash": self._path_hash_combo.get_selected(),
            "multi_ack": self._multi_ack_spin.get_value(),
            "auto_add": self._auto_add_switch.get_active(),
            "auto_chat": self._auto_chat_switch.get_active(),
            "auto_repeater": self._auto_repeater_switch.get_active(),
            "auto_room": self._auto_room_switch.get_active(),
            "auto_sensor": self._auto_sensor_switch.get_active(),
            "auto_overwrite": self._auto_overwrite_switch.get_active(),
            "auto_hop_limit": self._auto_hop_limit_entry.get_text(),
            "tele_base": self._tele_base_combo.get_selected(),
            "tele_loc": self._tele_loc_combo.get_selected(),
            "tele_env": self._tele_env_combo.get_selected(),
            "gps": self._gps_switch.get_active(),
            "pin": self._pin_entry.get_text(),
        }
        if self._apply_btn:
            self._apply_btn.set_sensitive(False)

    def _check_dirty(self):
        """Enable Apply button if any setting differs from the clean snapshot."""
        if self._suppressing_changes or not self._clean_snapshot or not self._apply_btn:
            return
        snap = self._clean_snapshot
        dirty = (
            self._name_entry.get_text() != snap["name"]
            or self._advert_loc_switch.get_active() != snap["advert_loc"]
            or self._lat_entry.get_text() != snap["lat"]
            or self._lon_entry.get_text() != snap["lon"]
            or self._freq_entry.get_text() != snap["freq"]
            or self._bw_entry.get_text() != snap["bw"]
            or self._sf_spin.get_value() != snap["sf"]
            or self._cr_spin.get_value() != snap["cr"]
            or self._tx_spin.get_value() != snap["tx"]
            or self._preset_row.get_selected() != snap["preset"]
            or self._repeat_switch.get_active() != snap.get("repeat", False)
            or self._path_hash_combo.get_selected() != snap.get("path_hash", 0)
            or self._multi_ack_spin.get_value() != snap.get("multi_ack", 1)
            or self._auto_add_switch.get_active() != snap.get("auto_add", False)
            or self._auto_chat_switch.get_active() != snap["auto_chat"]
            or self._auto_repeater_switch.get_active() != snap["auto_repeater"]
            or self._auto_room_switch.get_active() != snap["auto_room"]
            or self._auto_sensor_switch.get_active() != snap["auto_sensor"]
            or self._auto_overwrite_switch.get_active() != snap["auto_overwrite"]
            or self._auto_hop_limit_entry.get_text() != snap.get("auto_hop_limit", "")
            or self._tele_base_combo.get_selected() != snap.get("tele_base", 0)
            or self._tele_loc_combo.get_selected() != snap.get("tele_loc", 0)
            or self._tele_env_combo.get_selected() != snap.get("tele_env", 0)
            or self._gps_switch.get_active() != snap.get("gps", False)
            or self._pin_entry.get_text() != snap.get("pin", "")
        )
        pin_ok = self._is_pin_valid(self._pin_entry.get_text().strip())
        self._apply_btn.set_sensitive(dirty and pin_ok)

    def _detect_preset(self, info: DeviceInfo):
        """Set the preset dropdown to match current radio params, or Custom."""
        if not info.radio_freq:
            return
        for i, (_, freq, bw, sf, cr, _tx) in enumerate(RADIO_PRESETS):
            if (
                abs(info.radio_freq - freq) < 0.01
                and abs(info.radio_bw - bw / 1000) < 0.01
                and info.radio_sf == sf
                and info.radio_cr == cr
            ):
                self._preset_row.set_selected(i + 1)  # +1 for "Custom" at index 0
                return
        self._preset_row.set_selected(0)  # Custom

    def _on_preset_selected(self, combo, *args):
        idx = combo.get_selected()
        if idx == 0:  # "Custom" — don't change fields
            return
        self._applying_preset = True
        _, freq, bw, sf, cr, _tx = RADIO_PRESETS[idx - 1]
        self._freq_entry.set_text(str(freq))
        self._bw_entry.set_text(str(bw / 1000))  # Hz to kHz for display
        self._sf_spin.set_value(sf)
        self._cr_spin.set_value(cr)
        self._applying_preset = False
        # TX power is intentionally not set — it's independent of the preset

    def _on_repeat_toggled(self, switch, *args):
        if self._suppressing_changes:
            return
        active = switch.get_active()
        if active:
            self._pre_repeat_snapshot = {
                "freq": self._freq_entry.get_text(),
                "bw": self._bw_entry.get_text(),
                "sf": self._sf_spin.get_value(),
                "cr": self._cr_spin.get_value(),
                "preset": self._preset_row.get_selected(),
            }
            try:
                cur_freq = float(self._freq_entry.get_text())
            except ValueError:
                cur_freq = 918.0
            og_idx = _off_grid_preset_for(cur_freq)
            if og_idx >= 0:
                self._suppressing_changes = True
                self._applying_preset = True
                _, freq, bw, sf, cr, _tx = RADIO_PRESETS[og_idx]
                self._freq_entry.set_text(str(freq))
                self._bw_entry.set_text(str(bw / 1000))
                self._sf_spin.set_value(sf)
                self._cr_spin.set_value(cr)
                self._preset_row.set_selected(og_idx + 1)
                self._applying_preset = False
                self._suppressing_changes = False
        else:
            snap = self._pre_repeat_snapshot
            if snap:
                self._suppressing_changes = True
                self._applying_preset = True
                self._freq_entry.set_text(snap["freq"])
                self._bw_entry.set_text(snap["bw"])
                self._sf_spin.set_value(snap["sf"])
                self._cr_spin.set_value(snap["cr"])
                self._preset_row.set_selected(snap["preset"])
                self._applying_preset = False
                self._suppressing_changes = False
                self._pre_repeat_snapshot = None
        self._preset_row.set_sensitive(not active)
        self._radio_advanced.set_sensitive(not active)
        self._check_dirty()

    def _on_auto_add_toggled(self, switch, *args):
        if self._suppressing_changes:
            return
        self._auto_add_expander.set_sensitive(switch.get_active())

    def _on_apply_all(self, *args):
        """Apply all settings to the device, sending only changed values."""
        snap = self._clean_snapshot or {}
        cmds = []  # list of callables to send in sequence

        # Advert
        name = self._name_entry.get_text().strip()
        if name and name != snap.get("name", ""):
            cmds.append(lambda: self._window.set_advert_name(name))

        # Location — send latlon first, then loc policy via set_other_params
        lat_text = self._lat_entry.get_text().strip()
        lon_text = self._lon_entry.get_text().strip()
        try:
            lat = float(lat_text) if lat_text else 0.0
            lon = float(lon_text) if lon_text else 0.0
            if lat_text != snap.get("lat", "") or lon_text != snap.get("lon", ""):
                cmds.append(lambda _lat=lat, _lon=lon: self._window.set_advert_latlon(_lat, _lon))
        except ValueError:
            pass

        advert_loc = self._advert_loc_switch.get_active()
        if advert_loc != snap.get("advert_loc", False):
            policy = 1 if advert_loc else 0
            cmds.append(lambda: self._window.set_advert_loc_policy(policy))

        # Companion GPS
        gps = self._gps_switch.get_active()
        if gps != snap.get("gps", False):
            value = "1" if gps else "0"
            cmds.append(lambda: self._window.set_custom_var(f"gps:{value}"))

        # Telemetry privacy
        tele_base = self._tele_base_combo.get_selected()
        tele_loc = self._tele_loc_combo.get_selected()
        tele_env = self._tele_env_combo.get_selected()
        if (
            tele_base != snap.get("tele_base", 0)
            or tele_loc != snap.get("tele_loc", 0)
            or tele_env != snap.get("tele_env", 0)
        ):
            cmds.append(lambda: self._window.set_telemetry_mode(tele_base, tele_loc, tele_env))

        # Radio
        try:
            freq_mhz = float(self._freq_entry.get_text())
            bw_khz = float(self._bw_entry.get_text())
            sf = int(self._sf_spin.get_value())
            cr = int(self._cr_spin.get_value())
            power = int(self._tx_spin.get_value())
            repeat = self._repeat_switch.get_active()
            repeat_changed = repeat != snap.get("repeat", False)
            radio_changed = (
                self._freq_entry.get_text() != snap.get("freq", "")
                or self._bw_entry.get_text() != snap.get("bw", "")
                or sf != snap.get("sf", 0)
                or cr != snap.get("cr", 0)
            )
            if radio_changed or repeat_changed:
                rpt = repeat if repeat_changed else None
                dev_cr = cr - 4 if self._window._device_info.cr_old_encoding else cr
                cmds.append(
                    lambda: self._window.set_radio_params(
                        int(freq_mhz * 1000), int(bw_khz * 1000), sf, dev_cr, rpt
                    )
                )
            if power != snap.get("tx", 0):
                cmds.append(lambda: self._window.set_tx_power(power))
        except ValueError:
            pass

        # Path hash mode
        if (
            self._path_hash_combo.get_sensitive()
            and self._path_hash_combo.get_selected() != snap.get("path_hash", 0)
        ):
            mode = self._path_hash_combo.get_selected()
            cmds.append(lambda: self._window.set_path_hash_mode(mode))

        # Multi ACK (display 1/2 → firmware 0/1)
        multi_ack_val = int(self._multi_ack_spin.get_value())
        if multi_ack_val != int(snap.get("multi_ack", 1)):
            fw_val = multi_ack_val - 1
            cmds.append(lambda: self._window.set_multi_acks(fw_val))

        # Auto-add
        auto_add = self._auto_add_switch.get_active()
        hop_text = self._auto_hop_limit_entry.get_text().strip()
        hop_limit = int(hop_text) if hop_text.isdigit() else 0
        if (
            auto_add != snap.get("auto_add", False)
            or self._auto_chat_switch.get_active() != snap.get("auto_chat", False)
            or self._auto_repeater_switch.get_active() != snap.get("auto_repeater", False)
            or self._auto_room_switch.get_active() != snap.get("auto_room", False)
            or self._auto_sensor_switch.get_active() != snap.get("auto_sensor", False)
            or self._auto_overwrite_switch.get_active() != snap.get("auto_overwrite", False)
            or self._auto_hop_limit_entry.get_text() != snap.get("auto_hop_limit", "")
        ):
            from meshy.protocol import build_set_auto_add_config

            if auto_add:
                frame = build_set_auto_add_config(
                    chat=self._auto_chat_switch.get_active(),
                    repeater=self._auto_repeater_switch.get_active(),
                    room=self._auto_room_switch.get_active(),
                    sensor=self._auto_sensor_switch.get_active(),
                    overwrite_oldest=self._auto_overwrite_switch.get_active(),
                    max_hops=hop_limit,
                )
            else:
                frame = build_set_auto_add_config()
            cmds.append(lambda: self._window.send_frame(frame))

        # BLE PIN
        pin_text = self._pin_entry.get_text().strip()
        if pin_text != snap.get("pin", "") and pin_text.isdigit():
            pin_val = int(pin_text)
            cmds.append(lambda: self._window.set_device_pin(pin_val))

        if not cmds:
            return

        self._applying = True
        self._take_snapshot()
        self._send_next_setting(cmds, 0)

    def _send_next_setting(self, cmds, index):
        if index >= len(cmds):
            self._applying = False
            self._window.show_toast(_("Settings applied on device"))
            from meshy.protocol import build_device_query

            self._window.send_frame(build_device_query())
            return
        self._window._frame_handler.expect_ok(
            on_ok=lambda: self._send_next_setting(cmds, index + 1),
            on_error=lambda err: self._on_setting_error(err),
        )
        cmds[index]()

    def _on_setting_error(self, err):
        self._applying = False
        msg = err["message"] if err else _("Unknown error")
        self._window.show_toast(_("Settings error: {}").format(msg))

    def update_device_info(self, info: DeviceInfo):
        """Update fields from device info."""
        if self._applying:
            return
        self._suppressing_changes = True
        if info.radio_freq:
            self._freq_entry.set_text(str(info.radio_freq))
        if info.radio_bw:
            self._bw_entry.set_text(str(info.radio_bw))
        if info.radio_sf:
            self._sf_spin.set_value(info.radio_sf)
        if info.radio_cr:
            self._cr_spin.set_value(info.radio_cr)
        if info.max_tx_power:
            adj = self._tx_spin.get_adjustment()
            adj.set_upper(info.max_tx_power)
            self._tx_spin.set_title(_("TX Power (dBm, max {})").format(info.max_tx_power))
        if info.tx_power:
            self._tx_spin.set_value(info.tx_power)
        if info.fw_ver >= 9:
            self._repeat_switch.set_visible(True)
            self._repeat_switch.set_active(info.repeat)
            self._preset_row.set_sensitive(not info.repeat)
            self._radio_advanced.set_sensitive(not info.repeat)
        if info.fw_ver >= 10:
            self._path_hash_combo.set_selected(info.path_hash_mode)
            self._path_hash_combo.set_sensitive(True)
            self._multi_ack_spin.set_value(info.multi_acks + 1)
            self._multi_ack_spin.set_sensitive(True)
        if info.name:
            self._name_entry.set_text(info.name)
        if info.ble_pin:
            self._pin_entry.set_text(str(info.ble_pin))
        # Auto-detect matching preset from current radio params
        self._detect_preset(info)
        self._suppressing_changes = False
        self._take_snapshot()

    def update_transport_type(self, transport_type: str):
        """Show Bluetooth group only when connected via BLE."""
        self._bt_group.set_visible(transport_type == "ble")

    def update_gps_settings(self, custom_vars: dict):
        """Update GPS toggle from custom vars."""
        if "gps" in custom_vars:
            self._gps_switch.set_visible(True)
            self._suppressing_changes = True
            self._gps_switch.set_active(custom_vars.get("gps") == "1")
            self._suppressing_changes = False
            if self._clean_snapshot is not None:
                self._clean_snapshot["gps"] = self._gps_switch.get_active()

    def _on_gps_toggled(self, switch, *args):
        if self._suppressing_changes:
            return

    def _is_pin_valid(self, text: str) -> bool:
        if not text:
            return True
        if not text.isdigit():
            return False
        val = int(text)
        return val == 0 or 100000 <= val <= 999999

    def _on_pin_changed(self, entry, *args):
        text = entry.get_text().strip()
        if self._is_pin_valid(text):
            entry.remove_css_class("error")
        else:
            entry.add_css_class("error")
        self._check_dirty()

    def update_telemetry_modes(self, base: int, loc: int, env: int):
        """Populate the telemetry privacy combos from device self_info."""
        if self._applying:
            return
        self._suppressing_changes = True
        self._tele_base_combo.set_selected(base)
        self._tele_loc_combo.set_selected(loc)
        self._tele_env_combo.set_selected(env)
        self._suppressing_changes = False
        self._take_snapshot()

    def update_multi_acks(self, value: int):
        if self._applying:
            return
        self._suppressing_changes = True
        self._multi_ack_spin.set_value(value + 1)
        self._suppressing_changes = False
        self._take_snapshot()

    def update_advert_location(self, lat: float, lon: float, loc_policy: int = 0):
        """Populate the lat/lon fields and location policy from the device."""
        if self._applying:
            return
        self._suppressing_changes = True
        self._advert_loc_switch.set_active(loc_policy > 0)
        if abs(lat) > 1e-6 or abs(lon) > 1e-6:
            self._lat_entry.set_text(str(lat))
            self._lon_entry.set_text(str(lon))
        self._suppressing_changes = False
        self._take_snapshot()

    def _on_locate_clicked(self, btn):
        """Use GeoClue to get the computer's location and fill lat/lon fields."""
        import gi

        gi.require_version("Geoclue", "2.0")
        from gi.repository import Geoclue

        btn.set_sensitive(False)

        def _on_ready(source, result, *args):
            try:
                geoclue = Geoclue.Simple.new_finish(result)
                loc = geoclue.get_location()
                lat = loc.get_property("latitude")
                lon = loc.get_property("longitude")
                self._lat_entry.set_text(f"{lat:.6f}")
                self._lon_entry.set_text(f"{lon:.6f}")
                self._window.show_toast(_("Location filled, click Apply to save"))
            except Exception:
                self._window.show_toast(_("Location unavailable"))
            btn.set_sensitive(True)

        Geoclue.Simple.new(
            "page.codeberg.sesivany.Meshy",
            Geoclue.AccuracyLevel.EXACT,
            None,
            _on_ready,
        )

    def _on_pick_map_clicked(self, btn):
        """Open a map dialog where the user can click to pick a location."""
        from meshy.views import open_map_picker

        init_lat, init_lon = 0.0, 0.0
        try:
            lat = float(self._lat_entry.get_text())
            lon = float(self._lon_entry.get_text())
            if abs(lat) > 1e-6 or abs(lon) > 1e-6:
                init_lat, init_lon = lat, lon
        except ValueError:
            pass

        def _on_pick(lat, lon):
            self._lat_entry.set_text(f"{lat:.6f}")
            self._lon_entry.set_text(f"{lon:.6f}")

        open_map_picker(
            self._window, _("Pick Location"), _("Set Location"), init_lat, init_lon, _on_pick
        )

    def load_regions(self):
        """Load regions from storage and populate the UI."""
        if not self._window.storage:
            return
        for name in self._window.storage.get_regions():
            self._add_region_row(name)

    def _on_add_region(self, entry):
        name = entry.get_text().strip()
        if not name:
            return
        # Strip leading # if user typed it — stored without hashtag
        if name.startswith("#"):
            name = name[1:]
        if not name:
            return
        if name in self._region_rows:
            return
        if self._window.storage:
            self._window.storage.add_region(name)
        self._add_region_row(name)
        entry.set_text("")

    def _add_region_row(self, name: str):
        if name in self._region_rows:
            return
        row = Adw.ActionRow(title=name)
        remove_btn = Gtk.Button(
            icon_name="edit-delete-symbolic",
            valign=Gtk.Align.CENTER,
        )
        remove_btn.add_css_class("flat")
        remove_btn.connect("clicked", lambda *_, n=name: self._remove_region(n))
        row.add_suffix(remove_btn)
        self._regions_group.add(row)
        self._region_rows[name] = row
        self._scope_model.append(name)

    def _remove_region(self, name: str):
        row = self._region_rows.pop(name, None)
        if row:
            self._regions_group.remove(row)
        # Remove from scope combo
        for i in range(self._scope_model.get_n_items()):
            if self._scope_model.get_string(i) == name:
                if self._default_scope_combo.get_selected() == i:
                    self._default_scope_combo.set_selected(0)
                self._scope_model.remove(i)
                break
        if self._window.storage:
            self._window.storage.remove_region(name)
            # Clear region from in-memory channels too
            affected = False
            for ch in self._window.channels:
                if ch.region == name:
                    ch.region = ""
                    affected = True
            if affected:
                self._window._channels_view.update_channels(self._window.channels)

    def get_regions(self) -> list[str]:
        return list(self._region_rows.keys())

    @property
    def default_scope(self) -> str:
        idx = self._default_scope_combo.get_selected()
        if idx == 0 or idx == Gtk.INVALID_LIST_POSITION:
            return ""
        return self._scope_model.get_string(idx) or ""

    def _on_default_scope_changed(self, combo, *args):
        if self._suppressing_changes:
            return
        scope = self.default_scope
        if self._window.storage:
            self._window.storage.set_default_scope(scope)
        if self._window.is_connected:
            from meshy.protocol import build_set_flood_scope

            self._window.send_frame(build_set_flood_scope(scope))

    def restore_default_scope(self):
        """Restore default scope selection from storage."""
        if not self._window.storage:
            return
        scope = self._window.storage.get_default_scope()
        if not scope:
            return
        self._suppressing_changes = True
        for i in range(self._scope_model.get_n_items()):
            if self._scope_model.get_string(i) == scope:
                self._default_scope_combo.set_selected(i)
                break
        self._suppressing_changes = False

    def _on_discover_regions(self, *args):
        """Query repeater contacts for their regions and let user pick."""
        from meshy.models import ContactType

        if not self._window.is_connected:
            self._window.show_toast(_("Connect to a device first"))
            return

        repeaters = [
            c for c in self._window.contacts if c.type in (ContactType.REPEATER, ContactType.ROOM)
        ]
        if not repeaters:
            self._window.show_toast(_("No repeaters in contacts"))
            return

        dialog = Adw.Dialog()
        dialog.set_title(_("Discover Regions"))
        dialog.set_content_width(420)
        dialog.set_content_height(480)

        toolbar_view = Adw.ToolbarView()
        header = Adw.HeaderBar()
        toolbar_view.add_top_bar(header)

        content = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=8,
            margin_start=8,
            margin_end=8,
            margin_bottom=8,
        )

        status_box = Gtk.Box(spacing=8, halign=Gtk.Align.CENTER)
        spinner = Gtk.Spinner(spinning=True)
        status_box.append(spinner)
        status_label = Gtk.Label(
            label=(
                _("Querying {} repeaters...").format(len(repeaters))
                if len(repeaters) != 1
                else _("Querying {} repeater...").format(len(repeaters))
            )
        )
        status_label.add_css_class("dim-label")
        status_box.append(status_label)
        content.append(status_box)

        scroll = Gtk.ScrolledWindow(vexpand=True)
        list_box = Gtk.ListBox()
        list_box.set_selection_mode(Gtk.SelectionMode.NONE)
        list_box.add_css_class("boxed-list")

        placeholder = Adw.StatusPage(
            icon_name="edit-find-symbolic",
            title=_("Waiting..."),
            description=_("Querying repeaters for regions"),
        )
        list_box.set_placeholder(placeholder)
        list_wrapper = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            margin_start=8,
            margin_end=8,
            margin_top=8,
            margin_bottom=8,
        )
        list_wrapper.append(list_box)
        scroll.set_child(list_wrapper)
        content.append(scroll)

        # Add selected button
        add_btn = Gtk.Button(label=_("Add Selected"))
        add_btn.add_css_class("suggested-action")
        add_btn.set_halign(Gtk.Align.CENTER)
        add_btn.set_sensitive(False)
        content.append(add_btn)

        toolbar_view.set_content(content)
        dialog.set_child(toolbar_view)

        discovered: dict[str, Gtk.CheckButton] = {}  # region_name -> check button
        pending = [len(repeaters)]

        def _on_complete():
            pending[0] -= 1
            if pending[0] <= 0:
                spinner.set_spinning(False)
                count = len(discovered)
                if count:
                    status_label.set_label(
                        _("Found {} regions").format(count)
                        if count != 1
                        else _("Found {} region").format(count)
                    )
                    add_btn.set_sensitive(True)
                else:
                    status_label.set_label(_("No regions found"))

        def _on_region_response(repeater_name, region_text):
            if not region_text:
                GLib.idle_add(_on_complete)
                return

            def _update_ui():
                # Parse region text — regions are newline or comma separated
                for line in region_text.replace(",", "\n").split("\n"):
                    name = line.strip().strip("#")
                    if not name or name in discovered:
                        continue
                    already_added = name in self._region_rows
                    check = Gtk.CheckButton(active=not already_added)
                    check.set_valign(Gtk.Align.CENTER)
                    subtitle = _("from {}").format(repeater_name)
                    if already_added:
                        subtitle += " " + _("(already added)")
                    row = Adw.ActionRow(
                        title=name,
                        subtitle=subtitle,
                    )
                    row.add_prefix(check)
                    if already_added:
                        check.set_sensitive(False)
                    list_box.append(row)
                    discovered[name] = check
                _on_complete()

            GLib.idle_add(_update_ui)

        for i, repeater in enumerate(repeaters):

            def _send_request(rep=repeater):
                self._window.request_regions(
                    rep,
                    lambda text, rn=rep.name: _on_region_response(rn, text),
                )
                # Timeout for this request
                GLib.timeout_add(15000, lambda r=rep: _on_complete() or False)

            # Stagger requests 500ms apart to avoid overwhelming the firmware
            GLib.timeout_add(i * 500, lambda fn=_send_request: fn() or False)

        def _on_add(*_args):
            count = 0
            for name, check in discovered.items():
                if check.get_active() and name not in self._region_rows:
                    if self._window.storage:
                        self._window.storage.add_region(name)
                    self._add_region_row(name)
                    count += 1
            dialog.close()
            if count:
                self._window.show_toast(
                    _("Added {} regions").format(count)
                    if count != 1
                    else _("Added {} region").format(count)
                )

        add_btn.connect("clicked", _on_add)
        dialog.present(self._window)

    def update_auto_add_config(self, config: dict):
        """Update auto-add switches from device config."""
        self._suppressing_changes = True
        flags = config.get("config", 0)
        any_type = bool(flags & 0x1E)
        self._auto_add_switch.set_active(any_type)
        self._auto_add_expander.set_sensitive(any_type)
        self._auto_chat_switch.set_active(bool(flags & 0x02))
        self._auto_repeater_switch.set_active(bool(flags & 0x04))
        self._auto_room_switch.set_active(bool(flags & 0x08))
        self._auto_sensor_switch.set_active(bool(flags & 0x10))
        self._auto_overwrite_switch.set_active(bool(flags & 0x01))
        max_hops = config.get("max_hops", 0)
        self._auto_hop_limit_entry.set_text(str(max_hops) if max_hops > 0 else "")
        self._suppressing_changes = False
        self._take_snapshot()

    # ─── Backup & Restore ─────────────────────────────────────

    def build_backup_actions(self) -> Gtk.ListBox:
        """Build the Backup & Restore list for the sidebar."""
        listbox = Gtk.ListBox()
        listbox.set_selection_mode(Gtk.SelectionMode.NONE)
        listbox.add_css_class("boxed-list")

        export_row = Adw.ActionRow(
            title=_("Export Backup"),
            subtitle=_("Save to a file"),
        )
        export_btn = Gtk.Button(
            icon_name="document-save-symbolic",
            valign=Gtk.Align.CENTER,
        )
        export_btn.add_css_class("flat")
        export_btn.connect("clicked", self._on_export_backup)
        export_row.add_suffix(export_btn)
        export_row.set_activatable_widget(export_btn)
        listbox.append(export_row)

        import_row = Adw.ActionRow(
            title=_("Import Backup"),
            subtitle=_("Restore from a file"),
        )
        import_btn = Gtk.Button(
            icon_name="document-open-symbolic",
            valign=Gtk.Align.CENTER,
        )
        import_btn.add_css_class("flat")
        import_btn.connect("clicked", self._on_import_backup)
        import_row.add_suffix(import_btn)
        import_row.set_activatable_widget(import_btn)
        listbox.append(import_row)

        return listbox

    def _on_export_backup(self, *args):
        """Export backup to a JSON file."""
        import json
        from datetime import datetime

        if not self._window.is_connected:
            dialog = Adw.AlertDialog(
                heading=_("Not Connected"),
                body=_("Connect to a device first to export a backup."),
            )
            dialog.add_response("ok", _("OK"))
            dialog.present(self._window)
            return

        backup = self._window.export_backup()
        name = backup.get("name", "meshy").replace(" ", "_")
        ts = datetime.now().strftime("%Y-%m-%d-%H%M%S")
        default_name = f"{name}_meshcore_config_{ts}.json"

        file_dialog = Gtk.FileDialog()
        file_dialog.set_initial_name(default_name)

        json_filter = Gtk.FileFilter()
        json_filter.set_name(_("JSON files"))
        json_filter.add_pattern("*.json")
        filters = Gio.ListStore.new(Gtk.FileFilter)
        filters.append(json_filter)
        file_dialog.set_filters(filters)

        def on_save_response(dialog, result):
            try:
                gfile = dialog.save_finish(result)
                if gfile:
                    path = gfile.get_path()
                    with open(path, "w", encoding="utf-8") as f:
                        json.dump(backup, f, indent=2, ensure_ascii=False)
                    self._window.show_toast(_("Backup exported successfully"))
            except GLib.Error:
                pass  # user cancelled
            except Exception as e:
                self._window.show_toast(_("Export failed: {}").format(e), timeout=5)

        file_dialog.save(self._window, None, on_save_response)

    def _on_import_backup(self, *args):
        """Import backup from a JSON file."""
        if not self._window.is_connected:
            dialog = Adw.AlertDialog(
                heading=_("Not Connected"),
                body=_("Connect to a device first to import a backup."),
            )
            dialog.add_response("ok", _("OK"))
            dialog.present(self._window)
            return

        file_dialog = Gtk.FileDialog()

        json_filter = Gtk.FileFilter()
        json_filter.set_name(_("JSON files"))
        json_filter.add_pattern("*.json")
        filters = Gio.ListStore.new(Gtk.FileFilter)
        filters.append(json_filter)
        file_dialog.set_filters(filters)

        def on_open_response(dialog, result):
            try:
                gfile = dialog.open_finish(result)
                if gfile:
                    path = gfile.get_path()
                    self._show_import_preview(path)
            except GLib.Error:
                pass  # user cancelled

        file_dialog.open(self._window, None, on_open_response)

    def _show_import_preview(self, path: str):
        """Show a preview of what will be imported and let user confirm."""
        import json

        try:
            with open(path, encoding="utf-8") as f:
                backup = json.load(f)
        except Exception as e:
            dialog = Adw.AlertDialog(
                heading=_("Import Failed"),
                body=_("Could not read backup file: {}").format(e),
            )
            dialog.add_response("ok", _("OK"))
            dialog.present(self._window)
            return

        # Count what's in the backup
        n_contacts = len(backup.get("contacts", []))
        n_channels = len(backup.get("channels", []))
        n_messages = sum(len(msgs) for msgs in backup.get("messages", {}).values())
        n_ch_messages = sum(len(msgs) for msgs in backup.get("channel_messages", {}).values())
        n_passwords = len(backup.get("room_passwords", {}))
        source_name = backup.get("name", _("Unknown"))

        # Count what's new (not already on device)
        existing_contacts = {c.public_key_hex for c in self._window.contacts}
        new_contacts = sum(
            1
            for c in backup.get("contacts", [])
            if c.get("public_key", "") not in existing_contacts
        )
        existing_channels = {c.psk_hex for c in self._window.channels if not c.is_empty}
        new_channels = sum(
            1 for c in backup.get("channels", []) if c.get("secret", "") not in existing_channels
        )

        has_settings = bool(
            backup.get("name")
            or backup.get("radio_settings")
            or backup.get("position_settings")
            or backup.get("other_settings")
            or backup.get("auto_add_settings")
        )

        lines = [_("Source: {}").format(source_name)]
        if has_settings:
            lines.append(_("Device settings: name, radio, location"))
        if n_contacts:
            lines.append(_("Contacts: {} ({} new)").format(n_contacts, new_contacts))
        if n_channels:
            lines.append(_("Channels: {} ({} new)").format(n_channels, new_channels))
        if n_messages:
            lines.append(_("Messages: {}").format(n_messages))
        if n_ch_messages:
            lines.append(_("Channel messages: {}").format(n_ch_messages))
        if n_passwords:
            lines.append(_("Saved passwords: {}").format(n_passwords))

        dialog = Adw.AlertDialog(
            heading=_("Import Backup"),
            body="\n".join(lines),
        )

        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("device", _("Contacts & Channels Only"))
        dialog.add_response("all", _("Everything"))
        dialog.set_response_appearance("all", Adw.ResponseAppearance.SUGGESTED)

        def on_response(d, response):
            if response == "cancel":
                return
            restore_messages = response == "all"
            contacts, channels = self._window.import_backup(
                backup, restore_messages=restore_messages
            )
            parts = []
            if contacts:
                parts.append(_("{} contacts").format(contacts))
            if channels:
                parts.append(_("{} channels").format(channels))
            if restore_messages and n_messages:
                parts.append(_("messages"))
            if parts:
                self._window.show_toast(_("Imported: {}").format(", ".join(parts)), timeout=4)
            else:
                self._window.show_toast(_("Nothing new to import"))

        dialog.connect("response", on_response)
        dialog.present(self._window)
