from __future__ import annotations

from pathlib import Path
from typing import Callable

import gi

gi.require_version("Gtk", "4.0")

from gi.repository import Gdk, Gio, GLib, Gtk  # noqa: E402

from . import i18n, x11_overlay
from .config import (
    APP_ICON_PATH,
    EXAMPLE_CONFIG_PATH,
    AppConfig,
    OverlayConfig,
    TitleConfig,
    delete_profile,
    duplicate_profile,
    list_profiles,
    load_config,
    profile_path,
    rename_profile,
    save_config,
    save_language,
    set_active_profile,
)
from .hotkey_capture import capture_next_combo
from .i18n import t

_ANCHORS = ["top-left", "top-right", "bottom-left", "bottom-right"]
_DEFAULT_TITLE_COLOR = "#4da6ff"

# Card look for each title row, and a bit more breathing room than the
# theme default between the header/content rows within a section. Uses a
# neutral gray at low alpha rather than a @theme_* named color - COSMIC's
# own GTK theme was observed not to define the usual Adwaita palette names,
# so a plain rgba() that just nudges the background either way (lighter on
# dark themes, darker on light ones) is the safer cross-theme choice.
_CSS = """
.title-row {
    background-color: rgba(127, 127, 127, 0.09);
    border-radius: 8px;
    padding: 8px 10px;
}
.title-row-header {
    margin-left: 10px;
    margin-right: 10px;
}
"""


def _prompt_name(
    parent: Gtk.Window, title: str, initial: str, on_done: Callable[[str | None], None]
) -> None:
    """Small modal text-entry dialog - GTK4 dropped the convenience input
    dialog GTK3 had, so this replaces it for new/rename/duplicate profile
    names. Calls on_done(None) on cancel, on_done(text) on confirm.
    """
    dialog = Gtk.Window(transient_for=parent, modal=True, title=title, resizable=False)
    dialog.set_default_size(320, -1)
    box = Gtk.Box(
        orientation=Gtk.Orientation.VERTICAL,
        spacing=10,
        margin_top=12,
        margin_bottom=12,
        margin_start=12,
        margin_end=12,
    )
    dialog.set_child(box)

    entry = Gtk.Entry(text=initial)
    entry.set_activates_default(True)
    box.append(entry)

    button_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6, halign=Gtk.Align.END)
    box.append(button_row)
    cancel_button = Gtk.Button(label=t("cancel"))
    ok_button = Gtk.Button(label=t("ok"))
    ok_button.add_css_class("suggested-action")
    button_row.append(cancel_button)
    button_row.append(ok_button)
    dialog.set_default_widget(ok_button)

    def finish(result: str | None) -> None:
        dialog.close()
        on_done(result)

    cancel_button.connect("clicked", lambda _b: finish(None))
    ok_button.connect("clicked", lambda _b: finish(entry.get_text()))
    dialog.present()
    entry.grab_focus()


def _confirm(
    parent: Gtk.Window, title: str, message: str, on_done: Callable[[bool], None]
) -> None:
    dialog = Gtk.Window(transient_for=parent, modal=True, title=title, resizable=False)
    dialog.set_default_size(320, -1)
    box = Gtk.Box(
        orientation=Gtk.Orientation.VERTICAL,
        spacing=10,
        margin_top=12,
        margin_bottom=12,
        margin_start=12,
        margin_end=12,
    )
    dialog.set_child(box)
    box.append(Gtk.Label(label=message, wrap=True, xalign=0))

    button_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6, halign=Gtk.Align.END)
    box.append(button_row)
    cancel_button = Gtk.Button(label=t("cancel"))
    confirm_button = Gtk.Button(label=t("delete"))
    confirm_button.add_css_class("destructive-action")
    button_row.append(cancel_button)
    button_row.append(confirm_button)

    def finish(result: bool) -> None:
        dialog.close()
        on_done(result)

    cancel_button.connect("clicked", lambda _b: finish(False))
    confirm_button.connect("clicked", lambda _b: finish(True))
    dialog.present()


def _color_to_hex(rgba: Gdk.RGBA) -> str:
    return "#{:02x}{:02x}{:02x}".format(
        round(rgba.red * 255), round(rgba.green * 255), round(rgba.blue * 255)
    )


def _format_hotkey(combo: tuple[str, ...]) -> str:
    return " + ".join(key.removeprefix("KEY_") for key in combo)


