#!/usr/bin/env python3
"""AnduinOS Appearance — tweak taskbar style and position."""

import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
gi.require_version('Gio', '2.0')
from gi.repository import Gtk, Gdk, GLib, Adw, Gio
import subprocess
import shutil
import sys
import warnings
import gettext
import os
import threading
from pathlib import Path
warnings.filterwarnings('ignore', category=DeprecationWarning, module='gi')
# suppress Adwaita deprecation warnings for APIs whose replacements
# (add_page, toast_overlay) do not yet exist in Ubuntu 24.04's Adwaita version
warnings.filterwarnings('ignore', message=r'.*Adw\.PreferencesWindow\.add.*', category=DeprecationWarning)
warnings.filterwarnings('ignore', message=r'.*Adw\.PreferencesWindow\.add_toast.*', category=DeprecationWarning)

# ── i18n ─────────────────────────────────────────────────────────
_LOCALE_DIR = os.environ.get('ANDUINOS_APPEARANCE_LOCALE',
                              os.path.join(os.path.dirname(os.path.abspath(__file__)),
                                           '..', 'locale')
                              if os.path.isdir(os.path.join(os.path.dirname(os.path.abspath(__file__)),
                                                            '..', 'locale'))
                              else '/usr/share/locale')
try:
    _ = gettext.translation('anduinos-appearance', _LOCALE_DIR, fallback=True).gettext
except Exception:
    _ = lambda s: s

from anduinos_appearance import (
    ARC,
    DTP,
    apply_style_and_position,
    draw_preview,
)
from anduinos_appearance.layout import (
    dconf_read as _dconf_read,
    read_group_apps as _read_group_apps,
    read_use_launchers as _read_use_launchers,
    write_group_apps as _write_group_apps,
    write_use_launchers as _write_use_launchers,
    _smallest_monitor_height,
    MAX_SCREEN_HEIGHT,
)
from anduinos_appearance.profiles import (
    PANEL_EXTENSIONS,
    ProfileSwitchError,
    apply_profile,
    detect_profile,
    enabled_panel_extensions,
    requires_extension_changes,
)
from anduinos_appearance.progress import run_with_progress
from anduinos_appearance.weather import (
    prepare_weather_location,
    record_weather_consent,
    revoke_weather_consent,
    write_weather_location,
)
from anduinos_appearance.settings_transfer import (
    InvalidSettingsFile,
    SettingsImportError,
    dump_gnome_settings,
    import_gnome_settings,
    reset_desktop_settings,
)

ARC_UUID = 'arcmenu@arcmenu.com'
DTP_UUID = 'dash-to-panel@jderose9.github.com'


POS_LABELS = {'bottom': _('Bottom'), 'top': _('Top'), 'left': _('Left'), 'right': _('Right')}


def _ext_is_enabled(uuid):
    settings = Gio.Settings.new('org.gnome.shell')
    return (uuid in settings.get_strv('enabled-extensions') and
            uuid not in settings.get_strv('disabled-extensions'))


def taskbar_settings():
    """Load the extension's own schema without requiring a global installation."""
    source = Gio.SettingsSchemaSource.get_default()
    schema_id = 'org.gnome.shell.extensions.dash-to-panel'
    for data_dir in [GLib.get_user_data_dir(), *GLib.get_system_data_dirs()]:
        directory = Path(data_dir) / 'gnome-shell/extensions' / DTP_UUID / 'schemas'
        if not (directory / 'gschemas.compiled').is_file():
            continue
        try:
            source = Gio.SettingsSchemaSource.new_from_directory(str(directory), source, False)
            schema = source.lookup(schema_id, False)
            if schema:
                break
        except GLib.Error:
            continue
    else:
        schema = source.lookup(schema_id, True) if source else None
    if not schema or not all(schema.has_key(key) for key in (
        'intellihide', 'intellihide-use-pointer',
    )):
        return None
    return Gio.Settings.new_full(schema, None, None)


def taskbar_behavior_group(settings):
    group = Adw.PreferencesGroup(title=_('Behavior'))
    hide = Adw.SwitchRow(title=_('Automatically hide the taskbar'))
    pointer = Adw.SwitchRow(title=_('Show taskbar when pointer reaches screen edge'))
    group.add(hide)
    group.add(pointer)
    if settings is None:
        group.set_sensitive(False)
        return group
    settings.bind('intellihide', hide, 'active', Gio.SettingsBindFlags.DEFAULT)
    settings.bind('intellihide-use-pointer', pointer, 'active',
                  Gio.SettingsBindFlags.DEFAULT | Gio.SettingsBindFlags.NO_SENSITIVITY)

    def sync_sensitive(*_args):
        pointer.set_sensitive(settings.get_boolean('intellihide') and
                              settings.is_writable('intellihide-use-pointer'))

    settings.connect('changed::intellihide', sync_sensitive)
    settings.connect('writable-changed::intellihide-use-pointer', sync_sensitive)
    sync_sensitive()
    return group


# ── style page ───────────────────────────────────────────────────

