"""Main application window: monitor selection, dynamic controls, profile I/O."""

from __future__ import annotations

import datetime
from collections.abc import Callable
from pathlib import Path

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gio, GLib, Gtk  # noqa: E402

from . import __version__, ddc  # noqa: E402
from .categories import BRIGHTNESS, category_for, group_features  # noqa: E402
from .custom import (  # noqa: E402
    CustomControl,
    load_custom,
    parse_code,
    parse_value,
    save_custom,
)
from .i18n import _, ngettext  # noqa: E402
from .models import Access, Display, Feature  # noqa: E402
from .profile import Profile, build_profile, export_profile, load_profile  # noqa: E402
from .widgets import build_feature_row  # noqa: E402
from .worker import Worker  # noqa: E402

_JSON_FILTER_NAME = _("DDC profile (*.json)")


class MonitorWindow(Adw.ApplicationWindow):
    def __init__(self, app: Adw.Application) -> None:
        super().__init__(application=app, title=_("External Monitor Settings"))
        self.set_default_size(560, 720)

        self._worker = Worker()
        self._displays: list[Display] = []
        self._features: list[Feature] = []
        self._custom: list[CustomControl] = load_custom()
        self._current: Display | None = None
        self._busy = False
        self._show_advanced = False
        self._advanced_loaded = False  # full feature set fetched
        self._advanced_loading = False  # phase-2 fetch in flight
        self._advanced_widgets: list[Gtk.Widget] = []
        self._page: Adw.PreferencesPage | None = None
        self._load_gen = 0  # bumped per reload; stale worker callbacks are ignored

        self.connect("close-request", self._on_close)

        self._load_css()
        self._toasts = Adw.ToastOverlay()
        self.set_content(self._toasts)

        toolbar = Adw.ToolbarView()
        self._toasts.set_child(toolbar)

        header = Adw.HeaderBar()
        toolbar.add_top_bar(header)

        # Title widget: app name + current monitor name as subtitle (the latter
        # is the only on-screen monitor identity when there is a single display).
        self._title = Adw.WindowTitle(title=_("External Monitor Settings"), subtitle="")
        self._title.add_css_class("ddc-title")
        header.set_title_widget(self._title)

        # Monitor selector: only meaningful with more than one display.
        self._dropdown = Gtk.DropDown(model=Gtk.StringList())
        self._dropdown.connect("notify::selected", self._on_display_changed)
        header.pack_start(self._dropdown)

        self._refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic")
        self._refresh_btn.set_tooltip_text(_("Refresh"))
        self._refresh_btn.connect("clicked", lambda *_a: self._reload_features())
        header.pack_start(self._refresh_btn)

        header.pack_end(self._build_menu())

        self._stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE)
        toolbar.set_content(self._stack)
        self._stack.add_named(self._build_spinner(), "loading")
        self._stack.add_named(self._build_status(), "status")
        self._scroller = Gtk.ScrolledWindow(hexpand=True, vexpand=True)
        self._page_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self._scroller.set_child(self._page_box)
        self._stack.add_named(self._scroller, "content")

        self._install_actions()
        self._load_displays()

    # ----------------------------------------------------------------- build
    def _load_css(self) -> None:
        display = Gdk.Display.get_default()
        if display is None:
            return
        provider = Gtk.CssProvider()
        # The header subtitle (monitor name) is dimmed to ~55% opacity by the
        # theme; bring it back to full strength so it reads clearly.
        provider.load_from_string(".ddc-title .subtitle { opacity: 1; }")
        Gtk.StyleContext.add_provider_for_display(
            display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
        )

    def _build_menu(self) -> Gtk.MenuButton:
        menu = Gio.Menu()
        menu.append(_("Export configuration…"), "win.export")
        menu.append(_("Import configuration…"), "win.import")
        menu.append(_("Restore factory defaults"), "win.restore")
        menu.append(_("About"), "win.about")
        button = Gtk.MenuButton(icon_name="open-menu-symbolic", menu_model=menu)
        return button

    def _build_spinner(self) -> Gtk.Widget:
        box = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=12,
            valign=Gtk.Align.CENTER,
            halign=Gtk.Align.CENTER,
        )
        spinner = Gtk.Spinner(width_request=32, height_request=32)
        spinner.start()
        box.append(spinner)
        box.append(Gtk.Label(label=_("Reading monitor…")))
        return box

    def _build_status(self) -> Adw.StatusPage:
        self._status = Adw.StatusPage(
            icon_name="video-display-symbolic", title=_("No external monitor found"), description=""
        )
        return self._status

    def _install_actions(self) -> None:
        for name, handler in (
            ("export", self._on_export),
            ("import", self._on_import),
            ("restore", self._on_restore),
            ("about", self._on_about),
        ):
            action = Gio.SimpleAction.new(name, None)
            action.connect("activate", handler)
            self.add_action(action)

    # --------------------------------------------------------------- loading
    def _set_busy(self, busy: bool) -> None:
        self._busy = busy
        self._refresh_btn.set_sensitive(not busy)
        self._dropdown.set_sensitive(not busy and len(self._displays) > 1)

    def _update_header_title(self) -> None:
        # With several monitors the dropdown already names the current one, so the
        # subtitle is shown only when it is the sole on-screen monitor identity.
        name = self._current.name if self._current else ""
        self._title.set_subtitle("" if len(self._displays) > 1 else name)

    def _show_error(self, title: str, detail: str) -> None:
        self._status.set_title(title)
        self._status.set_description(detail)
        self._stack.set_visible_child_name("status")

    def _toast(self, text: str) -> None:
        self._toasts.add_toast(Adw.Toast(title=text, timeout=3))

    def _load_displays(self) -> None:
        if not ddc.have_ddcutil():
            self._show_error(_("ddcutil not found"), _("Install ddcutil to control monitors."))
            return
        self._stack.set_visible_child_name("loading")
        self._set_busy(True)
        self._worker.submit(ddc.list_displays, self._on_displays_loaded, self._on_load_error)

    def _on_displays_loaded(self, displays: list[Display]) -> None:
        self._displays = displays
        model = Gtk.StringList()
        for display in displays:
            model.append(display.label)
        self._dropdown.set_model(model)
        self._dropdown.set_visible(len(displays) > 1)
        if not displays:
            self._set_busy(False)
            self._show_error(
                _("No external monitor found"),
                _(
                    "This app adjusts external monitors — the ones plugged in with a "
                    "cable (HDMI, DisplayPort, DVI or VGA). It controls them through that "
                    "cable, just like the buttons on the monitor itself, so a laptop's "
                    "built-in screen cannot be adjusted here.\n\n"
                    "If you do have an external monitor connected, open its on-screen menu "
                    "using the buttons on the monitor and turn on the setting called "
                    "“DDC/CI”, then press Refresh. Some docks, adapters or switch boxes "
                    "can also block this connection."
                ),
            )
            return
        self._dropdown.set_selected(0)
        self._reload_features()

    def _on_load_error(self, exc: Exception) -> None:
        self._set_busy(False)
        self._show_error(_("Could not read monitors"), str(exc))

    def _on_display_changed(self, *_a: object) -> None:
        if self._busy:
            return
        self._reload_features()

    def _selected_display(self) -> Display | None:
        index = self._dropdown.get_selected()
        if 0 <= index < len(self._displays):
            return self._displays[index]
        return None

    def _reload_features(self) -> None:
        display = self._selected_display()
        if display is None:
            return
        self._current = display
        self._update_header_title()
        self._stack.set_visible_child_name("loading")
        self._set_busy(True)
        self._show_advanced = False
        self._advanced_loaded = False
        self._advanced_loading = False
        self._load_gen += 1
        gen = self._load_gen
        number = display.number
        # Phase 1 (fast): brightness/contrast only, shown immediately. The slow full
        # load runs only when the user opens the advanced section, so the worker stays
        # free for brightness/contrast changes.
        self._worker.submit(
            lambda: ddc.build_basic_features(number),
            lambda feats: self._on_basic_loaded(gen, feats),
            lambda exc: self._on_load_error(exc),
        )

    def _on_basic_loaded(self, gen: int, basic_features: list[Feature]) -> None:
        if gen != self._load_gen:
            return
        self._features = basic_features
        self._render_basic(basic_features)
        self._stack.set_visible_child_name("content")
        self._set_busy(False)

    def _load_full(self, then: Callable[[], None] | None = None) -> None:
        """Phase 2 (slow): fetch every feature, on demand. ``then`` runs after."""
        if self._advanced_loaded:
            if then is not None:
                then()
            return
        if self._advanced_loading or self._current is None:
            return
        self._advanced_loading = True
        self._advanced_button.set_sensitive(False)
        self._advanced_button.set_title(self._advanced_button_label())
        self._advanced_spinner.set_visible(True)
        gen = self._load_gen
        number = self._current.number
        self._worker.submit(
            lambda: ddc.build_features(number),
            lambda result: self._on_full_loaded(gen, result, then),
            lambda exc: self._on_full_error(gen, exc),
        )

    def _on_full_loaded(
        self, gen: int, result: tuple[list[Feature], str], then: Callable[[], None] | None
    ) -> None:
        if gen != self._load_gen:
            return
        features, mccs = result
        self._features = features
        self._advanced_loaded = True
        self._advanced_loading = False
        if self._current is not None and mccs:
            self._current = Display(
                number=self._current.number,
                bus=self._current.bus,
                drm_connector=self._current.drm_connector,
                mfg=self._current.mfg,
                model=self._current.model,
                serial=self._current.serial,
                vcp_version=mccs,
            )
            self._update_header_title()
        self._advanced_spinner.set_visible(False)
        self._populate_advanced(features)
        self._advanced_button.set_sensitive(True)
        self._advanced_button.set_title(self._advanced_button_label())
        if then is not None:
            then()

    def _on_full_error(self, gen: int, exc: Exception) -> None:
        if gen != self._load_gen:
            return
        # Brightness/contrast stay usable; allow another attempt.
        self._advanced_loading = False
        self._advanced_spinner.set_visible(False)
        self._advanced_button.set_sensitive(True)
        self._advanced_button.set_title(self._advanced_button_label())
        self._toast(str(exc))

    def _feature_group(
        self, title: str, features: list[Feature], *, basic: bool
    ) -> Adw.PreferencesGroup:
        # Codes are hidden in the basic section, shown in the advanced one (where
        # the custom-control feature makes them useful).
        group = Adw.PreferencesGroup(title=GLib.markup_escape_text(_(title), -1))
        for feature in features:
            group.add(
                build_feature_row(feature, self._on_change, self._on_action, show_code=not basic)
            )
        return group

    def _render_basic(self, basic_features: list[Feature]) -> None:
        """Phase 1: show Brightness & Contrast and the (still-loading) advanced toggle."""
        child = self._page_box.get_first_child()
        while child is not None:
            nxt = child.get_next_sibling()
            self._page_box.remove(child)
            child = nxt

        page = Adw.PreferencesPage()
        self._page = page
        self._advanced_widgets = []
        for title, features in group_features(basic_features):
            page.add(self._feature_group(title, features, basic=True))
        page.add(self._build_advanced_toggle())
        self._page_box.append(page)

    def _populate_advanced(self, features: list[Feature]) -> None:
        """Phase 2: append every non-basic group (hidden) and enable the toggle."""
        page = self._page
        if page is None:
            return
        for title, group_list in group_features(features):
            if title == BRIGHTNESS:
                continue
            group = self._feature_group(title, group_list, basic=False)
            group.set_visible(self._show_advanced)
            page.add(group)
            self._advanced_widgets.append(group)

        custom = self._build_custom_group()
        custom.set_visible(self._show_advanced)
        page.add(custom)
        self._advanced_widgets.append(custom)

    def _rerender(self) -> None:
        # Full rebuild after a custom-control edit (everything is already loaded).
        basic = [f for f in self._features if category_for(f) == BRIGHTNESS]
        self._render_basic(basic)
        self._populate_advanced(self._features)

    def _build_advanced_toggle(self) -> Adw.PreferencesGroup:
        group = Adw.PreferencesGroup(title=_("More monitor settings"))
        # Spinner in the group header: animates while the full set loads so the
        # ~10s fetch never looks frozen. Adw.Spinner spins only while visible.
        self._advanced_spinner = Adw.Spinner()
        self._advanced_spinner.set_size_request(18, 18)
        self._advanced_spinner.set_valign(Gtk.Align.CENTER)
        self._advanced_spinner.set_visible(self._advanced_loading)
        group.set_header_suffix(self._advanced_spinner)
        group.add(self._build_caution_row())
        self._advanced_button = Adw.ButtonRow(title=self._advanced_button_label())
        self._advanced_button.set_sensitive(not self._advanced_loading)
        self._advanced_button.connect("activated", lambda _r: self._toggle_advanced())
        group.add(self._advanced_button)
        return group

    def _build_caution_row(self) -> Gtk.Widget:
        # A plain full-opacity label (not the theme's dimmed group description) plus
        # a warning icon, so the safety notice is clearly legible.
        box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        box.set_margin_top(12)
        box.set_margin_bottom(12)
        box.set_margin_start(12)
        box.set_margin_end(12)
        icon = Gtk.Image.new_from_icon_name("dialog-warning-symbolic")
        icon.add_css_class("warning")
        icon.set_valign(Gtk.Align.START)
        label = Gtk.Label(
            label=_(
                "These settings control the monitor directly, with more access than its "
                "own buttons. A few advanced options — and custom commands — can leave the "
                "screen wrong in a way that is hard to undo, and in rare cases can damage "
                "the monitor permanently. Change only the settings you understand."
            ),
            wrap=True,
            xalign=0,
            hexpand=True,
        )
        box.append(icon)
        box.append(label)
        return box

    def _advanced_button_label(self) -> str:
        if self._advanced_loading:
            return _("Loading the other settings…")
        if self._advanced_loaded and self._show_advanced:
            return _("Hide advanced settings")
        return _("Show advanced settings")

    def _toggle_advanced(self) -> None:
        if self._advanced_loading:
            return
        if not self._advanced_loaded:
            # First open: fetch the full set on demand, then reveal it.
            self._load_full(then=self._reveal_advanced)
            return
        self._show_advanced = not self._show_advanced
        for widget in self._advanced_widgets:
            widget.set_visible(self._show_advanced)
        self._advanced_button.set_title(self._advanced_button_label())

    def _reveal_advanced(self) -> None:
        self._show_advanced = True
        for widget in self._advanced_widgets:
            widget.set_visible(True)
        self._advanced_button.set_title(self._advanced_button_label())

    def _build_custom_group(self) -> Adw.PreferencesGroup:
        group = Adw.PreferencesGroup(
            title=_("Custom controls"),
            description=_(
                "For advanced users: send a command straight to the monitor using a "
                "feature code, for example a feature specific to your monitor's maker."
            ),
        )
        for index, ctl in enumerate(self._custom):
            row = Adw.ActionRow(
                title=GLib.markup_escape_text(ctl.label, -1),
                subtitle=f"setvcp 0x{ctl.code:02X} 0x{ctl.value:02X}",
            )
            remove = Gtk.Button(icon_name="user-trash-symbolic", valign=Gtk.Align.CENTER)
            remove.add_css_class("flat")
            remove.set_tooltip_text(_("Remove"))
            remove.connect("clicked", lambda _b, i=index: self._remove_custom(i))
            apply = Gtk.Button(label=_("Apply"), valign=Gtk.Align.CENTER)
            apply.add_css_class("flat")
            apply.connect("clicked", lambda _b, c=ctl: self._apply_custom(c))
            row.add_suffix(remove)
            row.add_suffix(apply)
            row.set_activatable_widget(apply)
            group.add(row)

        add_row = Adw.ButtonRow(title=_("Add custom control…"))
        add_row.connect("activated", lambda _r: self._add_custom_dialog())
        group.add(add_row)
        return group

    def _apply_custom(self, ctl: CustomControl) -> None:
        display = self._current
        if display is None:
            return
        number = display.number

        def job() -> None:
            ddc.set_value(
                number, ctl.code, ctl.value, continuous=False, verify=False, permit_unknown=True
            )

        def on_fail(exc: Exception) -> None:
            self._toast(_("{label} failed: {error}").format(label=ctl.label, error=exc))

        self._worker.submit(
            job,
            lambda _r: self._toast(_("Applied {label}").format(label=ctl.label)),
            on_fail,
        )

    def _remove_custom(self, index: int) -> None:
        if 0 <= index < len(self._custom):
            del self._custom[index]
            save_custom(self._custom)
            self._rerender()

    def _add_custom_dialog(self) -> None:
        code_row = Adw.EntryRow(title=_("Feature code (hex), e.g. E1"))
        value_row = Adw.EntryRow(title=_("Value, e.g. 0x03 or 3"))
        label_row = Adw.EntryRow(title=_("Label"))
        form = Adw.PreferencesGroup()
        for row in (code_row, value_row, label_row):
            form.add(row)

        dialog = Adw.AlertDialog(heading=_("Add custom control"), body="")
        dialog.set_extra_child(form)
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("add", _("Add"))
        dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED)
        dialog.set_default_response("add")
        dialog.set_close_response("cancel")

        def on_response(_dialog: Adw.AlertDialog, response: str) -> None:
            if response != "add":
                return
            try:
                code = parse_code(code_row.get_text())
                value = parse_value(value_row.get_text())
            except ValueError as exc:
                self._toast(_("Invalid input: {error}").format(error=exc))
                return
            label = label_row.get_text().strip() or f"VCP 0x{code:02X}"
            ctl = CustomControl(code=code, label=label, value=value)
            self._custom.append(ctl)
            save_custom(self._custom)
            self._rerender()
            self._apply_custom(ctl)

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

    # --------------------------------------------------------------- changes
    def _on_change(self, code: int, value: int, continuous: bool) -> None:
        display = self._current
        if display is None:
            return
        number = display.number
        manufacturer = code >= 0xE0

        def job() -> None:
            ddc.set_value(
                number,
                code,
                value,
                continuous=continuous,
                verify=not manufacturer,
                permit_unknown=manufacturer,
            )

        self._worker.submit(
            job,
            None,
            lambda exc: self._toast(
                _("Set {code} failed: {error}").format(code=f"0x{code:02X}", error=exc)
            ),
        )

    def _on_action(self, code: int) -> None:
        display = self._current
        if display is None:
            return
        number = display.number

        def job() -> None:
            ddc.set_value(number, code, 1, continuous=False)

        self._worker.submit(
            job,
            lambda _r: self._toast(_("Applied {code}").format(code=f"0x{code:02X}")),
            lambda exc: self._toast(
                _("Apply {code} failed: {error}").format(code=f"0x{code:02X}", error=exc)
            ),
        )

    # ---------------------------------------------------------------- export
    def _on_export(self, *_a: object) -> None:
        if self._current is None or self._busy:
            return
        if not self._advanced_loaded:
            # A profile must cover every feature, so load the full set first.
            self._load_full(then=self._on_export)
            return
        dialog = Gtk.FileDialog(title=_("Export configuration"), modal=True)
        dialog.set_initial_name(self._default_filename())
        dialog.set_filters(self._json_filters())
        dialog.save(self, None, self._export_chosen)

    def _default_filename(self) -> str:
        model = self._current.model if self._current else "monitor"
        safe = "".join(c if c.isalnum() else "-" for c in model).strip("-") or "monitor"
        return f"{safe}.ddc.json"

    def _export_chosen(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
        try:
            gfile = dialog.save_finish(result)
        except GLib.Error:
            return
        raw_path = gfile.get_path() if gfile is not None else None
        if raw_path is None or self._current is None:
            return
        path = Path(raw_path)
        display = self._current
        number = display.number
        stamp = datetime.datetime.now().astimezone().isoformat(timespec="seconds")

        def job() -> int:
            features = self._reread_rw(number)
            profile = build_profile(display, features, exported_at=stamp)
            export_profile(path, profile)
            return len(profile["values"])

        self._set_busy(True)
        self._worker.submit(job, self._on_export_done, self._on_io_error)

    def _reread_rw(self, number: int) -> list[Feature]:
        fresh: list[Feature] = []
        for feature in self._features:
            if feature.access is Access.READ_WRITE:
                value = ddc.get_value(number, feature.code)
                fresh.append(
                    Feature(
                        code=feature.code,
                        name=feature.name,
                        access=feature.access,
                        vtype=feature.vtype,
                        values=feature.values,
                        value=value,
                    )
                )
        return fresh

    def _on_export_done(self, count: int) -> None:
        self._set_busy(False)
        self._toast(ngettext("Exported %d setting", "Exported %d settings", count) % count)

    # ---------------------------------------------------------------- import
    def _on_import(self, *_a: object) -> None:
        if self._current is None or self._busy:
            return
        if not self._advanced_loaded:
            # Need the full RW feature set to know which entries can be applied.
            self._load_full(then=self._on_import)
            return
        dialog = Gtk.FileDialog(title=_("Import configuration"), modal=True)
        dialog.set_filters(self._json_filters())
        dialog.open(self, None, self._import_chosen)

    def _import_chosen(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
        try:
            gfile = dialog.open_finish(result)
        except GLib.Error:
            return
        raw_path = gfile.get_path() if gfile is not None else None
        if raw_path is None:
            return
        path = Path(raw_path)
        self._worker.submit(lambda: load_profile(path), self._on_profile_loaded, self._on_io_error)

    def _on_profile_loaded(self, profile: Profile) -> None:
        display = self._current
        if display is None:
            return
        if profile.monitor_id and profile.monitor_id != display.monitor_id:
            self._confirm(
                _("Different monitor"),
                _("Profile was saved for {saved}, current is {current}. Apply anyway?").format(
                    saved=profile.monitor_id, current=display.monitor_id
                ),
                _("Apply"),
                lambda: self._apply_profile(profile),
            )
        else:
            self._apply_profile(profile)

    def _apply_profile(self, profile: Profile) -> None:
        display = self._current
        if display is None:
            return
        number = display.number
        editable = {f.code for f in self._features if f.access is Access.READ_WRITE}
        entries = [e for e in profile.entries if e.code in editable]

        def job() -> int:
            for entry in entries:
                ddc.set_value(number, entry.code, entry.value, continuous=entry.continuous)
            return len(entries)

        self._set_busy(True)
        self._worker.submit(job, self._on_import_done, self._on_io_error)

    def _on_import_done(self, count: int) -> None:
        self._toast(ngettext("Imported %d setting", "Imported %d settings", count) % count)
        self._reload_features()

    def _on_io_error(self, exc: Exception) -> None:
        self._set_busy(False)
        self._toast(str(exc))

    # --------------------------------------------------------------- restore
    def _on_restore(self, *_a: object) -> None:
        if self._current is None or self._busy:
            return
        self._confirm(
            _("Restore factory defaults"),
            _("Reset all monitor settings to factory defaults?"),
            _("Restore"),
            self._do_restore,
        )

    def _do_restore(self) -> None:
        display = self._current
        if display is None:
            return
        number = display.number

        def job() -> None:
            ddc.set_value(number, 0x04, 1, continuous=False)

        self._set_busy(True)
        self._worker.submit(job, self._on_restore_done, self._on_io_error)

    def _on_restore_done(self, _result: object) -> None:
        self._toast(_("Factory defaults restored"))
        self._reload_features()

    # ----------------------------------------------------------------- about
    def _on_about(self, *_a: object) -> None:
        name = _("External Monitor Settings")
        icon = "org.biglinux.DdcMonitor"
        dev = "BigLinux"
        version = __version__
        comments = _("Adjust an external monitor's settings over DDC/CI, using ddcutil.")
        if hasattr(Adw, "AboutDialog"):
            dialog = Adw.AboutDialog(
                application_name=name,
                application_icon=icon,
                developer_name=dev,
                version=version,
                comments=comments,
            )
            dialog.present(self)
        else:
            window = Adw.AboutWindow(
                transient_for=self,
                application_name=name,
                application_icon=icon,
                developer_name=dev,
                version=version,
                comments=comments,
            )
            window.present()

    # ----------------------------------------------------------------- utils
    def _json_filters(self) -> Gio.ListStore[Gtk.FileFilter]:
        store: Gio.ListStore[Gtk.FileFilter] = Gio.ListStore(item_type=Gtk.FileFilter)
        filt = Gtk.FileFilter()
        filt.set_name(_JSON_FILTER_NAME)
        filt.add_pattern("*.json")
        store.append(filt)
        return store

    def _confirm(
        self, heading: str, body: str, action_label: str, on_yes: Callable[[], None]
    ) -> None:
        dialog = Adw.AlertDialog(heading=heading, body=body)
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("ok", action_label)
        dialog.set_response_appearance("ok", Adw.ResponseAppearance.DESTRUCTIVE)
        dialog.set_default_response("cancel")
        dialog.set_close_response("cancel")

        def on_response(_dialog: Adw.AlertDialog, response: str) -> None:
            if response == "ok":
                on_yes()

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

    def _on_close(self, *_a: object) -> bool:
        self._worker.stop()
        return False