class _TitleRow(Gtk.Box):
    """One editable title: id/name/hotkey/cooldown/remove on the first line,
    pre-activation hotkey/color/icon on the second.
    """

    def __init__(self, title: TitleConfig, on_remove):
        super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        self.add_css_class("title-row")
        self._icon_path: str | None = title.icon
        self._hotkey: tuple[str, ...] = title.hotkey
        self._pre_hotkey: tuple[str, ...] | None = title.pre_hotkey

        line1 = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        line2 = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        self.append(line1)
        self.append(line2)

        self.id_entry = Gtk.Entry(text=title.id, placeholder_text="id", width_chars=8)
        self.name_entry = Gtk.Entry(text=title.name, placeholder_text="Name", hexpand=True)

        self.hotkey_button = Gtk.Button()
        self.hotkey_button.set_size_request(130, -1)
        self.hotkey_button.set_tooltip_text(t("hotkey_tooltip"))
        self.hotkey_button.connect("clicked", self._on_pick_hotkey)
        self._update_hotkey_button_label()

        self.cooldown_spin = Gtk.SpinButton.new_with_range(1, 3600, 1)
        self.cooldown_spin.set_digits(1)
        self.cooldown_spin.set_value(title.cooldown_seconds)

        self.restart_check = Gtk.CheckButton(label=t("restart_on_repeat"))
        self.restart_check.set_active(title.restart_on_repeat)
        self.restart_check.set_tooltip_text(t("restart_on_repeat_tooltip"))

        remove_button = Gtk.Button(label="✕")
        remove_button.add_css_class("flat")
        remove_button.set_tooltip_text(t("remove_title"))
        remove_button.connect("clicked", lambda _b: on_remove(self))

        for widget in (
            self.id_entry,
            self.name_entry,
            self.hotkey_button,
            self.cooldown_spin,
            self.restart_check,
            remove_button,
        ):
            line1.append(widget)

        line2.append(Gtk.Label(label=t("pre_activation_label")))

        self.pre_hotkey_button = Gtk.Button()
        self.pre_hotkey_button.set_size_request(130, -1)
        self.pre_hotkey_button.set_tooltip_text(t("pre_hotkey_tooltip"))
        self.pre_hotkey_button.connect("clicked", self._on_pick_pre_hotkey)
        self._update_pre_hotkey_button_label()
        line2.append(self.pre_hotkey_button)

        pre_hotkey_clear_button = Gtk.Button(label="✕")
        pre_hotkey_clear_button.add_css_class("flat")
        pre_hotkey_clear_button.set_tooltip_text(t("remove_pre_activation"))
        pre_hotkey_clear_button.connect("clicked", self._on_clear_pre_hotkey)
        line2.append(pre_hotkey_clear_button)

        line2.append(Gtk.Label(label=t("timeout_label")))
        self.standby_timeout_spin = Gtk.SpinButton.new_with_range(0, 300, 1)
        self.standby_timeout_spin.set_digits(0)
        self.standby_timeout_spin.set_value(title.standby_timeout_seconds or 0)
        self.standby_timeout_spin.set_tooltip_text(t("standby_timeout_tooltip"))
        line2.append(self.standby_timeout_spin)

        color_dialog = Gtk.ColorDialog()
        self.color_button = Gtk.ColorDialogButton(dialog=color_dialog)
        rgba = Gdk.RGBA()
        rgba.parse(title.color or _DEFAULT_TITLE_COLOR)
        self.color_button.set_rgba(rgba)
        line2.append(self.color_button)

        self.icon_button = Gtk.Button()
        self.icon_button.set_size_request(90, -1)
        self.icon_button.connect("clicked", self._on_pick_icon)
        self._update_icon_button_label()
        line2.append(self.icon_button)

        icon_clear_button = Gtk.Button(label="✕")
        icon_clear_button.add_css_class("flat")
        icon_clear_button.set_tooltip_text(t("remove_icon"))
        icon_clear_button.connect("clicked", self._on_clear_icon)
        line2.append(icon_clear_button)

    def _update_hotkey_button_label(self) -> None:
        self.hotkey_button.set_label(_format_hotkey(self._hotkey) if self._hotkey else t("choose_key"))

    def _on_pick_hotkey(self, _button) -> None:
        self.hotkey_button.set_sensitive(False)
        self.hotkey_button.set_label(t("press_keys"))
        capture_next_combo(self._on_hotkey_captured)

    def _on_hotkey_captured(self, combo: tuple[str, ...] | None) -> None:
        def apply() -> bool:
            if combo is not None:
                self._hotkey = combo
            self.hotkey_button.set_sensitive(True)
            self._update_hotkey_button_label()
            return GLib.SOURCE_REMOVE

        # capture_next_combo calls back from its own background thread.
        GLib.idle_add(apply)

    def _update_pre_hotkey_button_label(self) -> None:
        self.pre_hotkey_button.set_label(
            _format_hotkey(self._pre_hotkey) if self._pre_hotkey else t("none")
        )

    def _on_pick_pre_hotkey(self, _button) -> None:
        self.pre_hotkey_button.set_sensitive(False)
        self.pre_hotkey_button.set_label(t("press_keys"))
        capture_next_combo(self._on_pre_hotkey_captured)

    def _on_pre_hotkey_captured(self, combo: tuple[str, ...] | None) -> None:
        def apply() -> bool:
            if combo is not None:
                self._pre_hotkey = combo
            self.pre_hotkey_button.set_sensitive(True)
            self._update_pre_hotkey_button_label()
            return GLib.SOURCE_REMOVE

        GLib.idle_add(apply)

    def _on_clear_pre_hotkey(self, _button) -> None:
        self._pre_hotkey = None
        self._update_pre_hotkey_button_label()

    def _update_icon_button_label(self) -> None:
        self.icon_button.set_label(Path(self._icon_path).name if self._icon_path else t("icon_button_label"))
        self.icon_button.set_tooltip_text(self._icon_path or t("no_icon_selected"))

    def _on_pick_icon(self, _button) -> None:
        dialog = Gtk.FileDialog()
        image_filter = Gtk.FileFilter()
        image_filter.set_name(t("images_filter"))
        image_filter.add_pixbuf_formats()
        filters = Gio.ListStore.new(Gtk.FileFilter)
        filters.append(image_filter)
        dialog.set_filters(filters)
        dialog.open(self.get_root(), None, self._on_icon_chosen)

    def _on_icon_chosen(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
        try:
            file = dialog.open_finish(result)
        except GLib.Error:
            return  # user cancelled
        if file is not None:
            self._icon_path = file.get_path()
            self._update_icon_button_label()

    def _on_clear_icon(self, _button) -> None:
        self._icon_path = None
        self._update_icon_button_label()

    def to_config(self) -> TitleConfig:
        return TitleConfig(
            id=self.id_entry.get_text().strip(),
            name=self.name_entry.get_text().strip(),
            hotkey=self._hotkey,
            cooldown_seconds=self.cooldown_spin.get_value(),
            color=_color_to_hex(self.color_button.get_rgba()),
            pre_hotkey=self._pre_hotkey,
            standby_timeout_seconds=self.standby_timeout_spin.get_value() or None,
            restart_on_repeat=self.restart_check.get_active(),
            icon=self._icon_path,
        )


class SettingsWindow(Gtk.ApplicationWindow):
    """GTK4 editor for config.json - character profiles, titles, hotkeys,
    cooldowns, colors, dogma mode, and overlay anchor/margin/scale.

    Normally runs right alongside the overlay (see __main__.py); `on_saved`,
    if given, is called with the newly saved AppConfig and its path right
    after a successful save (or a profile switch/new/rename/duplicate,
    which reload from disk the same way), so the running overlay can pick
    up the change immediately instead of needing a restart.

    `profile_name` is the active character profile, or None if the app was
    launched with an explicit `--config` path that bypasses the profile
    system entirely - in that case the profile-management row is hidden,
    since there's no profile concept to manage.
    """

    def __init__(
        self,
        app: Gtk.Application,
        config: AppConfig,
        config_path: Path,
        profile_name: str | None = None,
        on_saved: Callable[[AppConfig, Path], None] | None = None,
    ):
        super().__init__(application=app, title=t("settings_title"))
        self._config_path = config_path
        self._profile_name = profile_name
        self._on_saved = on_saved
        self._overlay_x = config.overlay.x
        self._overlay_y = config.overlay.y
        self.set_default_size(760, 860)

        self.connect("map", self._on_map)
        self._setup_css()
        self._build_ui()
        self._populate_from_config(config)

    def _on_map(self, _widget) -> None:
        # Must run on "map", not "realize" - GTK4 sets (or clears)
        # _NET_WM_ICON itself around map time based on its own desktop-file
        # icon-theme lookup, which finds nothing useful for an unintegrated
        # AppImage run and wipes out whatever was set earlier. Same ordering
        # requirement as _NET_WM_STATE_ABOVE in x11_overlay.raise_above()/
        # make_overlay(), for an analogous reason (Mutter there instead of
        # GTK itself, but same "something else touches this after realize,
        # so we have to go last" shape).
        xid = x11_overlay.get_xid(self)
        d = x11_overlay.connect()
        x11_overlay.set_wm_icon(d, xid, str(APP_ICON_PATH))

    def _setup_css(self) -> None:
        # Registered once against the display, not per _build_ui() call -
        # CSS class definitions are global, only the widgets carrying
        # "title-row" get rebuilt on a language switch/profile reload.
        provider = Gtk.CssProvider()
        provider.load_from_string(_CSS)
        Gtk.StyleContext.add_provider_for_display(
            Gdk.Display.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
        )

    def _build_ui(self) -> None:
        """(Re-)builds the entire widget tree from scratch.

        Called once from __init__, and again from _on_language_changed to
        pick up new label/tooltip text - simplest way to re-translate
        everything without hand-tracking every widget that holds
        translated text. Replaces `self`'s child in place rather than
        creating a new window, so external wiring (app.add_window,
        "close-request") stays intact. Caller must follow up with
        _populate_from_config to restore values into the fresh widgets.
        """
        root = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=14,
            margin_top=14,
            margin_bottom=14,
            margin_start=14,
            margin_end=14,
        )
        self.set_child(root)

        # -- General: language, character profile, dogma mode --
        general_box = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=8,
            margin_top=8,
            margin_bottom=8,
            margin_start=8,
            margin_end=8,
        )
        general_frame = Gtk.Frame(label=t("general_header"))
        general_frame.set_child(general_box)
        root.append(general_frame)

        language_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        language_row.append(Gtk.Label(label=t("language_label")))
        # Language names are proper nouns, not translated by current
        # language - "Deutsch"/"English" stay put either way so the option
        # is always readable regardless of which one is currently active.
        self._suppress_language_switch = True
        self.language_dropdown = Gtk.DropDown.new_from_strings(["Deutsch", "English"])
        self.language_dropdown.set_selected(0 if i18n.get_language() == "de" else 1)
        self._suppress_language_switch = False
        self.language_dropdown.connect("notify::selected", self._on_language_changed)
        language_row.append(self.language_dropdown)
        general_box.append(language_row)

        self.profile_dropdown: Gtk.DropDown | None = None
        self._suppress_profile_switch = False
        if self._profile_name is not None:
            general_box.append(self._build_profile_row())
            self._refresh_profile_dropdown()
        self._update_window_title()

        dogma_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        dogma_row.append(Gtk.Label(label=t("dogma_mode"), hexpand=True, xalign=0))
        self.dogma_switch = Gtk.Switch()
        self.dogma_switch.set_valign(Gtk.Align.CENTER)
        dogma_row.append(self.dogma_switch)
        general_box.append(dogma_row)

        # -- Overlay: anchor/margin/scale, drag-set position --
        overlay_box = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=8,
            margin_top=8,
            margin_bottom=8,
            margin_start=8,
            margin_end=8,
        )
        overlay_frame = Gtk.Frame(label=t("overlay_header"))
        overlay_frame.set_child(overlay_box)
        root.append(overlay_frame)

        overlay_box.append(Gtk.Label(label=t("overlay_position"), xalign=0))
        position_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        self.anchor_dropdown = Gtk.DropDown.new_from_strings(_ANCHORS)
        position_row.append(self.anchor_dropdown)
        position_row.append(Gtk.Label(label=t("margin_x")))
        self.margin_x_spin = Gtk.SpinButton.new_with_range(0, 500, 1)
        position_row.append(self.margin_x_spin)
        position_row.append(Gtk.Label(label=t("margin_y")))
        self.margin_y_spin = Gtk.SpinButton.new_with_range(0, 500, 1)
        position_row.append(self.margin_y_spin)
        overlay_box.append(position_row)

        scale_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        scale_row.append(Gtk.Label(label=t("scale"), hexpand=True, xalign=0))
        self.scale_spin = Gtk.SpinButton.new_with_range(0.5, 3.0, 0.1)
        self.scale_spin.set_digits(2)
        scale_row.append(self.scale_spin)
        overlay_box.append(scale_row)

        drag_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        self.position_status_label = Gtk.Label(xalign=0, hexpand=True)
        self.position_status_label.add_css_class("dim-label")
        drag_row.append(self.position_status_label)
        reset_button = Gtk.Button(label=t("reset_position"))
        reset_button.connect("clicked", self._on_reset_position)
        drag_row.append(reset_button)
        overlay_box.append(drag_row)

        # -- Titles --
        titles_box = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=6,
            margin_top=8,
            margin_bottom=8,
            margin_start=8,
            margin_end=8,
            vexpand=True,
        )
        titles_frame = Gtk.Frame(label=t("titles_header"), vexpand=True)
        titles_frame.set_child(titles_box)
        root.append(titles_frame)

        header_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        header_row.add_css_class("title-row-header")
        header_row.add_css_class("dim-label")
        id_header = Gtk.Label(label=t("col_id"), xalign=0, width_chars=8)
        name_header = Gtk.Label(label=t("col_name"), xalign=0, hexpand=True)
        hotkey_header = Gtk.Label(label=t("col_hotkey"), xalign=0)
        hotkey_header.set_size_request(130, -1)
        cooldown_header = Gtk.Label(label=t("col_cooldown"), xalign=0)
        # Trailing spacer with no label - the data rows have a "Retriggerable"
        # checkbox + remove button after the cooldown spinner that have no
        # header counterpart; without this, name_header's hexpand swallows
        # that width too and the hotkey/cooldown headers drift right of
        # their actual columns.
        trailing_spacer = Gtk.Label(label="")
        trailing_spacer.set_size_request(178, -1)
        for widget in (id_header, name_header, hotkey_header, cooldown_header, trailing_spacer):
            header_row.append(widget)
        titles_box.append(header_row)

        self._title_rows: list[_TitleRow] = []
        self._title_rows_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        scroller = Gtk.ScrolledWindow(vexpand=True)
        scroller.set_child(self._title_rows_box)
        titles_box.append(scroller)

        add_button = Gtk.Button(label=t("add_title"))
        add_button.connect(
            "clicked",
            lambda _b: self._add_title_row(
                TitleConfig(id="", name="", hotkey=(), cooldown_seconds=60.0)
            ),
        )
        titles_box.append(add_button)

        self.status_label = Gtk.Label(xalign=0)
        root.append(self.status_label)

        button_row = Gtk.Box(
            orientation=Gtk.Orientation.HORIZONTAL, spacing=6, halign=Gtk.Align.END
        )
        cancel_button = Gtk.Button(label=t("close"))
        cancel_button.connect("clicked", lambda _b: self.close())
        save_button = Gtk.Button(label=t("save"))
        save_button.add_css_class("suggested-action")
        save_button.connect("clicked", self._on_save)
        button_row.append(cancel_button)
        button_row.append(save_button)
        root.append(button_row)

    def _on_language_changed(self, dropdown: Gtk.DropDown, _pspec) -> None:
        if self._suppress_language_switch:
            return
        selected = dropdown.get_selected_item()
        if selected is None:
            return
        lang = "de" if selected.get_string() == "Deutsch" else "en"
        if lang == i18n.get_language():
            return
        i18n.set_language(lang)
        save_language(lang)
        # Deferred for the same reason _load_profile defers via idle_add:
        # this handler runs while GTK is still mid-emission for
        # "notify::selected" on the very dropdown _build_ui is about to
        # replace - doing that synchronously segfaults.
        GLib.idle_add(self._reload_after_language_change)

    def _reload_after_language_change(self) -> bool:
        config = load_config(self._config_path)
        self._overlay_x = config.overlay.x
        self._overlay_y = config.overlay.y
        self._build_ui()
        self._populate_from_config(config)
        return GLib.SOURCE_REMOVE

    # -- character profiles --
    #
    # Each character is a full, separate config.json under
    # ~/.config/titletimer/profiles/ (see config.py). Switching, creating,
    # renaming, duplicating or deleting one always ends by reloading
    # whatever profile is now active from disk into this same window
    # (_load_profile) rather than trying to merge it with in-progress edits
    # - simplest mental model, and consistent with "Speichern" already being
    # the only thing that persists edits at all.

    def _build_profile_row(self) -> Gtk.Box:
        profile_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        profile_row.append(Gtk.Label(label=t("profile_label")))
        self.profile_dropdown = Gtk.DropDown(hexpand=True)
        self.profile_dropdown.connect("notify::selected", self._on_profile_dropdown_changed)
        profile_row.append(self.profile_dropdown)

        new_button = Gtk.Button(label=t("profile_new"))
        new_button.connect("clicked", self._on_new_profile)
        profile_row.append(new_button)

        duplicate_button = Gtk.Button(label=t("profile_duplicate"))
        duplicate_button.connect("clicked", self._on_duplicate_profile)
        profile_row.append(duplicate_button)

        rename_button = Gtk.Button(label=t("profile_rename"))
        rename_button.connect("clicked", self._on_rename_profile)
        profile_row.append(rename_button)

        delete_button = Gtk.Button(label=t("delete"))
        delete_button.connect("clicked", self._on_delete_profile)
        profile_row.append(delete_button)

        return profile_row

    def _update_window_title(self) -> None:
        title = t("settings_title")
        if self._profile_name is not None:
            title += f" ({self._profile_name})"
        self.set_title(title)

    def _refresh_profile_dropdown(self) -> None:
        if self.profile_dropdown is None:
            return
        profiles = list_profiles()
        self._suppress_profile_switch = True
        self.profile_dropdown.set_model(Gtk.StringList.new(profiles))
        if self._profile_name in profiles:
            self.profile_dropdown.set_selected(profiles.index(self._profile_name))
        self._suppress_profile_switch = False

    def _on_profile_dropdown_changed(self, dropdown: Gtk.DropDown, _pspec) -> None:
        if self._suppress_profile_switch:
            return
        selected = dropdown.get_selected_item()
        if selected is None:
            return
        name = selected.get_string()
        if name != self._profile_name:
            self._load_profile(name)

    def _load_profile(self, name: str) -> None:
        """Defers to _load_profile_now via idle_add.

        Every caller here (dropdown selection, new/rename/duplicate/delete)
        runs from inside a GTK signal handler that GTK itself is still
        unwinding - most importantly the dropdown's own "notify::selected",
        which fires while GtkDropDown is still processing the click that
        selected the item. _load_profile_now replaces the dropdown's model
        (among other things), and doing that synchronously while GTK is
        still mid-emission for that very widget segfaults (observed:
        SIGSEGV in g_object_notify_by_pspec/g_type_check_instance_is_
        fundamentally_a - a use-after-free on the just-replaced model).
        Scheduling the real work for the next main-loop iteration lets GTK
        finish its own dispatch first.
        """
        GLib.idle_add(self._load_profile_now, name)

    def _load_profile_now(self, name: str) -> None:
        """Load `name` from disk and make it the active profile: repopulate
        every widget from its config, remember it as last-active, and tell
        the running overlay (if any) to pick it up too.
        """
        path = profile_path(name)
        try:
            config = load_config(path)
        except (FileNotFoundError, ValueError) as exc:
            self.status_label.set_text(t("error_loading_profile", name=name, error=exc))
            return
        self._profile_name = name
        self._config_path = path
        set_active_profile(name)
        self._populate_from_config(config)
        self._refresh_profile_dropdown()
        self._update_window_title()
        if self._on_saved is not None:
            self._on_saved(config, path)

    def _on_new_profile(self, _button) -> None:
        _prompt_name(self, t("new_character_title"), "", self._create_profile)

    def _create_profile(self, name: str | None) -> None:
        if name is None:
            return
        name = name.strip()
        if not name:
            return
        if name in list_profiles():
            self.status_label.set_text(t("error_profile_exists", name=name))
            return
        try:
            path = profile_path(name)
        except ValueError as exc:
            self.status_label.set_text(t("error_generic", error=exc))
            return
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(EXAMPLE_CONFIG_PATH.read_text())
        self._load_profile(name)
        self.status_label.set_text(t("profile_created", name=name))

    def _on_duplicate_profile(self, _button) -> None:
        _prompt_name(
            self,
            t("duplicate_character_title"),
            t("duplicate_name_suffix", name=self._profile_name),
            self._do_duplicate_profile,
        )

    def _do_duplicate_profile(self, new_name: str | None) -> None:
        if new_name is None:
            return
        new_name = new_name.strip()
        if not new_name:
            return
        if new_name in list_profiles():
            self.status_label.set_text(t("error_profile_exists", name=new_name))
            return
        try:
            duplicate_profile(self._profile_name, new_name)
        except (ValueError, OSError) as exc:
            self.status_label.set_text(t("error_generic", error=exc))
            return
        self._load_profile(new_name)
        self.status_label.set_text(t("profile_duplicated", name=new_name))

    def _on_rename_profile(self, _button) -> None:
        _prompt_name(self, t("rename_character_title"), self._profile_name, self._do_rename_profile)

    def _do_rename_profile(self, new_name: str | None) -> None:
        if new_name is None:
            return
        new_name = new_name.strip()
        if not new_name or new_name == self._profile_name:
            return
        if new_name in list_profiles():
            self.status_label.set_text(t("error_profile_exists", name=new_name))
            return
        try:
            rename_profile(self._profile_name, new_name)
        except (ValueError, OSError) as exc:
            self.status_label.set_text(t("error_generic", error=exc))
            return
        self._load_profile(new_name)
        self.status_label.set_text(t("profile_renamed", name=new_name))

    def _on_delete_profile(self, _button) -> None:
        if len(list_profiles()) <= 1:
            self.status_label.set_text(t("error_cannot_delete_last"))
            return
        _confirm(
            self,
            t("delete_character_title"),
            t("delete_character_confirm", name=self._profile_name),
            self._do_delete_profile,
        )

    def _do_delete_profile(self, confirmed: bool) -> None:
        if not confirmed:
            return
        removed_name = self._profile_name
        delete_profile(removed_name)
        remaining = list_profiles()
        self._load_profile(remaining[0])
        self.status_label.set_text(t("profile_deleted", name=removed_name))

    # -- form population / titles --

    def _populate_from_config(self, config: AppConfig) -> None:
        self._overlay_x = config.overlay.x
        self._overlay_y = config.overlay.y
        self.dogma_switch.set_active(config.dogma_mode)
        self.anchor_dropdown.set_selected(_ANCHORS.index(config.overlay.anchor))
        self.margin_x_spin.set_value(config.overlay.margin_x)
        self.margin_y_spin.set_value(config.overlay.margin_y)
        self.scale_spin.set_value(config.overlay.scale)
        self._update_position_status()

        for row in list(self._title_rows):
            self._remove_title_row(row)
        for title in config.titles:
            self._add_title_row(title)

    def _add_title_row(self, title: TitleConfig) -> None:
        row = _TitleRow(title, self._remove_title_row)
        self._title_rows.append(row)
        self._title_rows_box.append(row)

    def _remove_title_row(self, row: _TitleRow) -> None:
        self._title_rows.remove(row)
        self._title_rows_box.remove(row)

    def _on_reset_position(self, _button) -> None:
        self._overlay_x = None
        self._overlay_y = None
        self._update_position_status()

    def _update_position_status(self) -> None:
        if self._overlay_x is not None and self._overlay_y is not None:
            self.position_status_label.set_text(
                t("position_dragged", x=self._overlay_x, y=self._overlay_y)
            )
        else:
            self.position_status_label.set_text(t("position_follows_anchor"))

    def _on_save(self, _button) -> None:
        titles = [row.to_config() for row in self._title_rows]

        if not titles:
            self.status_label.set_text(t("error_need_one_title"))
            return
        if any(not title.id or not title.name or not title.hotkey for title in titles):
            self.status_label.set_text(t("error_empty_fields"))
            return
        ids = [title.id for title in titles]
        if len(set(ids)) != len(ids):
            self.status_label.set_text(t("error_duplicate_id"))
            return
        all_hotkeys = [title.hotkey for title in titles] + [
            title.pre_hotkey for title in titles if title.pre_hotkey
        ]
        if len(set(all_hotkeys)) != len(all_hotkeys):
            self.status_label.set_text(t("error_duplicate_hotkey"))
            return

        new_config = AppConfig(
            dogma_mode=self.dogma_switch.get_active(),
            overlay=OverlayConfig(
                anchor=_ANCHORS[self.anchor_dropdown.get_selected()],
                margin_x=int(self.margin_x_spin.get_value()),
                margin_y=int(self.margin_y_spin.get_value()),
                scale=round(self.scale_spin.get_value(), 2),
                x=self._overlay_x,
                y=self._overlay_y,
            ),
            titles=titles,
        )
        save_config(self._config_path, new_config)
        self.status_label.set_text(t("saved_to", path=self._config_path))
        if self._on_saved is not None:
            self._on_saved(new_config, self._config_path)