class StylePage(Adw.PreferencesPage):
    def __init__(self, win):
        super().__init__()
        self.win = win
        self.set_title(_('Taskbar Style'))
        self.set_icon_name('preferences-desktop-theme-symbolic')

        # ── Preview group ──
        preview_group = Adw.PreferencesGroup()
        preview_group.set_title(_('Preview'))
        preview_group.set_description(_('Choose the visual style of your taskbar.'))

        card = Gtk.Frame()
        card.add_css_class('card')
        self.preview = Gtk.DrawingArea()
        self.preview.set_size_request(340, 110)
        self.preview.set_draw_func(self._draw, win.current_style, win.current_position)
        card.set_child(self.preview)
        preview_group.add(card)
        self.add(preview_group)

        # ── Layout group ──
        layout_group = Adw.PreferencesGroup()
        layout_group.set_title(_('Layout'))

        box = Gtk.FlowBox()
        box.set_selection_mode(Gtk.SelectionMode.NONE)
        box.set_column_spacing(12)
        box.set_row_spacing(6)
        box.set_max_children_per_line(4)
        self.btn_centered = Gtk.Button(label='  ' + _('Centered') + '  ')
        self.btn_centered.connect('clicked', self._on_click, 'eleven')
        self.btn10 = Gtk.Button(label='  ' + _('Classic') + '  ')
        self.btn10.connect('clicked', self._on_click, 'classic')
        self.btn_sep = Gtk.Button(label='  ' + _('Separated') + '  ')
        self.btn_sep.connect('clicked', self._on_click, 'separated')
        self.btn_gnome = Gtk.Button(label='  ' + _('GNOME') + '  ')
        self.btn_gnome.connect('clicked', self._on_click, 'gnome')
        box.append(self.btn10)
        box.append(self.btn_sep)
        box.append(self.btn_centered)
        box.append(self.btn_gnome)
        layout_group.add(box)

        # ── Position buttons ──
        self.pos_btns = {}
        pos_box = Gtk.FlowBox()
        self.pos_box = pos_box
        pos_box.set_homogeneous(False)
        pos_box.set_selection_mode(Gtk.SelectionMode.NONE)
        pos_box.set_row_spacing(6)
        pos_box.set_column_spacing(6)
        pos_box.set_margin_top(12)
        for pos_label in ['bottom', 'top', 'left', 'right']:
            btn = Gtk.ToggleButton(label=POS_LABELS[pos_label])
            btn.connect('toggled', self._on_pos_toggled, pos_label)
            pos_box.append(btn)
            self.pos_btns[pos_label] = btn
        layout_group.add(pos_box)
        self.add(layout_group)

        self._highlight_pos()

        self.taskbar_settings = taskbar_settings()
        self.behavior_group = taskbar_behavior_group(self.taskbar_settings)
        self.add(self.behavior_group)

        # ── Group apps (Classic only) ──
        self.group_expander = Adw.ExpanderRow()
        self.group_expander.set_title(_('Group apps'))
        self.group_expander.set_subtitle(_('Combine multiple windows of one app into a single icon'))
        self.group_row = Adw.SwitchRow()
        self.group_row.set_title(_('Enable grouping'))
        self.group_row.set_active(_read_group_apps())
        self.group_row.connect('notify::active', self._on_group_toggled)
        self.group_expander.add_row(self.group_row)

        # ── Vista taskbar behavior (Classic only) ──
        self.vista_row = Adw.SwitchRow()
        self.vista_row.set_title(_('Use Vista taskbar behavior'))
        self.vista_row.set_subtitle(_('Keep launchers separate from running apps'))
        self.vista_row.set_active(_read_use_launchers())
        self.vista_row.connect('notify::active', self._on_vista_toggled)
        self.group_expander.add_row(self.vista_row)

        self.group_group = Adw.PreferencesGroup()
        self.group_group.add(self.group_expander)
        self.add(self.group_group)

        self._highlight_btn()
        self._sync_group_visibility()

    def _draw(self, area, cr, w, h, style, position):
        draw_preview(area, cr, w, h, style, position)

    def _on_click(self, btn, style):
        if self.win._profile_busy:
            return
        enabled = enabled_panel_extensions(self.win._shell_settings)
        if self.win._profile_failed or requires_extension_changes(style, enabled):
            self.win._switch_profile(style)
            return
        # GNOME has no configurable Dash to Panel position.
        if style == 'gnome':
            return
        if apply_style_and_position(style, self.win.current_position):
            self.win.current_style = style
            self.win._sync_all_highlights()
            self._sync_group_visibility()
            self.win._refresh_previews()
            self.win._toast(_('✓ Applied — {style} | {pos}').format(style=self._label(style), pos=POS_LABELS[self.win.current_position]))
        else:
            self.win._toast(_('✗ Failed to apply style'))

    def _on_group_toggled(self, switch_row, pspec):
        if self.win.current_style in ('classic', 'separated'):
            _write_group_apps(switch_row.get_active())
            self._sync_vista_visibility()

    def _on_vista_toggled(self, switch_row, pspec):
        if self.win.current_style in ('classic', 'separated'):
            _write_use_launchers(switch_row.get_active())

    def _highlight_btn(self):
        s = None if self.win._profile_failed else self.win.current_style
        self.btn10.add_css_class('suggested-action') if s == 'classic' else self.btn10.remove_css_class('suggested-action')
        self.btn_sep.add_css_class('suggested-action') if s == 'separated' else self.btn_sep.remove_css_class('suggested-action')
        self.btn_centered.add_css_class('suggested-action') if s == 'eleven' else self.btn_centered.remove_css_class('suggested-action')
        self.btn_gnome.add_css_class('suggested-action') if s == 'gnome' else self.btn_gnome.remove_css_class('suggested-action')
        taskbar_active = s in ('classic', 'separated', 'eleven')
        self.pos_box.set_sensitive(taskbar_active)
        self.behavior_group.set_sensitive(taskbar_active and self.taskbar_settings is not None)

    def _on_pos_toggled(self, btn, pos):
        if self.win.current_style == 'gnome' or self.win._profile_busy or self.win._profile_failed:
            return
        if not btn.get_active():
            if pos == self.win.current_position:
                btn.handler_block_by_func(self._on_pos_toggled)
                btn.set_active(True)
                btn.handler_unblock_by_func(self._on_pos_toggled)
            return
        for p, other in self.pos_btns.items():
            if p != pos:
                other.handler_block_by_func(self._on_pos_toggled)
                other.set_active(False)
                other.handler_unblock_by_func(self._on_pos_toggled)
        if apply_style_and_position(self.win.current_style, pos):
            self.win.current_position = pos
            self._highlight_pos()
            self.win._refresh_previews()
            self.win._toast(_('✓ Applied — {style} | {pos}').format(style=self._label(self.win.current_style), pos=POS_LABELS[pos]))
        else:
            self.win._toast(_('✗ Failed to apply position'))

    def _highlight_pos(self):
        for p, btn in self.pos_btns.items():
            btn.handler_block_by_func(self._on_pos_toggled)
            if self.win.current_style != 'gnome' and p == self.win.current_position:
                btn.set_active(True)
                btn.add_css_class('suggested-action')
            else:
                btn.set_active(False)
                btn.remove_css_class('suggested-action')
            btn.handler_unblock_by_func(self._on_pos_toggled)

    def _sync_group_visibility(self):
        self.group_group.set_sensitive(not self.win._profile_failed)
        if self.win.current_style in ('classic', 'separated'):
            self.group_group.set_visible(True)
            self.group_expander.set_enable_expansion(True)
            self.group_row.set_active(_read_group_apps())
            self._sync_vista_visibility()
        else:
            self.group_group.set_visible(False)

    def _sync_vista_visibility(self):
        if _read_group_apps():
            self.vista_row.set_visible(False)
        else:
            self.vista_row.set_visible(True)
            self.vista_row.set_active(_read_use_launchers())

    @staticmethod
    def _label(s):
        if s == 'gnome':
            return _('GNOME')
        if s == 'eleven':
            return _('Centered')
        if s == 'separated':
            return _('Separated')
        return _('Classic')


