import gi
gi.require_version('Gtk', '4.0')
gi.require_version('GdkPixbuf', '2.0')
from gi.repository import Gtk, Gdk, GdkPixbuf, Pango, GLib, Gio
import os
from collections import OrderedDict
import utils
import settings

# Largest a history row ever draws an image; nothing above this is worth
# decoding, let alone keeping resident.
THUMB_MAX_W = 370
THUMB_MAX_H = 250
# Each cached thumbnail costs at most THUMB_MAX_W * THUMB_MAX_H * 4 ≈ 370KB,
# so the whole cache is bounded at roughly 15MB.
THUMB_CACHE_SIZE = 40



class ClipboardWindow(Gtk.ApplicationWindow):
    def __init__(self, app, db_interface, on_copy):
        super().__init__(application=app, title="Klipr - Clipboard Manager")
        self.set_icon_name("klipr")
        self.set_default_size(420, 600)

        self.db = db_interface
        self.on_copy_callback = on_copy
        self._toast_timeout_id = None
        self._search_debounce_id = None
        # path -> (mtime, texture), most-recently-used last
        self._thumb_cache = OrderedDict()
        # Set while the history changed but the window was hidden, so the
        # rebuild happens once on show instead of on every copy.
        self._history_dirty = False
        # iBus Ubuntu 22 workaround: track previous changed event to detect duplicate
        self._last_change_text = ""
        self._last_change_time = 0  # GLib monotonic microseconds

        # CSS
        self.css_provider = Gtk.CssProvider()
        self.light_provider = Gtk.CssProvider()
        self.load_css()

        # Main overlay (allows toast to float on top of content)
        overlay = Gtk.Overlay()
        self.set_child(overlay)

        # ── Stack (Root Container) ──────────────────────────────────────
        self.stack = Gtk.Stack()
        self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
        overlay.set_child(self.stack)

        # ── Page 1: Main View ───────────────────────────────────────────
        self.main_view = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        self.stack.add_named(self.main_view, "main")

        # Header Area
        header_area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        header_area.add_css_class("header-area")
        self.main_view.append(header_area)

        # ── Row 1: Title + actions ──────────────────────────────────
        title_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
        title_row.add_css_class("title-row")
        header_area.append(title_row)

        app_name = settings.get("name")
        brand_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        brand_box.add_css_class("app-brand")
        brand_box.set_hexpand(True)
        title_row.append(brand_box)

        logo_path = self._resolve_logo_path()
        if logo_path:
            logo_image = Gtk.Image.new_from_file(logo_path)
            logo_image.set_pixel_size(32)
            logo_image.add_css_class("app-logo")
            self.logo_image = logo_image
            brand_box.append(logo_image)

        self.title_label = Gtk.Label(label=app_name)
        self.title_label.set_xalign(0)
        self.title_label.add_css_class("app-title")
        brand_box.append(self.title_label)

        self.btn_search = Gtk.ToggleButton(icon_name="system-search-symbolic")
        self.btn_search.set_tooltip_text("Search")
        self.btn_search.add_css_class("header-btn")
        self.btn_search.connect('toggled', self._on_search_toggled)
        title_row.append(self.btn_search)

        self.btn_theme = Gtk.Button(icon_name="weather-clear-night-symbolic")
        self.btn_theme.set_tooltip_text("Switch to light mode")
        self.btn_theme.add_css_class("header-btn")
        self.btn_theme.connect('clicked', self._on_theme_toggle_clicked)
        title_row.append(self.btn_theme)

        self.btn_delete_all = Gtk.Button(icon_name="user-trash-symbolic")
        self.btn_delete_all.set_tooltip_text("Delete History")
        self.btn_delete_all.add_css_class("header-btn")
        self.btn_delete_all.connect('clicked', self._on_clear_clicked)
        title_row.append(self.btn_delete_all)

        btn_settings = Gtk.Button(icon_name="emblem-system-symbolic")
        btn_settings.set_tooltip_text("Settings")
        btn_settings.add_css_class("header-btn")
        btn_settings.connect('clicked', self._on_settings_clicked)
        title_row.append(btn_settings)

        # ── Row 2: Tabs ──────────────────────────────────────────────
        tab_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
        tab_row.add_css_class("tab-row")
        header_area.append(tab_row)

        self.active_filter = "all"

        # Tab group (segmented control)
        tab_group = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
        tab_group.add_css_class("tab-group")
        tab_group.set_hexpand(True)
        tab_row.append(tab_group)

        self.btn_all = Gtk.ToggleButton(label="History")
        self.btn_all.set_active(True)
        self.btn_all.add_css_class("tab")
        self.btn_all.add_css_class("tab-first")
        self.btn_all.set_hexpand(True)
        self.btn_all.connect('toggled', lambda b: self._on_filter_toggled("all"))
        tab_group.append(self.btn_all)

        self.btn_fav = Gtk.ToggleButton(label="Favourite")
        self.btn_fav.add_css_class("tab")
        self.btn_fav.add_css_class("tab-last")
        self.btn_fav.set_hexpand(True)
        self.btn_fav.set_group(self.btn_all)
        self.btn_fav.connect('toggled', lambda b: self._on_filter_toggled("favorites"))
        tab_group.append(self.btn_fav)

        # ── Row 3: Search (revealed on demand) ──────────────────────
        # Use plain Gtk.Entry (NOT Gtk.SearchEntry) — SearchEntry has built-in
        # GTK search delay, and its Enter/activate propagates to the ListBox
        # causing rows to get "activated" (copy triggered) unexpectedly.
        self.search_entry = Gtk.Entry()
        self.search_entry.set_placeholder_text("Search clipboard...")
        self.search_entry.set_icon_from_icon_name(
            Gtk.EntryIconPosition.PRIMARY, "system-search-symbolic"
        )
        self.search_entry.set_icon_activatable(Gtk.EntryIconPosition.PRIMARY, False)
        # 'changed' fires on committed text; 'preedit-changed' fires on iBus buffer
        self._changed_handler_id = self.search_entry.connect('changed', self._on_search_changed)
        self.search_entry.get_delegate().connect('preedit-changed', self._on_preedit_changed)
        self.search_entry.connect('activate', lambda e: None)
        self.search_revealer = Gtk.Revealer()
        self.search_revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
        self.search_revealer.set_transition_duration(160)
        self.search_revealer.set_reveal_child(False)
        self.search_revealer.set_child(self.search_entry)
        header_area.append(self.search_revealer)

        # ── Content Area (Stack: List vs Empty) ─────────────────────────
        self.content_stack = Gtk.Stack()
        self.content_stack.set_vexpand(True)
        self.content_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
        self.main_view.append(self.content_stack)

        # View 1: List
        self.scrolled = Gtk.ScrolledWindow()
        self.scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        self.scrolled.add_css_class("content-scroll")
        self.scrolled.set_vexpand(True)
        
        self.listbox = Gtk.ListBox()
        self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
        self.listbox.set_margin_top(2)
        self.listbox.set_margin_bottom(6)
        self.listbox.connect("row-activated", self._on_row_activated)
        self.scrolled.set_child(self.listbox)
        
        self.content_stack.add_named(self.scrolled, "list")

        # View 2: Empty State
        self.empty_state = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.empty_state.set_vexpand(True)
        self.empty_state.set_hexpand(True)
        self.empty_state.add_css_class("empty-state")
        
        # Center content wrapper
        center_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        center_box.set_halign(Gtk.Align.CENTER)
        center_box.set_valign(Gtk.Align.CENTER)
        center_box.set_vexpand(True)
        self.empty_state.append(center_box)
        
        self.empty_icon = Gtk.Image()
        self.empty_icon.set_pixel_size(72)
        self.empty_icon.add_css_class("dim-label")
        self.empty_icon.set_opacity(0.5)
        center_box.append(self.empty_icon)
        
        self.empty_title = Gtk.Label()
        self.empty_title.add_css_class("title-2")
        center_box.append(self.empty_title)
        
        self.empty_desc = Gtk.Label()
        self.empty_desc.add_css_class("dim-label")
        self.empty_desc.set_max_width_chars(30)
        self.empty_desc.set_wrap(True)
        self.empty_desc.set_justify(Gtk.Justification.CENTER)
        center_box.append(self.empty_desc)
        
        self.content_stack.add_named(self.empty_state, "empty")

        from ui.settings_dialog import SettingsView
        self.settings_view = SettingsView(
            on_close_callback=self._on_settings_closed,
            on_theme_changed=self._on_theme_changed,
            on_show_confirm=self._show_confirm,
            on_show_toast=self.show_toast
        )
        self.stack.add_named(self.settings_view, "settings")

        # ── Toast notification (floating overlay) ───────────────────────
        self.toast_revealer = Gtk.Revealer()
        self.toast_revealer.set_transition_type(Gtk.RevealerTransitionType.CROSSFADE)
        self.toast_revealer.set_transition_duration(200)
        self.toast_revealer.set_halign(Gtk.Align.CENTER)
        self.toast_revealer.set_valign(Gtk.Align.END)
        self.toast_revealer.set_margin_bottom(16)
        self.toast_revealer.set_can_target(False)

        self.toast_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self.toast_box.add_css_class("toast")
        self.toast_box.set_can_target(False)

        self.toast_icon = Gtk.Image()
        self.toast_icon.set_pixel_size(16)
        self.toast_box.append(self.toast_icon)

        self.toast_label = Gtk.Label()
        self.toast_box.append(self.toast_label)

        self.toast_revealer.set_child(self.toast_box)
        overlay.add_overlay(self.toast_revealer)

        # Set correct theme icon on startup
        self._update_theme_icon()

        # Load initial data
        self.refresh_list()

        # Close-to-background: hide window instead of destroying
        self.connect('close-request', self._on_close_request)

        # Flush any refresh that was skipped while hidden
        self.connect('map', self._on_map)

        # Keyboard: Ctrl+F search, Up/Down through rows, Enter copy, Esc dismiss.
        # Capture phase so these win before the focused widget consumes them.
        key_controller = Gtk.EventControllerKey()
        key_controller.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
        key_controller.connect('key-pressed', self._on_key_pressed)
        self.add_controller(key_controller)

        # Monitor system theme changes
        settings_default = Gtk.Settings.get_default()
        if settings_default:
            settings_default.connect("notify::gtk-application-prefer-dark-theme", self._on_system_theme_changed)

        # Also try to monitor GNOME interface settings directly
        try:
             self._gnome_interface_settings = Gio.Settings.new("org.gnome.desktop.interface")
             self._gnome_interface_settings.connect("changed::color-scheme", self._on_system_theme_changed)
        except Exception:
             self._gnome_interface_settings = None

    def _on_system_theme_changed(self, *args):
        """Called when system theme preference changes."""
        if settings.get("theme") == "system":
            self._apply_theme()
            if self.settings_view:
                 # Force logo update in settings view if open
                 self.settings_view._update_logo("system")

    # ── CSS ─────────────────────────────────────────────────────────

    def load_css(self):
        """Load CSS based on current theme setting."""
        self._apply_theme()
        return False

    # ── Settings Update ─────────────────────────────────────────────
    
    def update_from_settings(self):
        """Called when settings file changes on disk."""
        # Update Title if name changed
        app_name = settings.get("name")
        self.set_title(f"{app_name} - Clipboard Manager")
        
        # Reload settings view if open
        if self.settings_view:
             self.settings_view.reload_state()
             
        # Re-apply theme in case it changed
        self.load_css()

    # ── Toast ───────────────────────────────────────────────────────

    def show_toast(self, message, toast_type="success"):
        """Show a brief notification at the bottom of the window."""
        if self._toast_timeout_id:
            GLib.source_remove(self._toast_timeout_id)
            self._toast_timeout_id = None

        icons = {
            "success": "object-select-symbolic",
            "info": "dialog-information-symbolic",
            "warning": "dialog-warning-symbolic",
        }
        self.toast_icon.set_from_icon_name(
            icons.get(toast_type, "dialog-information-symbolic")
        )

        for cls in ("toast-success", "toast-info", "toast-warning"):
            self.toast_box.remove_css_class(cls)
        self.toast_box.add_css_class(f"toast-{toast_type}")

        self.toast_label.set_label(message)
        self.toast_revealer.set_reveal_child(True)
        self._toast_timeout_id = GLib.timeout_add(2000, self._hide_toast)

    def _hide_toast(self):
        self.toast_revealer.set_reveal_child(False)
        self._toast_timeout_id = None
        return False

    # ── Confirm Dialog ──────────────────────────────────────────────

    def _show_confirm(self, title, message, on_confirm, confirm_label="Delete"):
        """Show a simple confirmation dialog before destructive actions."""
        dialog = Gtk.MessageDialog(
            transient_for=self,
            modal=True,
            message_type=Gtk.MessageType.WARNING,
            buttons=Gtk.ButtonsType.NONE,
            text=title,
            secondary_text=message,
        )
        dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
        dialog.add_button(confirm_label, Gtk.ResponseType.OK)

        def on_response(_dialog, response_id):
            _dialog.destroy()
            if response_id == Gtk.ResponseType.OK:
                on_confirm()

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

    # ── Data & List ─────────────────────────────────────────────────

    def mark_history_dirty(self):
        """Note that stored history changed; rebuild only if the user can see it.

        Klipr lives in the tray, so most clipboard events arrive while the
        window is hidden. Rebuilding every row (and decoding every image)
        for an invisible list was pure waste; the pending refresh is folded
        into the next time the window is mapped.
        """
        self._history_dirty = True
        if self.get_mapped():
            self.refresh_list(self.search_entry.get_text())

    def _on_map(self, *_args):
        if self._history_dirty:
            self.refresh_list(self.search_entry.get_text())

    def refresh_list(self, search_query=None):
        self._history_dirty = False
        hist_count, fav_count = self.db.get_counts()
        self.btn_all.set_label(f"History ({hist_count})" if hist_count else "History")
        self.btn_fav.set_label(f"Favourite ({fav_count})" if fav_count else "Favourite")
        
        if self.active_filter == "favorites":
             self.btn_delete_all.set_tooltip_text("Delete All Favorites")
             self.btn_delete_all.set_sensitive(fav_count > 0)
        else:
             self.btn_delete_all.set_tooltip_text("Delete All History")
             self.btn_delete_all.set_sensitive(hist_count > 0)

        # Clear the listbox
        while (child := self.listbox.get_first_child()):
            self.listbox.remove(child)

        if self.active_filter == "favorites":
            items = self.db.get_favorites(search_query)
        else:
            items = self.db.get_history(search_query)

        if not items:
            self._update_empty_state_ui(search_query)
            self.content_stack.set_visible_child_name("empty")
        else:
            self.content_stack.set_visible_child_name("list")
            # One query for the whole list instead of an is_favorite() call
            # per row, which made a full refresh O(rows) round trips.
            pinned = None
            if self.active_filter == "all":
                pinned = {row[1] for row in self.db.get_favorites(None)}
            for item in items:
                self.listbox.append(self._create_row(item, pinned))

    def _update_empty_state_ui(self, search_query):
        if search_query:
            self.empty_icon.set_from_icon_name("system-search-symbolic")
            self.empty_title.set_label("No results found")
            self.empty_desc.set_label(f"No matches for '{search_query}'")
        elif self.active_filter == "favorites":
            self.empty_icon.set_from_icon_name("starred-symbolic")
            self.empty_title.set_label("No favorites yet")
            self.empty_desc.set_label("Star items in your history to pin them here for quick access.")
        else:
            self.empty_icon.set_from_icon_name("edit-paste-symbolic")
            self.empty_title.set_label("Clipboard is empty")
            self.empty_desc.set_label("Copy text or images to see them appear in your history.")

    # ── Event Handlers ──────────────────────────────────────────────

    def _on_filter_toggled(self, filter_name):
        is_active = (
            (filter_name == "all" and self.btn_all.get_active()) or
            (filter_name == "favorites" and self.btn_fav.get_active())
        )
        if is_active:
            self.active_filter = filter_name
            self.refresh_list(self.search_entry.get_text())

    def _on_preedit_changed(self, entry, preedit):
        # iBus holds text in preedit buffer while composing — entry.get_text() won't
        # include it, so 'changed' never fires. Combine committed + preedit for live search.
        text = entry.get_text() + (preedit or "")
        if self._search_debounce_id:
            GLib.source_remove(self._search_debounce_id)
            self._search_debounce_id = None
        self._search_debounce_id = GLib.timeout_add(350, self._do_search, text)

    def _on_search_changed(self, entry):
        """Called immediately on every character change. No GTK internal delay."""
        now = GLib.get_monotonic_time()  # microseconds
        text = entry.get_text()

        # iBus Ubuntu 22 bug: committing a Vietnamese syllable fires `changed`
        # twice in the same GLib iteration (0ms apart). The second fire appends
        # the committed syllable again: "tái hiện" → "tái hiệnhiện".
        #
        # Detection: within a 10ms window (below any hardware key-repeat rate),
        # new text = prev_text + suffix AND prev_text already ends with suffix.
        # The 10ms bound avoids false positives from fast human typing.
        elapsed_ms = (now - self._last_change_time) / 1000
        if elapsed_ms < 10 and self._last_change_text:
            prev = self._last_change_text
            if text.startswith(prev) and len(text) > len(prev):
                suffix = text[len(prev):]
                if prev.endswith(suffix):
                    self.search_entry.handler_block(self._changed_handler_id)
                    entry.set_text(prev)
                    self.search_entry.handler_unblock(self._changed_handler_id)
                    text = prev

        self._last_change_text = text
        self._last_change_time = now

        if self._search_debounce_id:
            GLib.source_remove(self._search_debounce_id)
            self._search_debounce_id = None
        self._search_debounce_id = GLib.timeout_add(350, self._do_search, text)

    def _do_search(self, query):
        self._search_debounce_id = None
        self.refresh_list(query)
        return False

    def _on_search_toggled(self, btn):
        is_active = btn.get_active()
        self.search_revealer.set_reveal_child(is_active)
        if is_active:
            self.search_entry.grab_focus()
        else:
            if self.search_entry.get_text():
                self.search_entry.set_text("")
            self.refresh_list()

    def _on_clear_clicked(self, btn):
        if self.active_filter == "favorites":
            self._show_confirm(
                "Delete all favorites?",
                "All items in the Favorites list will be permanently deleted.",
                self._do_clear_favorites,
            )
        else:
            self._show_confirm(
                "Delete all history?",
                "All items in the History list will be permanently deleted.\nFavorites will be SAFE.",
                self._do_clear_history,
            )

    def _do_clear_history(self):
        count = self.db.clear_history()
        item_word = "item" if count == 1 else "items"
        self.show_toast(f"Deleted {count} history {item_word}", "success")
        self.refresh_list(self.search_entry.get_text())

    def _do_clear_favorites(self):
        count = self.db.clear_favorites()
        item_word = "favorite" if count == 1 else "favorites"
        self.show_toast(f"Deleted {count} {item_word}", "success")
        self.refresh_list(self.search_entry.get_text())

    def _on_copy_clicked(self, content):
        self.on_copy_callback(content)
        self.show_toast("Copied to clipboard", "success")

    def _on_pin_clicked(self, row, item_id, content):
        """Toggle favorite status."""
        if self.active_filter == "favorites":
            self.db.remove_from_favorites(content)
            self.show_toast("Removed from favorites", "info")
            self.refresh_list(self.search_entry.get_text())
        else:
            if self.db.is_favorite(content):
                self.db.remove_from_favorites(content)
                self.show_toast("Removed from favorites", "info")
                row.set_fav_active(False)
            else:
                self.db.add_to_favorites(content)
                self.show_toast("Added to favorites", "success")
                row.set_fav_active(True)
            # Update counts and delete-all button state without resetting scroll position
            hist_count, fav_count = self.db.get_counts()
            self.btn_all.set_label(f"History ({hist_count})" if hist_count else "History")
            self.btn_fav.set_label(f"Favourite ({fav_count})" if fav_count else "Favourite")
            if self.active_filter == "favorites":
                self.btn_delete_all.set_tooltip_text("Delete All Favorites")
                self.btn_delete_all.set_sensitive(fav_count > 0)
            else:
                self.btn_delete_all.set_tooltip_text("Delete All History")
                self.btn_delete_all.set_sensitive(hist_count > 0)

    def _on_edit_favorite_name(self, item_id, current_name):
        """Open dialog to set optional name for a favorite; search will use this name."""
        dialog = Gtk.Window(
            transient_for=self,
            modal=True,
            title="Edit name",
        )
        dialog.set_resizable(False)
        dialog.set_default_size(320, -1)

        entry = Gtk.Entry(
            placeholder_text="Enter clipboard name (optional)",
            hexpand=True,
        )
        if current_name:
            entry.set_text(current_name)

        def do_close():
            dialog.destroy()

        def do_save():
            name = entry.get_text().strip() or None
            self.db.update_favorite_name(item_id, name)
            self.show_toast("Saved", "success")
            self.refresh_list(self.search_entry.get_text())
            dialog.destroy()

        btn_cancel = Gtk.Button(label="Cancel")
        btn_cancel.connect("clicked", lambda _b: do_close())

        btn_save = Gtk.Button(label="Save")
        btn_save.add_css_class("suggested-action")
        btn_save.connect("clicked", lambda _b: do_save())

        header = Gtk.HeaderBar()
        header.set_show_title_buttons(True)
        dialog.set_titlebar(header)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        box.set_margin_top(18)
        box.set_margin_bottom(6)
        box.set_margin_start(18)
        box.set_margin_end(18)
        box.append(entry)

        action_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        action_row.set_margin_top(6)
        action_row.set_margin_bottom(12)
        action_row.set_margin_start(18)
        action_row.set_margin_end(18)
        action_row.append(Gtk.Label(hexpand=True))
        action_row.append(btn_cancel)
        action_row.append(btn_save)

        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        content.append(box)
        content.append(action_row)
        dialog.set_child(content)

        entry.grab_focus()
        dialog.present()

    def _on_delete_clicked(self, item_id):
        if self.active_filter == "favorites":
            self.db.delete_favorite_item(item_id)
        else:
            self.db.delete_history_item(item_id)
        self.show_toast("Deleted", "success")
        self.refresh_list(self.search_entry.get_text())

    def _on_key_pressed(self, controller, keyval, keycode, state):
        ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK)

        if ctrl and keyval in (Gdk.KEY_f, Gdk.KEY_F):
            self.btn_search.set_active(not self.btn_search.get_active())
            return True

        if keyval == Gdk.KEY_Escape:
            if self.btn_search.get_active():
                self.btn_search.set_active(False)
                return True
            self.close()
            return True

        return False

    def _on_row_activated(self, listbox, row):
        if not hasattr(row, 'item_data'):
            return
        _, content, *_ = row.item_data
        self._on_copy_clicked(content)

    def _on_close_request(self, window):
        if settings.get("closeToTray"):
            self.hide()
            return True
        else:
            self.get_application().quit()
            return False

    # ── Settings Navigation ─────────────────────────────────────────

    def _on_theme_toggle_clicked(self, btn):
        """Cycle through dark → light → system."""
        current = settings.get("theme")
        cycle = {"dark": "light", "light": "system", "system": "dark"}
        new_theme = cycle.get(current, "dark")
        settings.set("theme", new_theme)
        self._apply_theme(new_theme)

    def _update_theme_icon(self, theme=None):
        """Update the theme toggle button icon to reflect current theme."""
        if not hasattr(self, 'btn_theme'):
            return
        if theme is None:
            theme = settings.get("theme")
        icons = {
            "dark": ("weather-clear-night-symbolic", "Theme: Dark (click to switch)"),
            "light": ("weather-clear-symbolic", "Theme: Light (click to switch)"),
            "system": ("preferences-desktop-appearance-symbolic", "Theme: System (click to switch)"),
        }
        icon, tooltip = icons.get(theme, icons["dark"])
        self.btn_theme.set_icon_name(icon)
        self.btn_theme.set_tooltip_text(tooltip)

    def _on_settings_clicked(self, btn):
        self.settings_view.reload_state()
        self.stack.set_visible_child_name("settings")

    def _on_settings_closed(self, saved):
        self.stack.set_visible_child_name("main")
        if saved:
            app = self.get_application()
            if app and hasattr(app, "_reload_settings"):
                app._reload_settings()
            self._apply_theme()
            self.show_toast("Settings saved", "success")

    def _on_theme_changed(self, theme):
        self._apply_theme(theme)

    def _apply_theme(self, theme=None):
        if theme is None:
            theme = settings.get("theme")

        # Resolve "system" to actual dark/light
        resolved = theme
        if theme == "system":
            gtk_settings = Gtk.Settings.get_default()
            if gtk_settings and gtk_settings.get_property("gtk-application-prefer-dark-theme"):
                resolved = "dark"
            else:
                # Check freedesktop color-scheme
                try:
                    import subprocess
                    result = subprocess.run(
                        ["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
                        capture_output=True, text=True, timeout=2
                    )
                    resolved = "dark" if "dark" in result.stdout.lower() else "light"
                except Exception:
                    resolved = "dark"

        self._update_theme_icon(theme)

        display = Gdk.Display.get_default()

        # Always load base dark theme
        try:
            Gtk.StyleContext.remove_provider_for_display(display, self.css_provider)
        except Exception:
            pass
        try:
            css_path = os.path.join(os.path.dirname(__file__), "..", "style.css")
            self.css_provider.load_from_path(css_path)
            Gtk.StyleContext.add_provider_for_display(
                display, self.css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER,
            )
        except Exception as e:
            print(f"Error loading CSS: {e}")

        # Layer light overrides on top when needed
        try:
            Gtk.StyleContext.remove_provider_for_display(display, self.light_provider)
        except Exception:
            pass
        if resolved == "light":
            try:
                light_path = os.path.join(os.path.dirname(__file__), "..", "style_light.css")
                self.light_provider.load_from_path(light_path)
                Gtk.StyleContext.add_provider_for_display(
                    display, self.light_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1,
                )
            except Exception as e:
                print(f"Error loading light CSS: {e}")

        # Update logo
        target_logo = "light_logo.png" if resolved == "light" else "logo.png"
        logo_path = self._resolve_logo_path(target_logo)
        if logo_path and hasattr(self, 'logo_image'):
            self.logo_image.set_from_file(logo_path)

    def _resolve_logo_path(self, filename="logo.png"):
        base_dir = os.path.dirname(os.path.abspath(__file__))
        candidates = [
            os.path.join(base_dir, "..", "assets", filename),
            os.path.join(base_dir, "..", "..", "assets", filename),
            os.path.join(os.getcwd(), "assets", filename),
        ]
        for path in candidates:
            abs_path = os.path.abspath(path)
            if os.path.exists(abs_path):
                return abs_path
        
        # Fallback to default logo if light version not found
        if filename != "logo.png":
            return self._resolve_logo_path("logo.png")
            
        return None

    # ── Row Builder ─────────────────────────────────────────────────

    def _load_thumbnail(self, image_path):
        """Decode an image straight to row size, cached and bounded.

        Gdk.Texture.new_from_filename decodes at full resolution and keeps
        every pixel resident: one 4K screenshot costs ~33MB, and the list
        built a fresh texture per image row on every rebuild. Decoding at
        thumbnail size is what stops a long image history from pinning
        gigabytes of RAM.
        """
        try:
            mtime = os.stat(image_path).st_mtime
        except OSError:
            return None

        cached = self._thumb_cache.get(image_path)
        if cached and cached[0] == mtime:
            self._thumb_cache.move_to_end(image_path)
            return cached[1]

        # get_file_info reads only the header, so the target size is known
        # before any pixels are decoded.
        info = GdkPixbuf.Pixbuf.get_file_info(image_path)
        width, height = (info[1], info[2]) if info and info[0] else (0, 0)
        if width > 0 and height > 0:
            # min(..., 1.0) keeps small images at native size instead of
            # upscaling them to fill the box.
            scale = min(THUMB_MAX_W / width, THUMB_MAX_H / height, 1.0)
            pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
                image_path, max(1, int(width * scale)), max(1, int(height * scale)), True)
        else:
            pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
                image_path, THUMB_MAX_W, THUMB_MAX_H, True)

        texture = Gdk.Texture.new_for_pixbuf(pixbuf)
        self._thumb_cache[image_path] = (mtime, texture)
        self._thumb_cache.move_to_end(image_path)
        while len(self._thumb_cache) > THUMB_CACHE_SIZE:
            self._thumb_cache.popitem(last=False)
        return texture

    def _create_row(self, item, pinned=None):
        item_id, content, timestamp = item[0], item[1], item[2]
        name = (item[3] if len(item) > 3 else None) or None

        is_pinned = True
        if self.active_filter == "all":
            is_pinned = content in pinned if pinned is not None else self.db.is_favorite(content)

        row = Gtk.ListBoxRow()
        row.item_data = item
        row.set_selectable(False)
        row.set_activatable(True)
        row.set_focusable(False)
        row.set_css_classes([])

        item_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        item_box.add_css_class("clipboard-item")

        header_row = None
        if name:
            header_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
            name_lbl = Gtk.Label(label=name)
            name_lbl.add_css_class("clipboard-item-name")
            name_lbl.set_xalign(0)
            name_lbl.set_ellipsize(Pango.EllipsizeMode.END)
            name_lbl.set_wrap(False)
            name_lbl.set_hexpand(True)
            header_row.append(name_lbl)
            item_box.append(header_row)

        top_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        item_box.append(top_row)

        if content.startswith("IMAGE::"):
            image_path = content.replace("IMAGE::", "")
            if os.path.exists(image_path):
                try:
                    texture = self._load_thumbnail(image_path)
                    if texture is None:
                        raise ValueError("thumbnail unavailable")
                    picture = Gtk.Picture.new_for_paintable(texture)
                    picture.set_can_shrink(True)
                    picture.add_css_class("content-image")
                    # Already fitted inside THUMB_MAX_W x THUMB_MAX_H on decode.
                    picture.set_size_request(-1, texture.get_height())

                    img_box = Gtk.Box()
                    img_box.set_hexpand(True)
                    img_box.append(picture)
                    top_row.append(img_box)
                except Exception as e:
                    top_row.append(Gtk.Label(label="[Image Error]"))
            else:
                top_row.append(Gtk.Label(label="[Image Missing]", css_classes=["clipboard-item-text"]))
        else:
            display_text = content[:300] + ("..." if len(content) > 300 else "")
            lbl = Gtk.Label(label=display_text)
            lbl.set_xalign(0)
            lbl.set_wrap(True)
            lbl.set_wrap_mode(Pango.WrapMode.WORD_CHAR)
            lbl.set_lines(5)
            lbl.add_css_class("clipboard-item-text")
            lbl.set_hexpand(True)
            top_row.append(lbl)

        actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        actions.add_css_class("actions")
        actions.set_valign(Gtk.Align.START)
        if header_row is not None:
            header_row.append(actions)
        else:
            top_row.append(actions)

        btn_copy = Gtk.Button(icon_name="edit-copy-symbolic")
        btn_copy.set_focusable(False)
        btn_copy.add_css_class("icon-btn")
        btn_copy.add_css_class("copy")
        btn_copy.set_tooltip_text("Copy")
        btn_copy.connect('clicked', lambda b: self._on_copy_clicked(content))
        actions.append(btn_copy)

        if self.active_filter == "favorites":
            btn_edit = Gtk.Button(icon_name="document-edit-symbolic")
            btn_edit.set_focusable(False)
            btn_edit.add_css_class("icon-btn")
            btn_edit.add_css_class("edit-name")
            btn_edit.set_tooltip_text("Edit name")
            btn_edit.connect("clicked", lambda b: self._on_edit_favorite_name(item_id, name))
            actions.append(btn_edit)
        else:
            btn_fav = Gtk.Button(icon_name="emblem-favorite-symbolic")
            btn_fav.set_focusable(False)
            btn_fav.add_css_class("icon-btn")
            btn_fav.add_css_class("fav")
            btn_fav.set_tooltip_text("Add to Favorites")
            if is_pinned:
                btn_fav.add_css_class("active")
                btn_fav.set_tooltip_text("Remove from Favorites")

            def set_fav_active(active):
                if active:
                    btn_fav.add_css_class("active")
                    btn_fav.set_tooltip_text("Remove from Favorites")
                else:
                    btn_fav.remove_css_class("active")
                    btn_fav.set_tooltip_text("Add to Favorites")
            row.set_fav_active = set_fav_active

            btn_fav.connect('clicked', lambda b: self._on_pin_clicked(row, item_id, content))
            actions.append(btn_fav)

        btn_del = Gtk.Button(icon_name="user-trash-symbolic")
        btn_del.set_focusable(False)
        btn_del.add_css_class("icon-btn")
        btn_del.add_css_class("delete")
        btn_del.set_tooltip_text("Delete")
        btn_del.connect('clicked', lambda b: self._on_delete_clicked(item_id))
        actions.append(btn_del)

        meta_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        meta_row.add_css_class("clipboard-item-meta")
        item_box.append(meta_row)

        meta_row.append(Gtk.Label(label=utils.format_time(timestamp)))
        meta_row.append(Gtk.Label(hexpand=True))
        if not content.startswith("IMAGE::"):
            meta_row.append(Gtk.Label(label=f"{len(content)} chars"))

        row.set_child(item_box)
        return row
