# 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 Contact, DeviceInfo
from meshy.utils import clear_children, format_number, parse_number

# (name, freq_mhz, bw_hz, sf, cr, tx_dbm)
RADIO_PRESETS = [
    ("Australia (Mid)", 915.075, 125000, 9, 5, 20),
    ("Australia (Narrow)", 916.575, 62500, 7, 8, 20),
    ("Australia (Wide)", 915.8, 250000, 11, 5, 20),
    ("Australia SA, WA", 923.125, 62500, 8, 8, 20),
    ("Australia QLD", 923.125, 62500, 8, 5, 20),
    ("Brazil", 923.125, 62500, 8, 8, 20),
    ("Czech Republic", 869.432, 62500, 7, 5, 14),
    ("EU 433MHz", 433.650, 250000, 11, 5, 20),
    ("EU/UK (Deprecated)", 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),
    ("Netherlands", 869.618, 62500, 7, 5, 14),
    ("Portugal 433", 433.375, 62500, 9, 5, 20),
    ("Portugal 869", 869.618, 62500, 7, 5, 14),
    ("Switzerland", 869.618, 62500, 8, 5, 14),
    ("USA/Canada", 910.525, 62500, 7, 5, 20),
    ("Vietnam (Narrow)", 920.250, 62500, 8, 5, 20),
    ("Vietnam (Deprecated)", 920.250, 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.495
    return 918.0


def _repeat_freq_from_ranges(freq_mhz: float, ranges: list[tuple[int, int]]) -> float | None:
    """Pick a repeat frequency (MHz) from firmware-reported ranges for the current band."""
    freq_khz = int(freq_mhz * 1000)
    best = None
    best_dist = float("inf")
    for lower, upper in ranges:
        mid = (lower + upper) // 2
        dist = abs(mid - freq_khz)
        if dist < best_dist:
            best_dist = dist
            best = mid
    return best / 1000.0 if best is not None else None


@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")
    _routing_help_button = Gtk.Template.Child("routing_help_button")
    _routing_mode_combo = Gtk.Template.Child("routing_mode_combo")
    _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")
    _regions_wrapbox = Gtk.Template.Child("regions_wrapbox")
    _region_entry = Gtk.Template.Child("region_entry")
    _add_region_btn = Gtk.Template.Child("add_region_btn")
    _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._radio_dirty = False  # True when user edited radio params since last snapshot
        self._pre_repeat_snapshot: dict | None = None  # radio params before repeat on
        self._region_rows: dict[str, Gtk.Button] = {}

        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._region_entry.connect("activate", self._on_add_region)
        self._add_region_btn.connect("clicked", self._on_add_region)
        self._region_entry.connect("icon-press", self._on_region_icon_press)

        # 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:
                self._radio_dirty = True
                if 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()
        self._init_routing_settings()

    def _init_routing_settings(self):
        mode = self._app_settings.get_string("default-routing-mode")
        self._routing_mode_combo.set_selected(1 if mode == "smart" else 0)
        self._routing_mode_combo.connect("notify::selected", self._on_routing_mode_changed)
        self._routing_help_button.connect("clicked", self._on_routing_help_clicked)

    def _on_routing_help_clicked(self, _button):
        Gtk.show_uri(self._window, "https://meshy-app.org/docs/guide/routing/", 0)

    def _on_routing_mode_changed(self, combo, pspec):
        mode = "smart" if combo.get_selected() == 1 else "classic"
        self._app_settings.set_string("default-routing-mode", mode)
        Contact._global_smart_default = mode == "smart"
        self._window.update_contacts_from_storage()

    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
        self._radio_dirty = 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(),
        }
        self._radio_dirty = False
        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)
        if not dirty:
            self._radio_dirty = False

    def _detect_preset(self, info: DeviceInfo):
        """Set the preset dropdown to match current radio params, or Custom."""
        if info.repeat or not info.radio_freq:
            self._preset_row.set_selected(0)
            return
        for i, (_, freq, bw, sf, cr, _tx) in enumerate(RADIO_PRESETS):
            if (
                abs(info.radio_freq - freq) < 0.001
                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 self._suppressing_changes:
            return
        self._radio_dirty = True
        if idx == 0:
            self._check_dirty()
            return
        self._applying_preset = True
        _, freq, bw, sf, cr, _tx = RADIO_PRESETS[idx - 1]
        self._freq_entry.set_text(format_number(freq, 3))
        self._bw_entry.set_text(format_number(bw / 1000, 1))  # 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(),
                "preset": self._preset_row.get_selected(),
            }
            try:
                cur_freq = parse_number(self._freq_entry.get_text())
            except ValueError:
                cur_freq = 918.0
            fw_freq = _repeat_freq_from_ranges(
                cur_freq, self._window._device_info.allowed_repeat_freqs
            )
            repeat_freq = fw_freq if fw_freq is not None else _off_grid_freq_for(cur_freq)
            self._suppressing_changes = True
            self._freq_entry.set_text(format_number(repeat_freq, 3))
            self._preset_row.set_selected(0)
            self._suppressing_changes = False
            self._freq_entry.set_sensitive(False)
            self._preset_row.set_sensitive(False)
        else:
            snap = self._pre_repeat_snapshot
            if snap:
                self._suppressing_changes = True
                self._freq_entry.set_text(snap["freq"])
                self._preset_row.set_selected(snap["preset"])
                self._suppressing_changes = False
                self._pre_repeat_snapshot = None
            self._freq_entry.set_sensitive(True)
            self._preset_row.set_sensitive(True)
        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 = parse_number(self._freq_entry.get_text())
            bw_khz = parse_number(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._sync_device_info_from_widgets()
            self._window.show_toast(_("Settings applied on device"))
            from meshy.protocol import build_app_start

            self._window.send_frame(build_app_start())
            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 _sync_device_info_from_widgets(self):
        info = self._window._device_info
        try:
            info.radio_freq = parse_number(self._freq_entry.get_text())
            info.radio_bw = parse_number(self._bw_entry.get_text())
            info.radio_sf = int(self._sf_spin.get_value())
            info.radio_cr = int(self._cr_spin.get_value())
            info.tx_power = int(self._tx_spin.get_value())
        except ValueError:
            pass
        self._window._device_view.update_device_info(info)

    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
        if self._radio_dirty:
            return
        self._suppressing_changes = True
        if info.repeat:
            fw_freq = _repeat_freq_from_ranges(info.radio_freq, info.allowed_repeat_freqs)
            repeat_freq = fw_freq if fw_freq is not None else _off_grid_freq_for(info.radio_freq)
            self._freq_entry.set_text(format_number(repeat_freq, 3))
        elif info.radio_freq:
            self._freq_entry.set_text(format_number(info.radio_freq, 3))
        if info.radio_bw:
            self._bw_entry.set_text(format_number(info.radio_bw, 1))
        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._freq_entry.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 platform location service to get the computer's location and fill lat/lon fields."""
        from meshy.location import LocationService

        btn.set_sensitive(False)

        def _on_location(lat, lon, error):
            if error:
                self._window.show_toast(_("Location unavailable"))
            else:
                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"))
            btn.set_sensitive(True)

        LocationService.get_location(_on_location)

    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_chip(name)

    def _on_region_icon_press(self, _entry, icon_pos):
        if icon_pos == Gtk.EntryIconPosition.SECONDARY:
            self._on_discover_regions(None)

    def _on_add_region(self, _entry):
        name = self._region_entry.get_text().strip()
        if not name:
            return
        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_chip(name)
        self._region_entry.set_text("")

    def _add_region_chip(self, name: str):
        if name in self._region_rows:
            return
        content = Gtk.Box(spacing=4)
        content.append(Gtk.Label(label=name))
        content.append(Gtk.Image(icon_name="window-close-symbolic"))
        chip = Gtk.Button(child=content)
        chip.add_css_class("flat")
        chip.add_css_class("region-chip")
        chip.connect("clicked", lambda *_, n=name: self._remove_region(n))
        self._regions_wrapbox.append(chip)
        self._region_rows[name] = chip
        self._scope_model.append(name)
        self._regions_wrapbox.set_visible(True)

    def _remove_region(self, name: str):
        chip = self._region_rows.pop(name, None)
        if chip:
            self._regions_wrapbox.remove(chip)
            if not self._region_rows:
                self._regions_wrapbox.set_visible(False)
        # 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_default_flood_scope

            self._window.send_frame(build_set_default_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):
        """Broadcast NODE_DISCOVER_REQ, then query responding repeaters for regions."""
        from types import SimpleNamespace

        from meshy.models import ContactType

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

        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/discover-regions-dialog.ui"
        )
        dialog = builder.get_object("dialog")
        spinner = builder.get_object("spinner")
        status_label = builder.get_object("status_label")
        list_box = builder.get_object("list_box")
        add_btn = builder.get_object("add_btn")

        status_label.set_label(_("Discovering repeaters in range..."))

        discovered_regions: dict[str, Gtk.CheckButton] = {}
        region_rows: dict[str, Adw.ActionRow] = {}
        region_sources: dict[str, list[str]] = {}
        seen_repeaters: set[str] = set()
        pending_regions = [0]
        finished = [False]
        query_queue: list[tuple[str, str]] = []
        query_timer_id = [0]

        def _check_finished():
            if pending_regions[0] <= 0 and finished[0]:
                spinner.set_spinning(False)
                spinner.set_visible(False)
                count = len(discovered_regions)
                if count:
                    status_label.set_label(
                        _("Found {} regions").format(count)
                        if count != 1
                        else _("Found {} region").format(count)
                    )
                    add_btn.set_label(_("Add Selected"))
                    add_btn.set_sensitive(True)
                else:
                    if self._window._table_full_error:
                        status_label.set_label(
                            _(
                                "Contact list on the device is full. Remove some contacts to discover regions."
                            )
                        )
                    elif seen_repeaters:
                        status_label.set_label(_("No regions found"))
                    else:
                        status_label.set_label(_("No repeaters in range"))
                    add_btn.set_label(_("Retry"))
                    add_btn.set_sensitive(True)

        def _on_region_response(repeater_name, region_text):
            def _update_ui():
                pending_regions[0] -= 1
                if region_text:
                    for line in region_text.replace(",", "\n").split("\n"):
                        name = line.strip().strip("#")
                        if not name or name == "*":
                            continue
                        if name in discovered_regions:
                            sources = region_sources.get(name, [])
                            if repeater_name not in sources:
                                sources.append(repeater_name)
                                subtitle = _("from {}").format(", ".join(sources))
                                if name in self._region_rows:
                                    subtitle += " " + _("(already added)")
                                region_rows[name].set_subtitle(subtitle)
                            continue
                        already_added = name in self._region_rows
                        check = Gtk.CheckButton(active=not already_added)
                        check.set_valign(Gtk.Align.CENTER)
                        region_sources[name] = [repeater_name]
                        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_regions[name] = check
                        region_rows[name] = row
                _check_finished()

            GLib.idle_add(_update_ui)

        def _send_region_query(pub_key_hex, repeater_name):
            contact = self._window.find_contact_by_key_prefix(pub_key_hex)
            if not contact:
                pub_key = bytes.fromhex(pub_key_hex)
                contact = SimpleNamespace(
                    public_key=pub_key,
                    public_key_hex=pub_key_hex,
                )
            name = getattr(contact, "name", None) or repeater_name
            pending_regions[0] += 1
            responded = [False]

            def _handle_response(text):
                if responded[0]:
                    return
                responded[0] = True
                _on_region_response(name, text)

            self._window.request_regions(
                contact,
                lambda text: _handle_response(text),
            )

            def _retry():
                if responded[0]:
                    return False
                self._window.request_regions(
                    contact,
                    lambda text: _handle_response(text),
                )
                return False

            def _give_up():
                _handle_response(None)
                return False

            GLib.timeout_add(5000, _retry)
            GLib.timeout_add(10000, _give_up)

        def _process_query_queue():
            if not query_queue:
                query_timer_id[0] = 0
                return False
            pub_key_hex, repeater_name = query_queue.pop(0)
            _send_region_query(pub_key_hex, repeater_name)
            if not query_queue:
                query_timer_id[0] = 0
                return False
            return True

        def _enqueue_repeater(pub_key_hex, repeater_name):
            if pub_key_hex in seen_repeaters:
                return
            seen_repeaters.add(pub_key_hex)
            query_queue.append((pub_key_hex, repeater_name))
            status_label.set_label(
                _("Querying {} repeaters...").format(len(seen_repeaters))
                if len(seen_repeaters) != 1
                else _("Querying {} repeater...").format(len(seen_repeaters))
            )
            if not query_timer_id[0]:
                _process_query_queue()
                if query_queue:
                    query_timer_id[0] = GLib.timeout_add(1000, _process_query_queue)

        def _on_discover_response(result):
            node_type = result.get("node_type", 0)
            if node_type not in (ContactType.REPEATER, ContactType.ROOM):
                return
            pub_key_hex = result.get("pub_key_hex", "")
            if not pub_key_hex:
                return
            contact = self._window.find_contact_by_key_prefix(pub_key_hex)
            name = contact.name if contact else f"0x{pub_key_hex[:12].upper()}"
            GLib.idle_add(lambda: _enqueue_repeater(pub_key_hex, name) or False)

        def _on_timeout():
            self._window.stop_discover()
            self._window.set_advert_listener(None)
            finished[0] = True
            _check_finished()
            return False

        def _on_advert(contact):
            if contact.type not in (ContactType.REPEATER, ContactType.ROOM):
                return
            GLib.idle_add(
                lambda: _enqueue_repeater(contact.public_key_hex, contact.name) or False,
            )

        def _start_discover():
            discovered_regions.clear()
            region_rows.clear()
            region_sources.clear()
            seen_repeaters.clear()
            pending_regions[0] = 0
            finished[0] = False
            self._window._table_full_error = False
            query_queue.clear()
            if query_timer_id[0]:
                GLib.source_remove(query_timer_id[0])
                query_timer_id[0] = 0

            clear_children(list_box)

            spinner.set_visible(True)
            spinner.set_spinning(True)
            status_label.set_label(_("Discovering repeaters in range..."))
            add_btn.set_label(_("Add Selected"))
            add_btn.set_sensitive(False)

            self._window.set_advert_listener(_on_advert)
            self._window.send_discover_req(_on_discover_response)
            GLib.timeout_add(
                200,
                lambda: self._window.send_advert(flood=False, silent=True) or False,
            )
            GLib.timeout_add(
                3000,
                lambda: self._window.send_discover_req(_on_discover_response) or False,
            )
            GLib.timeout_add(
                6000,
                lambda: self._window.send_discover_req(_on_discover_response) or False,
            )
            GLib.timeout_add_seconds(15, _on_timeout)

        def _on_dialog_closed(d):
            self._window.stop_discover()
            self._window.set_advert_listener(None)
            query_queue.clear()
            if query_timer_id[0]:
                GLib.source_remove(query_timer_id[0])
                query_timer_id[0] = 0

        dialog.connect("closed", _on_dialog_closed)
        _start_discover()

        def _on_add(*_args):
            if not discovered_regions:
                _start_discover()
                return
            count = 0
            for name, check in discovered_regions.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_chip(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 ─────────────────────────────────────

    _SECTION_KEYS = [
        "device_settings",
        "contacts",
        "channels",
        "messages",
        "regions",
        "discovered_contacts",
        "path_history",
        "local_settings",
        "device_identity",
    ]

    _SECTION_GROUPS = {
        "group_settings": ["device_settings", "local_settings", "regions"],
        "group_contacts": ["contacts", "discovered_contacts"],
        "group_messages": ["messages", "path_history"],
    }

    @staticmethod
    def _connect_group_checks(builder, groups):
        for group_id, child_keys in groups.items():
            group_cb = builder.get_object(f"check_{group_id}")
            children = [builder.get_object(f"check_{k}") for k in child_keys]

            def _on_group_toggled(cb, _pspec, kids=children):
                active = cb.get_active()
                for kid in kids:
                    kid.set_active(active)

            def _on_child_toggled(cb, group=group_cb, kids=children):
                if not cb.get_active():
                    if all(not k.get_active() for k in kids):
                        group.set_active(False)
                elif not group.get_active():
                    group.set_active(True)

            group_cb.connect("notify::active", _on_group_toggled)
            for child in children:
                child.connect("toggled", _on_child_toggled)

    def _on_manual_backup(self, *args):
        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

        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/manual-backup-dialog.ui"
        )
        dialog = builder.get_object("manual_backup_dialog")
        checks = {key: builder.get_object(f"check_{key}") for key in self._SECTION_KEYS}
        self._connect_group_checks(builder, self._SECTION_GROUPS)

        def on_export(*_args):
            sections = {k for k, cb in checks.items() if cb.get_active()}
            if not sections:
                return
            dialog.close()
            self._save_backup_to_file(sections)

        builder.get_object("export_btn").connect("clicked", on_export)
        dialog.present(self._window)

    def _save_backup_to_file(self, sections: set[str]):
        import json
        from datetime import datetime

        backup = self._window.export_backup(sections=sections)

        def _do_save(backup_data):
            name = backup_data.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_data, f, indent=2, ensure_ascii=False)
                        self._window.show_toast(_("Backup exported successfully"))
                except GLib.Error:
                    pass
                except Exception as e:
                    self._window.show_toast(_("Export failed: {}").format(e), timeout=5)

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

        if "device_identity" in sections:

            def _on_key(key_bytes):
                if key_bytes:
                    backup["private_key"] = key_bytes.hex()
                else:
                    self._window.show_toast(_("Device does not support key export"), timeout=3)
                _do_save(backup)

            self._window.export_private_key(_on_key)
        else:
            _do_save(backup)

    def _on_auto_backup_settings(self, *args):
        settings = Gio.Settings.new("page.codeberg.sesivany.Meshy")

        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/auto-backup-dialog.ui"
        )
        dialog = builder.get_object("auto_backup_dialog")
        enable_switch = builder.get_object("enable_switch")
        checks = {key: builder.get_object(f"check_{key}") for key in self._SECTION_KEYS}

        enable_switch.set_active(settings.get_boolean("auto-backup"))
        saved_sections = set(settings.get_strv("auto-backup-sections"))
        for key, cb in checks.items():
            cb.set_active(key in saved_sections)
        self._connect_group_checks(builder, self._SECTION_GROUPS)

        def on_enable_changed(*_args):
            active = enable_switch.get_active()
            settings.set_boolean("auto-backup", active)
            if active and self._window.is_connected:
                self._window._auto_backup.schedule(self._window)
            elif not active:
                self._window._auto_backup.cancel()

        def on_section_changed(*_args):
            selected = [k for k, cb in checks.items() if cb.get_active()]
            settings.set_strv("auto-backup-sections", selected)

        enable_switch.connect("notify::active", on_enable_changed)
        for cb in checks.values():
            cb.connect("toggled", on_section_changed)

        dialog.present(self._window)

    def _on_import_backup(self, *args):
        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:
                    self._show_import_preview(gfile.get_path())
            except GLib.Error:
                pass

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

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

        device_addr = self._window._transport.connected_address
        backups = self._window._auto_backup.list_backups(device_addr)
        if not backups:
            dialog = Adw.AlertDialog(
                heading=_("No Auto Backups"),
                body=_("No automatic backups found for this device."),
            )
            dialog.add_response("ok", _("OK"))
            dialog.present(self._window)
            return

        dialog = Adw.AlertDialog(heading=_("Pick Snapshot"))
        dialog.add_response("cancel", _("Cancel"))

        listbox = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE)
        listbox.add_css_class("boxed-list")
        selected_path = [None]

        from meshy.views import _format_date_text

        for b in backups:
            row = Adw.ActionRow(
                title=_format_date_text(b["mtime_dt"]),
                subtitle=b["time"],
            )
            row._backup_path = b["path"]
            listbox.append(row)

        if listbox.get_row_at_index(0):
            listbox.select_row(listbox.get_row_at_index(0))

        def on_row_selected(lb, row):
            if row:
                selected_path[0] = row._backup_path

        listbox.connect("row-selected", on_row_selected)
        selected_path[0] = backups[0]["path"]

        dialog.add_response("restore", _("Restore"))
        dialog.set_response_appearance("restore", Adw.ResponseAppearance.SUGGESTED)
        dialog.set_extra_child(listbox)

        def on_response(d, response):
            if response == "restore" and selected_path[0]:
                self._show_import_preview(selected_path[0])

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

    def _show_import_preview(self, path: str):
        import json

        from meshy.backup import BackupManager

        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

        available = BackupManager.available_sections(backup)
        source_name = backup.get("name", _("Unknown"))

        builder = Gtk.Builder.new_from_resource(
            "/page/codeberg/sesivany/Meshy/ui/import-backup-dialog.ui"
        )
        dialog = builder.get_object("import_backup_dialog")
        checks = {key: builder.get_object(f"check_{key}") for key in self._SECTION_KEYS}
        self._connect_group_checks(builder, self._SECTION_GROUPS)

        builder.get_object("source_label").set_label(_("Source: {}").format(source_name))

        for key, cb in checks.items():
            if key not in available:
                cb.set_active(False)
                cb.set_sensitive(False)

        for group_id, child_keys in self._SECTION_GROUPS.items():
            if not any(k in available for k in child_keys):
                group_cb = builder.get_object(f"check_{group_id}")
                group_cb.set_active(False)
                group_cb.set_sensitive(False)

        if "channels" not in available:
            row = builder.get_object("row_channels")
            row.set_sensitive(False)

        if "device_identity" not in available:
            row = builder.get_object("row_device_identity")
            row.set_sensitive(False)

        def on_restore(*_args):
            sections = {k for k, cb in checks.items() if cb.get_active()}
            if not sections:
                return
            dialog.close()

            def _show_result(result):
                contacts, channels = result
                parts = []
                if contacts:
                    parts.append(_("{} contacts").format(contacts))
                if channels:
                    parts.append(_("{} channels").format(channels))
                if "messages" in sections:
                    n_messages = sum(len(msgs) for msgs in backup.get("messages", {}).values())
                    if n_messages:
                        parts.append(_("messages"))
                if "device_identity" in sections and backup.get("private_key"):
                    parts.append(_("device identity"))
                if parts:
                    self._window.show_toast(_("Imported: {}").format(", ".join(parts)), timeout=4)
                else:
                    self._window.show_toast(_("Nothing new to import"))

            self._window.import_backup(backup, sections=sections, on_complete=_show_result)

        builder.get_object("restore_btn").connect("clicked", on_restore)
        dialog.present(self._window)