# ── panel widgets page ───────────────────────────────────────────

WIDGETS = {
    'weather': (_('Show Weather'), 'simple-weather@romanlefler.com'),
    'network': (_('Show Network'), 'network-stats@gnome.noroadsleft.xyz'),
    'desktop_icons': (_('Show Desktop Icons'), 'ding@rastersoft.com'),
}


class PanelWidgetsPage(Adw.PreferencesPage):
    def __init__(self, win):
        super().__init__()
        self.win = win
        self._busy = False
        self._weather_busy = False
        # Read the effective GSettings value, including the system schema
        # default. A fresh Live user has no dconf override even though DING
        # and other extensions are enabled by AnduinOS's schema override.
        self._shell_settings = Gio.Settings.new('org.gnome.shell')
        self._widget_rows = {}
        self.set_title(_('Panel Widgets'))
        self.set_icon_name('application-x-addon-symbolic')

        group = Adw.PreferencesGroup()
        group.set_title(_('Panel Indicators'))
        group.set_description(_('Toggle panel indicator widgets on or off.'))

        self.weather_location_button = None
        for key, (label, uuid) in WIDGETS.items():
            row = Adw.SwitchRow()
            row.set_title(label)
            row.set_subtitle(uuid)
            row.set_active(uuid in self._shell_settings.get_strv('enabled-extensions'))
            row.connect('notify::active', self._on_toggle, key, uuid, label)
            self._widget_rows[uuid] = row
            if key == 'weather':
                self.weather_row = row
                self.weather_location_button = Gtk.Button.new_from_icon_name(
                    'find-location-symbolic'
                )
                self.weather_location_button.set_tooltip_text(
                    _('Update approximate weather location')
                )
                self.weather_location_button.set_valign(Gtk.Align.CENTER)
                self.weather_location_button.set_sensitive(row.get_active())
                self.weather_location_button.connect(
                    'clicked', self._on_weather_location_clicked
                )
                row.add_suffix(self.weather_location_button)
            group.add(row)

        self.add(group)
        self._shell_settings.connect('changed::enabled-extensions', self._sync_extension_rows)

        # ── Activities button toggle ──
        tb_group = Adw.PreferencesGroup()
        tb_group.set_title(_('Taskbar Elements'))
        tb_group.set_description(_('Show or hide built-in panel elements.'))

        self.activities_row = Adw.SwitchRow()
        self.activities_row.set_title(_('Show Activities Button'))
        self.activities_row.set_subtitle(_('Display the Activities button on the taskbar'))
        self.activities_row.set_active(_dconf_read(f'{ARC}/show-activities-button') != 'false')
        self.activities_row.connect('notify::active', self._on_activities_toggle)
        tb_group.add(self.activities_row)

        self.add(tb_group)

    def _sync_extension_rows(self, settings, key):
        enabled = set(settings.get_strv(key))
        self._busy = True
        try:
            for uuid, row in self._widget_rows.items():
                row.set_active(uuid in enabled)
            if self.weather_location_button is not None:
                self.weather_location_button.set_sensitive(self.weather_row.get_active())
        finally:
            self._busy = False

    def _on_activities_toggle(self, switch_row, pspec):
        if self._busy or self.win.current_style == 'gnome':
            return
        visible = switch_row.get_active()
        v = 'true' if visible else 'false'
        try:
            subprocess.run(['dconf', 'write', f'{ARC}/show-activities-button', v], check=True)
            # Force refresh Dash-to-Panel to override legacy 2.0.0 invisible state
            apply_style_and_position(self.win.current_style, self.win.current_position)
            self.win._toast(
                _('✓ Activities button shown')
                if visible else _('✓ Activities button hidden')
            )
        except subprocess.CalledProcessError:
            self.win._toast(_('✗ Failed to toggle activities button'))
            self._busy = True
            switch_row.set_active(not visible)
            self._busy = False

    def _on_toggle(self, switch_row, pspec, key, uuid, label):
        if self._busy:
            return
        state = switch_row.get_active()
        if key == 'weather' and state:
            self._show_weather_consent(
                lambda: self._start_weather_location(
                    switch_row=switch_row,
                    uuid=uuid,
                    enable_after=True,
                ),
                lambda: self._reject_weather_enable(switch_row),
            )
            return

        action = 'enable' if state else 'disable'
        try:
            subprocess.run(['gnome-extensions', action, uuid], check=True)
        except subprocess.CalledProcessError:
            self.win._toast(
                (_('✗ Failed to enable {label}') if state
                 else _('✗ Failed to disable {label}')).format(label=label)
            )
            # revert switch
            self._busy = True
            switch_row.set_active(not state)
            self._busy = False
            return

        if key == 'weather':
            try:
                revoke_weather_consent()
            except Exception:
                self.win._toast(_('Weather was disabled, but its saved permission could not be cleared.'))
            self.weather_location_button.set_sensitive(False)
        self.win._toast(
            (_('✓ {label} enabled') if state
             else _('✓ {label} disabled')).format(label=label)
        )

    def _show_weather_consent(self, on_accept, on_reject):
        body = _(
            '1. Approximate location (ipapi.co)\n'
            'AnduinOS will make one HTTPS request to ipapi.co. Its operator, '
            'Kloudend, Inc., will see your public IP address and may use it to '
            'estimate your city, country, and approximate coordinates.\n\n'
            '2. Weather data (Open-Meteo)\n'
            'The weather extension will send the selected approximate coordinates '
            'to Open-Meteo, operated by OpenMeteo GmbH. The service will also see '
            'your public IP address and may retain API logs for up to 90 days.\n\n'
            'AnduinOS is not affiliated with, sponsored by, or endorsed by either '
            'third-party service. If location detection fails, AnduinOS will use an '
            'offline estimate based on your system timezone.\n\n'
            'Privacy policies:\n'
            'https://ipapi.co/privacy/\n'
            'https://open-meteo.com/en/terms'
        )
        dialog = Adw.MessageDialog.new(
            self.win,
            _('Weather Services Privacy Agreement'),
            body,
        )
        dialog.add_response('reject', _('Decline'))
        dialog.add_response('accept', _('Agree to All'))
        dialog.set_default_response('reject')
        dialog.set_close_response('reject')
        dialog.set_response_appearance('accept', Adw.ResponseAppearance.SUGGESTED)

        def response(dlg, response_id):
            dlg.destroy()
            if response_id == 'accept':
                on_accept()
            else:
                on_reject()

        dialog.connect('response', response)
        dialog.present()

    def _reject_weather_enable(self, switch_row):
        self._busy = True
        switch_row.set_active(False)
        self._busy = False
        self.win._toast(_('Weather was not enabled because permission was declined.'))

    def _on_weather_location_clicked(self, button):
        if self._weather_busy:
            return
        self._show_weather_consent(
            lambda: self._start_weather_location(enable_after=False),
            lambda: None,
        )

    def _start_weather_location(
        self,
        switch_row=None,
        uuid='simple-weather@romanlefler.com',
        enable_after=False,
    ):
        if self._weather_busy:
            return
        try:
            record_weather_consent()
        except Exception:
            self.win._toast(_('Failed to save weather permission.'))
            if switch_row is not None:
                self._reject_weather_enable(switch_row)
            return

        self._weather_busy = True
        self.weather_row.set_sensitive(False)
        if self.weather_location_button is not None:
            self.weather_location_button.set_sensitive(False)
        self.win._toast(_('Determining your approximate location…'))

        def worker():
            try:
                location, used_network = prepare_weather_location()
                write_weather_location(location)
                error = None
                location_name = location.name
            except Exception as exc:
                error = exc
                location_name = ''
                used_network = False
            GLib.idle_add(
                self._finish_weather_location,
                switch_row,
                uuid,
                enable_after,
                location_name,
                used_network,
                error,
            )

        threading.Thread(target=worker, daemon=True).start()

    def _finish_weather_location(
        self,
        switch_row,
        uuid,
        enable_after,
        location_name,
        used_network,
        error,
    ):
        if error is None and enable_after:
            try:
                subprocess.run(['gnome-extensions', 'enable', uuid], check=True)
            except subprocess.CalledProcessError as exc:
                error = exc

        self._weather_busy = False
        self.weather_row.set_sensitive(True)
        if error is not None:
            if switch_row is not None:
                self._busy = True
                switch_row.set_active(False)
                self._busy = False
            self.weather_location_button.set_sensitive(
                _ext_is_enabled('simple-weather@romanlefler.com')
            )
            self.win._toast(_('Failed to configure the weather extension.'))
            return GLib.SOURCE_REMOVE

        self.weather_location_button.set_sensitive(True)
        if used_network:
            self.win._toast(
                _('Weather location set to {location}.').format(location=location_name)
            )
        else:
            self.win._toast(
                _('Location service unavailable; using {location} from your timezone.').format(
                    location=location_name
                )
            )
        return GLib.SOURCE_REMOVE


# ── advanced page ────────────────────────────────────────────────

class AdvancedPage(Adw.PreferencesPage):
    def __init__(self, win):
        super().__init__()
        self.win = win
        self.set_title(_('Advanced'))
        self.set_icon_name('preferences-other-symbolic')

        # ── Appearance group ──
        appear = Adw.PreferencesGroup()
        appear.set_title(_('Appearance'))

        row_wallpaper = Adw.ActionRow()
        row_wallpaper.set_title(_('Wallpaper'))
        btn_wallpaper = Gtk.Button(label=_('Open'))
        btn_wallpaper.set_valign(Gtk.Align.CENTER)
        btn_wallpaper.connect('clicked', lambda b: subprocess.Popen(
            ['gnome-control-center', 'background']))
        row_wallpaper.add_suffix(btn_wallpaper)
        appear.add(row_wallpaper)

        if shutil.which('gnome-tweaks'):
            row_tweaks = Adw.ActionRow()
            row_tweaks.set_title(_('Tweaks'))
            btn_tweaks = Gtk.Button(label=_('Open'))
            btn_tweaks.set_valign(Gtk.Align.CENTER)
            btn_tweaks.connect('clicked', lambda b: subprocess.Popen(['gnome-tweaks']))
            row_tweaks.add_suffix(btn_tweaks)
            appear.add(row_tweaks)

        self.add(appear)

        # ── Extension Settings group ──
        ext = Adw.PreferencesGroup()
        ext.set_title(_('Extension Settings'))
        ext.set_description(_('Open the preference windows of the panel extensions.'))

        ext_defs = [
            (_('ArcMenu'), 'arcmenu@arcmenu.com'),
            (_('Dash-to-Panel'), 'dash-to-panel@jderose9.github.com'),
            (_('Simple Weather'), 'simple-weather@romanlefler.com'),
            (_('Network Stats'), 'network-stats@gnome.noroadsleft.xyz'),
        ]
        self._ext_rows = []
        for title, uuid in ext_defs:
            row = Adw.ActionRow()
            row.set_title(title)
            btn = Gtk.Button(label=_('Open'))
            btn.set_valign(Gtk.Align.CENTER)
            btn.connect('clicked', lambda b, u=uuid: subprocess.Popen(
                ['gnome-extensions', 'prefs', u]))
            row.add_suffix(btn)
            ext.add(row)
            self._ext_rows.append((row, uuid))

        self.add(ext)
        self.connect('map', lambda w: self._refresh_ext_rows())

        # ── Backup and Restore group ──
        transfer = Adw.PreferencesGroup()
        transfer.set_title(_('Backup and Restore'))
        transfer.set_description(
            _('Back up GNOME settings, layouts, and extension preferences. '
              'Personal files are not included.')
        )

        row_export = Adw.ActionRow()
        row_export.set_title(_('Export GNOME settings'))
        row_export.set_subtitle(_('Save the current settings as a readable INI file'))
        btn_export = Gtk.Button(label=_('Export…'))
        btn_export.set_valign(Gtk.Align.CENTER)
        btn_export.connect('clicked', self._on_export)
        row_export.add_suffix(btn_export)
        transfer.add(row_export)

        row_import = Adw.ActionRow()
        row_import.set_title(_('Import GNOME settings'))
        row_import.set_subtitle(_('Restore settings from a dconf INI file'))
        btn_import = Gtk.Button(label=_('Import…'))
        btn_import.set_valign(Gtk.Align.CENTER)
        btn_import.connect('clicked', self._on_import)
        row_import.add_suffix(btn_import)
        transfer.add(row_import)

        self.add(transfer)

        # ── Danger Zone group ──
        danger = Adw.PreferencesGroup()
        danger.set_title(_('Danger Zone'))

        row_reset = Adw.ActionRow()
        row_reset.set_title(_('Reset desktop settings'))
        row_reset.set_subtitle(_('Restore desktop appearance, search, app folders, and privacy to factory defaults'))
        btn_reset = Gtk.Button(label=_('Reset'))
        btn_reset.set_valign(Gtk.Align.CENTER)
        btn_reset.add_css_class('destructive-action')
        btn_reset.connect('clicked', self._on_reset)
        row_reset.add_suffix(btn_reset)
        danger.add(row_reset)

        self.add(danger)

    def _refresh_ext_rows(self):
        for row, uuid in self._ext_rows:
            row.set_visible(_ext_is_enabled(uuid))

    @staticmethod
    def _ini_filters():
        ini_filter = Gtk.FileFilter()
        ini_filter.set_name(_('INI settings files'))
        ini_filter.add_pattern('*.ini')
        filters = Gio.ListStore.new(Gtk.FileFilter)
        filters.append(ini_filter)
        return filters

    def _on_export(self, btn):
        dialog = Gtk.FileDialog.new()
        dialog.set_title(_('Export GNOME Settings'))
        dialog.set_initial_name('AnduinOS-GNOME-settings.ini')
        dialog.set_filters(self._ini_filters())
        dialog.save(self.win, None, self._on_export_file_chosen)

    def _on_export_file_chosen(self, dialog, result):
        try:
            selected = dialog.save_finish(result)
            path = selected.get_path() if selected else None
            if not path:
                raise OSError('The selected destination is not a local file')
            Path(path).write_bytes(dump_gnome_settings())
        except GLib.Error as error:
            if not error.matches(Gio.io_error_quark(), Gio.IOErrorEnum.CANCELLED):
                print(f'File dialog error: {error.message}', file=sys.stderr)
                self.win._toast(_('✗ Failed to export GNOME settings'))
            return
        except (OSError, subprocess.CalledProcessError) as error:
            print(f'Failed to export GNOME settings: {error}', file=sys.stderr)
            self.win._toast(_('✗ Failed to export GNOME settings'))
            return

        self.win._toast(_('✓ GNOME settings exported'))

    def _on_import(self, btn):
        dialog = Gtk.FileDialog.new()
        dialog.set_title(_('Import GNOME Settings'))
        dialog.set_filters(self._ini_filters())
        dialog.open(self.win, None, self._on_import_file_chosen)

    def _on_import_file_chosen(self, dialog, result):
        try:
            selected = dialog.open_finish(result)
            path = selected.get_path() if selected else None
            if not path:
                raise OSError('The selected source is not a local file')
            contents = Path(path).read_bytes()
        except GLib.Error as error:
            if not error.matches(Gio.io_error_quark(), Gio.IOErrorEnum.CANCELLED):
                print(f'File dialog error: {error.message}', file=sys.stderr)
                self.win._toast(_('✗ Failed to read the settings file'))
            return
        except OSError as error:
            print(f'Failed to read GNOME settings: {error}', file=sys.stderr)
            self.win._toast(_('✗ Failed to read the settings file'))
            return

        choice = Adw.MessageDialog.new(
            self.win,
            _('How should these settings be imported?'),
            _('Merge import keeps current values that are absent from the file.\n\n'
              'Strict restore resets all /org/gnome/ settings first; values absent '
              'from the file return to their defaults. A safety backup is saved '
              'before either operation.')
        )
        choice.add_response('cancel', _('Cancel'))
        choice.add_response('merge', _('Merge Import'))
        choice.add_response('strict', _('Strict Restore'))
        choice.set_default_response('merge')
        choice.set_close_response('cancel')
        choice.set_response_appearance('merge', Adw.ResponseAppearance.SUGGESTED)
        choice.set_response_appearance('strict', Adw.ResponseAppearance.DESTRUCTIVE)
        choice.connect(
            'response',
            lambda dlg, response: self._on_import_mode_chosen(
                dlg,
                response,
                contents,
            ),
        )
        choice.present()

    def _on_import_mode_chosen(self, dialog, response, contents):
        dialog.destroy()
        if response not in ('merge', 'strict'):
            return

        try:
            import_gnome_settings(contents, strict=response == 'strict')
        except InvalidSettingsFile as error:
            print(f'Invalid GNOME settings file: {error}', file=sys.stderr)
            self.win._toast(_('✗ The selected file is not a valid dconf INI file'))
            return
        except SettingsImportError as error:
            print(f'Failed to import GNOME settings: {error}', file=sys.stderr)
            if error.rollback_succeeded:
                self.win._toast(_('✗ Import failed; previous settings were restored'))
            else:
                self.win._toast(_('✗ Import and automatic rollback both failed'))
            return
        except (OSError, subprocess.CalledProcessError) as error:
            print(f'Failed to prepare GNOME settings import: {error}', file=sys.stderr)
            self.win._toast(_('✗ Failed to import GNOME settings'))
            return

        self.win.current_style, self.win.current_position = detect_profile(self.win._shell_settings)
        self.win._sync_all_highlights()
        self.win._refresh_previews()
        self._refresh_ext_rows()
        self.win._toast(_('✓ Settings imported. Log out and back in to apply all changes.'))

    def _on_reset(self, btn):
        def do_reset():
            try:
                reset_desktop_settings()
                self.win._toast(_('✓ Desktop settings reset. Log out and back in to apply all changes.'))
            except (OSError, subprocess.CalledProcessError):
                self.win._toast(_('✗ Failed to reset'))

        dlg = Adw.MessageDialog.new(
            self.win,
            _('Reset desktop settings?'),
            _('This will restore extensions, panel layout, search preferences, app folders, '
              'wallpaper, interface, and privacy preferences to factory defaults.')
        )
        dlg.add_response('cancel', _('Cancel'))
        dlg.add_response('reset', _('Reset'))
        dlg.set_response_appearance('reset', Adw.ResponseAppearance.DESTRUCTIVE)
        dlg.set_default_response('cancel')
        dlg.connect('response', lambda d, resp: (d.destroy(), do_reset() if resp == 'reset' else None))
        dlg.present()


def _find_headerbar(widget):
    """Recursively walk the widget tree to find an Adw.HeaderBar."""
    if isinstance(widget, Adw.HeaderBar):
        return widget
    child = widget.get_first_child() if hasattr(widget, 'get_first_child') else None
    while child:
        result = _find_headerbar(child)
        if result:
            return result
        child = child.get_next_sibling() if hasattr(child, 'get_next_sibling') else None
    return None


# ── GDM wallpaper page ────────────────────────────────────────────

GDM_OUTPUT = '/var/lib/anduinos-gdm3-wallpaper/anduinos-theme.gresource'


class GdmWallpaperPage(Adw.PreferencesPage):
    def __init__(self, win):
        super().__init__()
        self.win = win
        self._wallpaper_path = ''
        self.set_title(_('GDM Wallpaper'))
        self.set_icon_name('preferences-desktop-wallpaper-symbolic')

        # ── Choose wallpaper ──
        choose = Adw.PreferencesGroup()
        choose.set_title(_('Login Screen Wallpaper'))
        choose.set_description(_('Choose an image for the GDM login screen background.'))

        self.preview_img = Gtk.Picture()
        self.preview_img.set_size_request(340, 190)
        self.preview_img.set_content_fit(Gtk.ContentFit.COVER)
        self.preview_img.add_css_class('card')
        self.preview_img.set_visible(False)
        choose.add(self.preview_img)

        btn_row = Adw.ActionRow()
        btn_row.set_title(_('Select image'))
        btn = Gtk.Button(label=_('Choose…'))
        btn.set_valign(Gtk.Align.CENTER)
        btn.connect('clicked', self._on_choose)
        btn_row.add_suffix(btn)
        choose.add(btn_row)
        self.add(choose)

        # ── Apply ──
        apply_grp = Adw.PreferencesGroup()
        apply_btn = Gtk.Button(label=_('Apply to Login Screen'))
        apply_btn.add_css_class('suggested-action')
        apply_btn.set_halign(Gtk.Align.CENTER)
        apply_btn.set_margin_top(12)
        apply_btn.set_margin_bottom(12)
        apply_btn.connect('clicked', self._on_apply)
        apply_grp.add(apply_btn)
        self.add(apply_grp)

    def _on_choose(self, btn):
        dialog = Gtk.FileDialog.new()
        flt = Gtk.FileFilter()
        flt.set_name(_('Images'))
        for mime in ['image/png', 'image/jpeg']:
            flt.add_mime_type(mime)
        filters = Gio.ListStore.new(Gtk.FileFilter)
        filters.append(flt)
        dialog.set_filters(filters)
        dialog.open(self.win, None, self._on_file_chosen)

    def _on_file_chosen(self, dialog, result):
        try:
            f = dialog.open_finish(result)
            if f:
                self._wallpaper_path = f.get_path()
                self.preview_img.set_filename(self._wallpaper_path)
                self.preview_img.set_visible(True)
        except GLib.Error as e:
            if not e.matches(Gio.io_error_quark(), Gio.IOErrorEnum.CANCELLED):
                print(f"File dialog error: {e.message}", file=sys.stderr)

    def _on_apply(self, btn):
        if not self._wallpaper_path:
            self.win._toast(_('✗ Please select an image first'))
            return
        btn.set_sensitive(False)
        proc = Gio.Subprocess.new(
            ['pkexec', 'anduinos-gdm-set-wallpaper',
             '--wallpaper', self._wallpaper_path,
             '--output', GDM_OUTPUT],
            Gio.SubprocessFlags.NONE,
        )
        proc.wait_async(None, self._on_apply_done, btn)

    def _on_apply_done(self, proc, result, btn):
        ok = proc.wait_finish(result)
        btn.set_sensitive(True)
        if ok:
            self.win._toast(_('✓ GDM wallpaper applied'))
        else:
            self.win._toast(_('✗ Failed to set GDM wallpaper'))


# ── main window ──────────────────────────────────────────────────

class AppearanceWindow(Adw.PreferencesWindow):
    def __init__(self, app):
        super().__init__(application=app)
        self.set_title(_('AnduinOS Appearance'))
        self.set_default_size(910, 660)
        self.set_icon_name('anduinos-appearance')

        self._profile_busy = False
        self._shell_settings = Gio.Settings.new('org.gnome.shell')
        self._profile_failed = False
        self.current_style, self.current_position = detect_profile(self._shell_settings)

        # Pages — added in sidebar order
        self.style_page = StylePage(self)
        self.widgets_page = PanelWidgetsPage(self)
        self.gdm_page = GdmWallpaperPage(self)
        self.advanced_page = AdvancedPage(self)

        self.add(self.style_page)
        self.add(self.widgets_page)
        self.add(self.gdm_page)
        self.add(self.advanced_page)
        self.widgets_page.activities_row.set_sensitive(self.current_style != 'gnome' and not self._profile_failed)
        self._shell_settings.connect('changed::enabled-extensions', self._on_profile_extensions_changed)
        self._shell_settings.connect('changed::disabled-extensions', self._on_profile_extensions_changed)
        self.connect('close-request', lambda *_: self._profile_busy)

        # Inject hamburger menu into internal headerbar on realize
        self.connect('realize', self._inject_menu)

    def _inject_menu(self, window):
        """Find the internal Adw.HeaderBar and add an About menu button."""
        menu = Gio.Menu()
        menu.append(_('About AnduinOS Appearance'), 'app.about')

        menu_btn = Gtk.MenuButton()
        menu_btn.set_icon_name('open-menu-symbolic')
        menu_btn.set_menu_model(menu)

        header = _find_headerbar(window)
        if header:
            header.pack_end(menu_btn)

    def _toast(self, message: str):
        self.add_toast(Adw.Toast.new(message))

    def _sync_all_highlights(self):
        self.style_page._highlight_btn()
        self.style_page._highlight_pos()
        self.style_page._sync_group_visibility()
        self.widgets_page.activities_row.set_sensitive(self.current_style != 'gnome' and not self._profile_failed)

    def _on_profile_extensions_changed(self, *_args):
        if self._profile_busy:
            return
        self.current_style, self.current_position = detect_profile(self._shell_settings)
        self._sync_all_highlights()
        self._refresh_previews()

    def _switch_profile(self, style):
        self._profile_busy = True
        position = self.current_position
        # GDK access stays on the GTK thread, never in the blocking worker.
        screen_height = _smallest_monitor_height() or MAX_SCREEN_HEIGHT
        self._progress_window = run_with_progress(
            self,
            _('Switching desktop layout… Please wait.'),
            lambda: apply_profile(style, position, screen_height),
            lambda error: self._finish_profile_switch(style, error),
        )

    def _finish_profile_switch(self, style, error):
        self._profile_busy = False
        self._progress_window = None
        self.current_style, self.current_position = detect_profile(self._shell_settings)
        enabled = enabled_panel_extensions(self._shell_settings)
        if error is None and requires_extension_changes(style, enabled):
            error = RuntimeError('Extension state did not match the requested profile')
        self._profile_failed = error is not None
        self._sync_all_highlights()
        self._refresh_previews()
        self.advanced_page._refresh_ext_rows()
        if error is None:
            self._toast(_('✓ Applied — {style}').format(style=StylePage._label(style)))
            return
        print(f'Failed to switch desktop profile: {error}', file=sys.stderr)
        if isinstance(error, ProfileSwitchError) and error.restart_required:
            text = _('An extension is in an error state. Log out and back in before trying again.')
        elif isinstance(error, ProfileSwitchError) and error.extension and error.action in ('enable', 'disable'):
            names = dict(zip(PANEL_EXTENSIONS, ('Dash to Panel', 'ArcMenu', 'Blur my Shell')))
            text = (_('Failed to enable {extension}.') if error.action == 'enable'
                    else _('Failed to disable {extension}.')).format(extension=names[error.extension])
        else:
            text = _('Failed to switch desktop layout. Check the extension settings and try again.')
        dialog = Adw.MessageDialog.new(self, _('Desktop layout could not be applied'), text)
        dialog.add_response('close', _('Close'))
        dialog.set_close_response('close')
        dialog.present()

    def _refresh_previews(self):
        for page in [self.style_page]:
            page.preview.set_draw_func(page._draw, self.current_style, self.current_position)
            page.preview.queue_draw()


# ── application ──────────────────────────────────────────────────

class AppearanceApp(Adw.Application):
    def __init__(self):
        super().__init__(application_id='com.anduinos.Appearance')

    def do_startup(self):
        Adw.Application.do_startup(self)
        about_action = Gio.SimpleAction.new('about', None)
        about_action.connect('activate', self._on_about)
        self.add_action(about_action)

    def _on_about(self, action, param):
        dialog = Adw.AboutDialog()
        dialog.set_application_name(_('AnduinOS Appearance'))
        dialog.set_application_icon('anduinos-appearance')
        dialog.set_developer_name(_('AnduinOS Team'))
        dialog.set_website('https://www.anduinos.com')
        dialog.set_issue_url('https://github.com/AiursoftWeb/AnduinOS-Packages/issues')
        dialog.set_license_type(Gtk.License.GPL_3_0)
        dialog.set_version('2.0.5')
        dialog.present(self.get_active_window())

    def _on_win_destroyed(self, win):
        if hasattr(self, '_win'):
            del self._win

    def do_activate(self):
        if hasattr(self, '_win'):
            self._win.present()
            return
        self._win = AppearanceWindow(self)
        self._win.connect('destroy', self._on_win_destroyed)
        self._win.present()


def main():
    return AppearanceApp().run(None)


if __name__ == '__main__':
    raise SystemExit(main())
