#!/usr/bin/env python3
#--------------------------------------------------------------------------------------------------------
# Name: Linux Lite - Lite Tweaks
# Architecture: amd64
# Author: Jerry Bezencon
# Website: https://www.linuxliteos.com
# Language: Python/GTK4
# Licence: GPLv2
#--------------------------------------------------------------------------------------------------------

import gi
gi.require_version('Gtk', '4.0')

import os
import sys
import subprocess
import threading
import shutil
import re
import time
from pathlib import Path
from datetime import datetime
from gi.repository import Gtk, GLib, Gio, GObject, Gdk

APP_ID = "com.linuxliteos.litetweaks"
APP_TITLE = "Lite Tweaks"
ICON_PATH = "/usr/share/icons/Papirus/24x24/apps/lite-tweaks.png"
ICON_DIR = "/usr/share/liteappsicons/litetweaks"


# ============================================================================
# GTK4 helper widgets (replacements for Adw widgets)
# ============================================================================

class ActionRow(Gtk.ListBoxRow):
    """A GTK4 replacement for Adw.ActionRow."""
    def __init__(self, title="", subtitle="", **kwargs):
        super().__init__(**kwargs)
        self._prefix_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self._prefix_box.set_valign(Gtk.Align.CENTER)

        self._label_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        self._label_box.set_hexpand(True)
        self._label_box.set_valign(Gtk.Align.CENTER)

        self._title_label = Gtk.Label(label=title, xalign=0)
        self._title_label.set_ellipsize(3)  # PANGO_ELLIPSIZE_END
        self._label_box.append(self._title_label)

        self._subtitle_label = Gtk.Label(label=subtitle, xalign=0)
        self._subtitle_label.add_css_class("dim-label")
        self._subtitle_label.add_css_class("caption")
        self._subtitle_label.set_ellipsize(3)
        if subtitle:
            self._label_box.append(self._subtitle_label)
        self._subtitle_visible = bool(subtitle)

        self._suffix_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self._suffix_box.set_valign(Gtk.Align.CENTER)

        hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        hbox.set_margin_top(8)
        hbox.set_margin_bottom(8)
        hbox.set_margin_start(12)
        hbox.set_margin_end(12)
        hbox.append(self._prefix_box)
        hbox.append(self._label_box)
        hbox.append(self._suffix_box)

        self.set_child(hbox)

    def add_prefix(self, widget):
        self._prefix_box.append(widget)

    def add_suffix(self, widget):
        self._suffix_box.append(widget)

    def set_activatable_widget(self, widget):
        self.set_activatable(True)
        self._activatable_widget = widget
        self.connect("activate", lambda row: widget.activate() if hasattr(widget, 'activate') else None)

    def set_subtitle(self, text):
        self._subtitle_label.set_text(text)
        if text and not self._subtitle_visible:
            self._label_box.append(self._subtitle_label)
            self._subtitle_visible = True
        elif not text and self._subtitle_visible:
            self._label_box.remove(self._subtitle_label)
            self._subtitle_visible = False


class PreferencesGroup(Gtk.Box):
    """A GTK4 replacement for Adw.PreferencesGroup."""
    def __init__(self, title="", description="", **kwargs):
        super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6, **kwargs)

        if title:
            title_label = Gtk.Label(label=title, xalign=0)
            title_label.add_css_class("title-4")
            title_label.set_margin_start(6)
            self.append(title_label)

        if description:
            desc_label = Gtk.Label(label=description, xalign=0)
            desc_label.add_css_class("dim-label")
            desc_label.set_margin_start(6)
            self.append(desc_label)

        self._listbox = Gtk.ListBox()
        self._listbox.set_selection_mode(Gtk.SelectionMode.NONE)
        self._listbox.add_css_class("boxed-list")
        self.append(self._listbox)

    def add(self, row):
        if isinstance(row, Gtk.ListBoxRow):
            self._listbox.append(row)
        else:
            wrapper = Gtk.ListBoxRow()
            wrapper.set_child(row)
            self._listbox.append(wrapper)

    def remove(self, row):
        if isinstance(row, Gtk.ListBoxRow):
            self._listbox.remove(row)
        else:
            # Try to find the wrapper
            child = self._listbox.get_first_child()
            while child:
                if child.get_child() == row:
                    self._listbox.remove(child)
                    return
                child = child.get_next_sibling()


def show_alert(parent, heading, body, responses, callback, extra_child=None, appearances=None):
    """A GTK4 replacement for Adw.AlertDialog.

    Args:
        parent: Parent window
        heading: Dialog heading text
        body: Dialog body text
        responses: List of (id, label) tuples
        callback: Function(response_id) called when a button is clicked
        extra_child: Optional widget to add between body and buttons
        appearances: Optional dict mapping response_id to CSS class (e.g. 'suggested-action', 'destructive-action')
    """
    win = Gtk.Window(title=heading, transient_for=parent, modal=True, resizable=False)
    win.set_default_size(360, -1)

    vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
    vbox.set_margin_top(24)
    vbox.set_margin_bottom(24)
    vbox.set_margin_start(24)
    vbox.set_margin_end(24)

    heading_label = Gtk.Label(label=heading)
    heading_label.add_css_class("title-2")
    heading_label.set_wrap(True)
    vbox.append(heading_label)

    if body:
        body_label = Gtk.Label(label=body)
        body_label.set_wrap(True)
        body_label.set_xalign(0)
        vbox.append(body_label)

    if extra_child:
        vbox.append(extra_child)

    btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8, homogeneous=True)
    btn_box.set_margin_top(12)

    for resp_id, resp_label in responses:
        btn = Gtk.Button(label=resp_label)
        if appearances and resp_id in appearances:
            btn.add_css_class(appearances[resp_id])
        btn.connect("clicked", lambda b, rid=resp_id: (callback(rid), win.close()))
        btn_box.append(btn)

    vbox.append(btn_box)
    win.set_child(vbox)
    win.present()
    return win


# Browser cache paths (relative to HOME)
BROWSER_CACHES = {
    'brave': '.cache/BraveSoftware/Brave-Browser/Default/Cache/',
    'chrome': '.cache/google-chrome/',
    'chromium': '.cache/chromium/',
    'firefox': '.cache/mozilla/',
    'edge': '.cache/microsoft-edge/',
    'midori': '.cache/midori/',
    'opera': '.cache/opera/Cache/',
    'palemoon': '.cache/moonchild productions/pale moon/',
    'vivaldi': '.cache/vivaldi/',
}

# Browser desktop file mappings
BROWSER_DESKTOP_FILES = {
    'brave': 'brave-browser.desktop',
    'firefox': 'firefox.desktop',
    'chrome': 'google-chrome.desktop',
    'chromium': 'chromium-browser.desktop',
    'edge': 'microsoft-edge.desktop',
    'midori': 'midori.desktop',
    'opera': 'opera.desktop',
    'palemoon': 'palemoon.desktop',
    'vivaldi': 'vivaldi-stable.desktop',
}

BROWSER_COMMANDS = {
    'brave': 'brave-browser',
    'firefox': 'firefox',
    'chrome': 'google-chrome',
    'chromium': 'chromium-browser',
    'edge': 'microsoft-edge',
    'midori': 'midori',
    'opera': 'opera',
    'palemoon': 'palemoon',
    'vivaldi': 'vivaldi',
}


def get_dir_size(path):
    """Get size of directory in human readable format."""
    if not os.path.exists(path):
        return None
    try:
        result = subprocess.run(['du', '-sh', path], capture_output=True, text=True, timeout=30)
        if result.returncode == 0:
            size = result.stdout.split()[0]
            return f"{size}B"
    except:
        pass
    return None


def get_dir_size_bytes(path):
    """Get size of directory in bytes."""
    if not os.path.exists(path):
        return 0
    try:
        result = subprocess.run(['du', '-sb', path], capture_output=True, text=True, timeout=30)
        if result.returncode == 0:
            return int(result.stdout.split()[0])
    except:
        pass
    return 0


def run_command(cmd, shell=False):
    """Run a command and return (success, output)."""
    try:
        if shell:
            result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=300)
        else:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
        return result.returncode == 0, result.stdout + result.stderr
    except subprocess.TimeoutExpired:
        return False, "Command timed out"
    except Exception as e:
        return False, str(e)


def check_command_exists(cmd):
    """Check if a command exists in PATH."""
    return shutil.which(cmd) is not None


def check_internet():
    """Check if internet is available."""
    try:
        result = subprocess.run(['curl', '-sk', '--connect-timeout', '5', 'https://google.com/'],
                              capture_output=True, timeout=10)
        return result.returncode == 0
    except:
        return False


# ============================================================================
# Root Actions - These run with elevated privileges via pkexec
# ============================================================================

def root_action_apt_clean():
    """Clean the package cache."""
    print("Stage 1: Cleaning package cache...", flush=True)
    success, output = run_command(['apt-get', 'clean'])
    if success:
        print("  Package cache cleared.", flush=True)
    else:
        print(f"  Error: {output}", flush=True)
    return 0 if success else 1


def root_action_clear_mem():
    """Free up system memory."""
    print("Stage 1: Syncing filesystem...", flush=True)
    subprocess.run(['sync'])
    print("  Filesystem synced", flush=True)
    print("Stage 2: Dropping caches...", flush=True)
    with open('/proc/sys/vm/drop_caches', 'w') as f:
        f.write('3')
    print("  System memory freed.", flush=True)
    return 0


def _open_files_under(prefix):
    """Return paths under `prefix` currently held open by any process.

    Walks /proc/*/fd/* and resolves each symlink. As root this sees every
    process's FDs, so anything actively being written by syslog, rsyslog,
    cups, etc. is spared. Inaccessible PIDs are silently skipped."""
    open_set = set()
    try:
        pids = [p for p in os.listdir('/proc') if p.isdigit()]
    except OSError:
        return open_set
    for pid in pids:
        fddir = f'/proc/{pid}/fd'
        try:
            fds = os.listdir(fddir)
        except OSError:
            continue
        for fd in fds:
            try:
                target = os.readlink(os.path.join(fddir, fd))
            except OSError:
                continue
            if target.startswith(prefix + '/') or target == prefix:
                open_set.add(target)
    return open_set


def root_action_log_archives():
    """Empty every file in /var/log.

    Old behaviour was a fixed pattern list (*.gz, *.1 etc.); the previous
    revision broadened to "delete if not held open" but spared the active
    log writers, leaving the bulk of usage (syslog, auth.log, kern.log)
    on disk.

    The right semantic for an "empty all logs" action is logrotate's
    copytruncate strategy: files held open by a running process get
    truncated to 0 bytes (the writer's FD stays valid, next write lands
    at offset 0 of an empty file — works because log writers use O_APPEND);
    everything else gets unlinked. This frees real space AND preserves
    stateful binary databases like wtmp/btmp/lastlog that need to exist
    even when empty.

    /var/log/journal is excluded — that's the dedicated sysd_logs action
    using journalctl --vacuum to avoid fighting journald."""
    print("Stage 1: Scanning /var/log for active file handles...", flush=True)
    open_files = _open_files_under('/var/log')
    print(f"  {len(open_files)} file(s) held open — will truncate in place.", flush=True)

    # Service-specific log dirs that we ALWAYS fully delete (never just
    # truncate), even if a daemon has the active log open. apt and
    # dist-upgrade don't hold their logs between operations; samba and
    # vmware regenerate on next write/restart, and we'd rather reclaim
    # the inode than leave a phantom-FD'd zero-byte file lying around.
    ALWAYS_DELETE_DIRS = (
        '/var/log/apt',
        '/var/log/dist-upgrade',
        '/var/log/samba',
        '/var/log/vmware',
    )
    print("Stage 2: Purging service log dirs (apt, dist-upgrade, samba, vmware)...",
          flush=True)
    force_removed = 0
    for ad in ALWAYS_DELETE_DIRS:
        if not os.path.exists(ad):
            continue
        for sub_root, sub_dirs, sub_files in os.walk(ad):
            for f in sub_files:
                fp = os.path.join(sub_root, f)
                try:
                    os.unlink(fp)
                    force_removed += 1
                    open_files.discard(fp)  # don't double-process below
                except OSError:
                    pass
    print(f"  Force-deleted {force_removed} file(s) from service log dirs.",
          flush=True)

    print("Stage 3: Emptying remaining log files...", flush=True)
    truncated = 0
    removed = 0
    for root_dir, dirs, files in os.walk('/var/log'):
        if root_dir == '/var/log/journal' or root_dir.startswith('/var/log/journal/'):
            dirs[:] = []
            continue
        for f in files:
            fp = os.path.join(root_dir, f)
            try:
                if fp in open_files:
                    # Held open by a running process — truncate so the writer's
                    # FD stays valid. open(...,'w') would do this but we want
                    # explicit truncate+close, no buffering side effects.
                    with open(fp, 'wb'):
                        pass
                    truncated += 1
                else:
                    os.unlink(fp)
                    removed += 1
            except OSError:
                pass
    print(f"  Removed {removed} file(s), truncated {truncated} active log(s).",
          flush=True)

    # Stage 4: purge the systemd journal too. --rotate retires the active
    # file so journald releases its write handle and starts a new one;
    # --vacuum-size=1K then deletes every archived journal (since 1K is
    # smaller than any single journal file, this hits everything except
    # the brand-new active one — which we cannot delete while journald
    # is running anyway). --vacuum-time=1s would race with --rotate
    # because the just-rotated file is younger than 1 second.
    print("Stage 4: Purging systemd journal...", flush=True)
    subprocess.run(['journalctl', '--rotate'], capture_output=True)
    subprocess.run(['journalctl', '--vacuum-size=1K'], capture_output=True)
    print("  Systemd journal cleared.", flush=True)
    return 0


def root_action_tmp_old():
    """Delete files in /tmp older than 10 days, sparing socket and
    systemd-private dirs that are still in use by active services."""
    print("Stage 1: Scanning /tmp for files older than 10 days...", flush=True)
    cutoff = time.time() - (10 * 86400)
    # Protected paths and prefixes — never touch these, they hold live
    # sockets (X11, ICE, font, Test) or per-service systemd tmp dirs.
    protected_exact = {'/tmp/.X11-unix', '/tmp/.ICE-unix',
                       '/tmp/.font-unix', '/tmp/.Test-unix'}
    protected_prefixes = ('/tmp/.X11-unix/', '/tmp/.ICE-unix/',
                          '/tmp/.font-unix/', '/tmp/.Test-unix/',
                          '/tmp/systemd-private-')

    def is_protected(path):
        if path in protected_exact:
            return True
        return any(path.startswith(p) for p in protected_prefixes)

    removed = 0
    print("Stage 2: Removing old temporary files...", flush=True)
    try:
        for root_dir, dirs, files in os.walk('/tmp', topdown=True):
            dirs[:] = [d for d in dirs
                       if not is_protected(os.path.join(root_dir, d))]
            if is_protected(root_dir):
                continue
            for f in files:
                fp = os.path.join(root_dir, f)
                try:
                    st = os.lstat(fp)
                    if st.st_mtime < cutoff:
                        os.unlink(fp)
                        removed += 1
                except OSError:
                    pass
    except Exception as e:
        print(f"  Error scanning /tmp: {e}", flush=True)
    print(f"  Removed {removed} old temporary file(s).", flush=True)
    return 0


def root_action_crash_dumps():
    """Delete crash reports from /var/crash."""
    print("Stage 1: Scanning /var/crash...", flush=True)
    if not os.path.exists('/var/crash'):
        print("  /var/crash does not exist.", flush=True)
        return 0
    removed = 0
    print("Stage 2: Removing crash dumps...", flush=True)
    try:
        for f in os.listdir('/var/crash'):
            fp = os.path.join('/var/crash', f)
            try:
                if os.path.isfile(fp):
                    os.unlink(fp)
                    removed += 1
            except OSError:
                pass
    except OSError as e:
        print(f"  Error: {e}", flush=True)
    print(f"  Removed {removed} crash dump file(s).", flush=True)
    return 0


def _remove_files_in_dir(path):
    """Unlink every regular file directly under `path`. Returns count
    removed. Used for stash-style dirs (coredumps, etc.) where we want
    to clear contents but leave the directory structure intact for
    whatever service writes there."""
    if not os.path.exists(path):
        return 0
    removed = 0
    try:
        for name in os.listdir(path):
            fp = os.path.join(path, name)
            try:
                if os.path.isfile(fp):
                    os.unlink(fp)
                    removed += 1
            except OSError:
                pass
    except OSError:
        pass
    return removed


def root_action_systemd_coredump():
    """Delete systemd-coredump's stashed core files in /var/lib/systemd/coredump.
    Separate from /var/crash (Apport reports) — these are raw cores written
    by the kernel's core_pattern handler. Sometimes the single biggest
    forgotten space-hog on a long-running box."""
    print("Stage 1: Scanning /var/lib/systemd/coredump...", flush=True)
    target = '/var/lib/systemd/coredump'
    if not os.path.exists(target):
        print("  Coredump directory does not exist.", flush=True)
        return 0
    print("Stage 2: Removing systemd coredumps...", flush=True)
    removed = _remove_files_in_dir(target)
    print(f"  Removed {removed} systemd coredump file(s).", flush=True)
    return 0


def root_action_apport_coredump():
    """Delete Apport's intermediate coredumps in /var/lib/apport/coredump.
    These are the raw cores Apport stages before turning them into the
    .crash files in /var/crash — sometimes a duplicate copy lingers."""
    print("Stage 1: Scanning /var/lib/apport/coredump...", flush=True)
    target = '/var/lib/apport/coredump'
    if not os.path.exists(target):
        print("  Apport coredump directory does not exist.", flush=True)
        return 0
    print("Stage 2: Removing Apport coredumps...", flush=True)
    removed = _remove_files_in_dir(target)
    print(f"  Removed {removed} Apport coredump file(s).", flush=True)
    return 0


def root_action_vartmp_old():
    """Delete files in /var/tmp older than 30 days.
    /var/tmp is meant to survive reboots so accumulates more crud than /tmp;
    systemd-tmpfiles' default age policy here is 30 days, so we match that."""
    print("Stage 1: Scanning /var/tmp for files older than 30 days...", flush=True)
    if not os.path.exists('/var/tmp'):
        print("  /var/tmp does not exist.", flush=True)
        return 0
    cutoff = time.time() - (30 * 86400)
    removed = 0
    print("Stage 2: Removing old /var/tmp files...", flush=True)
    try:
        for root_dir, dirs, files in os.walk('/var/tmp', topdown=True):
            for f in files:
                fp = os.path.join(root_dir, f)
                try:
                    st = os.lstat(fp)
                    if st.st_mtime < cutoff:
                        os.unlink(fp)
                        removed += 1
                except OSError:
                    pass
    except Exception as e:
        print(f"  Error scanning /var/tmp: {e}", flush=True)
    print(f"  Removed {removed} old /var/tmp file(s).", flush=True)
    return 0


def root_action_resid_config():
    """Remove residual configuration files."""
    print("Stage 1: Scanning for residual configs...", flush=True)
    result = subprocess.run(['dpkg', '-l'], capture_output=True, text=True)
    packages = []
    for line in result.stdout.splitlines():
        if line.startswith('rc'):
            parts = line.split()
            if len(parts) >= 2:
                packages.append(parts[1])
    if packages:
        print(f"  Found {len(packages)} residual config(s)", flush=True)
        print("Stage 2: Purging residual configs...", flush=True)
        subprocess.run(['dpkg', '--purge'] + packages)
        print(f"  Removed {len(packages)} residual config packages.", flush=True)
    else:
        print("  No residual configuration files found.", flush=True)
    return 0


def root_action_remove_pkgs(username):
    """Autoremove unneeded packages."""
    print("Stage 1: Cleaning obsolete package files...", flush=True)
    subprocess.run(['apt-get', 'autoclean', '-y'], capture_output=True)
    print("  Autoclean complete", flush=True)
    print("Stage 2: Removing unneeded packages...", flush=True)
    # --purge wipes conffiles too. Without it, apt-hook conffiles in
    # /etc/apt/apt.conf.d/ that reference the now-removed binary cause
    # exit-127 on every later apt transaction.
    subprocess.run(['apt-get', 'autoremove', '-y', '--purge'], capture_output=True)
    print("  Autoremove complete", flush=True)

    print("Stage 3: Updating package status...", flush=True)
    # Update dryapt file
    dryapt_file = f"/home/{username}/.local/share/.dryapt"
    now = datetime.now().strftime("%Y-%m-%d %H")
    try:
        with open(dryapt_file, 'w') as f:
            f.write("0\n")
            f.write(f"{now}\n")
        os.chown(dryapt_file, int(subprocess.run(['id', '-u', username], capture_output=True, text=True).stdout.strip()),
                 int(subprocess.run(['id', '-g', username], capture_output=True, text=True).stdout.strip()))
    except:
        pass
    print("  Unneeded packages cleared.", flush=True)
    return 0


def root_action_fix_apt():
    """Package system repair."""
    print("Stage 1: Checking internet connection...", flush=True)
    if not check_internet():
        print("ERROR: No internet connection. Package System Repair requires internet access.", flush=True)
        return 1
    print("  Internet connection OK", flush=True)

    print("Stage 2: Fixing broken dependencies...", flush=True)
    result = subprocess.run(['apt-get', 'install', '-f', '-y'], capture_output=True, text=True)
    if result.returncode == 0:
        print("  Broken dependencies fixed", flush=True)
    else:
        print(f"  WARNING: apt-get install -f returned code {result.returncode}", flush=True)

    print("Stage 3: Rebuilding Ubuntu sources...", flush=True)
    # Get Ubuntu codename
    result = subprocess.run(['lsb_release', '-sc'], capture_output=True, text=True)
    codename = result.stdout.strip() if result.returncode == 0 else 'resolute'
    print(f"  Detected codename: {codename}", flush=True)

    # Rebuild ubuntu.sources in DEB822 format
    ubuntu_sources = f"""Types: deb deb-src
URIs: http://archive.ubuntu.com/ubuntu/
Suites: {codename} {codename}-updates {codename}-backports
Components: main restricted universe multiverse
Architectures: amd64
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg

Types: deb deb-src
URIs: http://archive.ubuntu.com/ubuntu/
Suites: {codename}-security
Components: main restricted universe multiverse
Architectures: amd64
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
"""

    with open('/etc/apt/sources.list.d/ubuntu.sources', 'w') as f:
        f.write(ubuntu_sources)
    print("  Written /etc/apt/sources.list.d/ubuntu.sources", flush=True)

    print("Stage 4: Rebuilding Linux Lite sources...", flush=True)
    # Check LL version and update linuxlite.sources in DEB822 format
    try:
        with open('/etc/llver', 'r') as f:
            llver = f.read().split()[2].split('.')[0]

        release_names = {'5': 'emerald', '6': 'fluorite', '7': 'galena', '8': 'hematite'}
        if llver in release_names:
            ll_sources = (f"Types: deb\n"
                          f"URIs: http://repo.linuxliteos.com/linuxlite/\n"
                          f"Suites: {release_names[llver]}\n"
                          f"Components: main\n"
                          f"Architectures: amd64\n"
                          f"Signed-By: /usr/share/keyrings/linuxlite-archive-keyring.gpg\n")
            with open('/etc/apt/sources.list.d/linuxlite.sources', 'w') as f:
                f.write(ll_sources)
            print(f"  Written /etc/apt/sources.list.d/linuxlite.sources (suite: {release_names[llver]})", flush=True)
            # Remove old .list file if it exists
            if os.path.exists('/etc/apt/sources.list.d/linuxlite.list'):
                os.remove('/etc/apt/sources.list.d/linuxlite.list')
                print("  Removed legacy linuxlite.list", flush=True)
        else:
            print(f"  WARNING: Unknown Linux Lite version: {llver}", flush=True)
    except Exception as e:
        print(f"  WARNING: Could not update Linux Lite sources: {e}", flush=True)

    print("Stage 5: Cleaning package lists...", flush=True)
    if os.path.exists('/var/lib/apt/lists.old'):
        shutil.rmtree('/var/lib/apt/lists.old', ignore_errors=True)
        print("  Removed old package lists", flush=True)
    subprocess.run(['apt-get', 'clean'], capture_output=True)
    if os.path.exists('/var/lib/apt/lists'):
        shutil.move('/var/lib/apt/lists', '/var/lib/apt/lists.old')
    os.makedirs('/var/lib/apt/lists/partial', exist_ok=True)
    subprocess.run(['apt-get', 'clean'], capture_output=True)
    print("  Package cache cleaned", flush=True)

    print("Stage 6: Updating package lists...", flush=True)
    result = subprocess.run(['apt-get', 'update'], capture_output=True, text=True)
    if result.returncode == 0:
        print("  Package lists updated successfully", flush=True)
    else:
        print(f"  WARNING: apt-get update returned code {result.returncode}", flush=True)
        if result.stderr:
            for line in result.stderr.strip().split('\n')[-3:]:
                print(f"  {line}", flush=True)

    print("Stage 7: Configuring pending packages...", flush=True)
    result = subprocess.run(['dpkg', '--configure', '-a'], capture_output=True, text=True)
    if result.returncode == 0:
        print("  Package configuration OK", flush=True)
    else:
        print(f"  WARNING: dpkg --configure returned code {result.returncode}", flush=True)

    print("Package system repair completed.", flush=True)
    return 0


def root_action_fix_bootup():
    """Fix the bootup splash."""
    print("Stage 1: Reading OS version...", flush=True)

    # Get LL version
    try:
        with open('/etc/llver', 'r') as f:
            ll_version = f.read().split()[2]
    except Exception:
        print("ERROR: Unknown OS version", flush=True)
        return 1

    major_version = ll_version.split('.')[0]
    if major_version not in ['7', '8']:
        print(f"ERROR: Release {ll_version} not supported", flush=True)
        return 1

    print(f"  Detected Linux Lite {ll_version}", flush=True)

    print("Stage 2: Setting Plymouth boot splash theme...", flush=True)
    theme_path = '/usr/share/plymouth/themes/linuxlite/linuxlite.plymouth'
    if os.path.exists(theme_path):
        subprocess.run(['plymouth-set-default-theme', 'linuxlite'], capture_output=True, text=True)
        print("  Plymouth theme set to: linuxlite", flush=True)
    else:
        print(f"  WARNING: Plymouth theme not found: {theme_path}", flush=True)

    print("Stage 3: Rebuilding initramfs (this may take a moment)...", flush=True)
    result = subprocess.run(['update-initramfs', '-u'], capture_output=True, text=True)
    if result.returncode == 0:
        print("  Initramfs updated successfully", flush=True)
    else:
        print(f"  WARNING: update-initramfs returned code {result.returncode}", flush=True)
        if result.stderr:
            print(f"  {result.stderr.strip()}", flush=True)

    print("Stage 4: Updating /etc/issue...", flush=True)
    with open('/etc/issue', 'w') as f:
        f.write(f"Linux Lite {ll_version} LTS \\n \\l\n")
    print(f"  Set to: Linux Lite {ll_version} LTS", flush=True)

    print("Stage 5: Updating /etc/lsb-release...", flush=True)
    with open('/etc/lsb-release', 'r') as f:
        content = f.read()
    content = re.sub(r'^DISTRIB_DESCRIPTION=.*$', f'DISTRIB_DESCRIPTION="Linux Lite {ll_version}"', content, flags=re.MULTILINE)
    with open('/etc/lsb-release', 'w') as f:
        f.write(content)
    print(f"  DISTRIB_DESCRIPTION set to: Linux Lite {ll_version}", flush=True)

    print("Bootup fix completed successfully.", flush=True)
    return 0


def root_action_hostname(new_hostname, username):
    """Change the system hostname."""
    print("Stage 1: Updating kernel hostname...", flush=True)

    old_hostname = subprocess.run(['hostname'], capture_output=True, text=True).stdout.strip()

    # Update /proc/sys/kernel/hostname
    with open('/proc/sys/kernel/hostname', 'w') as f:
        f.write(new_hostname)
    print(f"  Hostname: {old_hostname} -> {new_hostname}", flush=True)

    print("Stage 2: Updating /etc/hosts...", flush=True)
    # Update /etc/hosts
    with open('/etc/hosts', 'r') as f:
        content = f.read()
    content = re.sub(r'^127\.0\.1\.1.*$', f'127.0.1.1\t{new_hostname}', content, flags=re.MULTILINE)
    with open('/etc/hosts', 'w') as f:
        f.write(content)
    print("  /etc/hosts updated", flush=True)

    print("Stage 3: Updating /etc/hostname...", flush=True)
    # Update /etc/hostname
    with open('/etc/hostname', 'w') as f:
        f.write(new_hostname + '\n')
    print("  /etc/hostname updated", flush=True)

    print("Stage 4: Restarting NetworkManager...", flush=True)
    subprocess.run(['systemctl', 'restart', 'NetworkManager'])
    print("  NetworkManager restarted", flush=True)

    print("Stage 5: Updating xauth entries...", flush=True)
    # Update xauth
    home_dir = f"/home/{username}"
    result = subprocess.run(['xauth', 'list'], capture_output=True, text=True, env={'HOME': home_dir})
    for line in result.stdout.splitlines():
        if old_hostname in line:
            new_entry = line.replace(old_hostname, new_hostname)
            parts = new_entry.split()
            if len(parts) >= 3:
                subprocess.run(['su', username, '-c', f'xauth add {parts[0]} {parts[1]} {parts[2]}'],
                             env={'HOME': home_dir})

    # Copy xauth-cleanup.desktop to autostart
    autostart_dir = f"{home_dir}/.config/autostart"
    os.makedirs(autostart_dir, exist_ok=True)
    xauth_desktop = "/usr/local/sbin/xauth-cleanup.desktop"
    if os.path.exists(xauth_desktop):
        dest = f"{autostart_dir}/xauth-cleanup.desktop"
        shutil.copy(xauth_desktop, dest)
        uid = int(subprocess.run(['id', '-u', username], capture_output=True, text=True).stdout.strip())
        gid = int(subprocess.run(['id', '-g', username], capture_output=True, text=True).stdout.strip())
        os.chown(dest, uid, gid)
    print("  xauth updated", flush=True)

    print("Stage 6: Checking Samba configuration...", flush=True)
    # Update Samba if configured
    samba_conf = '/etc/samba/smb.conf'
    if os.path.exists(samba_conf):
        with open(samba_conf, 'r') as f:
            content = f.read()
        content = re.sub(r'^netbios name =.*$', f'netbios name = {new_hostname}', content, flags=re.MULTILINE)
        with open(samba_conf, 'w') as f:
            f.write(content)
        subprocess.run(['systemctl', 'reload', 'smbd'], capture_output=True)
        subprocess.run(['systemctl', 'restart', 'nmbd'], capture_output=True)
        print(f"  Samba netbios name updated to {new_hostname}", flush=True)
    else:
        print("  Samba not configured, skipping", flush=True)

    print("Hostname changed successfully.", flush=True)
    return 0


def root_action_numlock(enable):
    """Enable or disable numlock at login."""
    print("Stage 1: Updating LightDM configuration...", flush=True)
    lightdm_conf = '/etc/lightdm/lightdm.conf'
    if not os.path.exists(lightdm_conf):
        print("ERROR: lightdm.conf not found", flush=True)
        return 1

    with open(lightdm_conf, 'r') as f:
        content = f.read()

    if enable:
        content = content.replace('numlockx off', 'numlockx on')
        print("  Numlock enabled at login.", flush=True)
    else:
        content = content.replace('numlockx on', 'numlockx off')
        print("  Numlock disabled at login.", flush=True)

    with open(lightdm_conf, 'w') as f:
        f.write(content)
    return 0


def root_action_save_session(mode):
    """Manage save session settings."""
    print("Stage 1: Updating session save settings...", flush=True)
    kiosk_dir = '/etc/xdg/xfce4/kiosk'
    kioskrc = f'{kiosk_dir}/kioskrc'

    os.makedirs(kiosk_dir, exist_ok=True)

    if not os.path.exists(kioskrc):
        with open(kioskrc, 'w') as f:
            f.write("""[xfce4-panel]
CustomizePanel=ALL

[xfce4-session]
CustomizeSplash=ALL
CustomizeChooser=ALL
CustomizeLogout=ALL
CustomizeCompatibility=%sudo
CustomizeSecurity=NONE
Shutdown=ALL
SaveSession=ALL
""")

    with open(kioskrc, 'r') as f:
        content = f.read()

    content = re.sub(r'^SaveSession=.*$', f'SaveSession={mode}', content, flags=re.MULTILINE)

    with open(kioskrc, 'w') as f:
        f.write(content)

    print(f"  Save session set to {mode}.", flush=True)
    return 0


def root_action_kiosk_mode(setting, value):
    """Configure login/logout options."""
    print("Stage 1: Updating kiosk configuration...", flush=True)
    kiosk_dir = '/etc/xdg/xfce4/kiosk'
    kioskrc = f'{kiosk_dir}/kioskrc'
    pwrlogin = '/etc/lightdm/lightdm-gtk-greeter.conf'

    os.makedirs(kiosk_dir, exist_ok=True)

    if not os.path.exists(kioskrc):
        with open(kioskrc, 'w') as f:
            f.write("""[xfce4-panel]
CustomizePanel=ALL

[xfce4-session]
CustomizeSplash=ALL
CustomizeChooser=ALL
CustomizeLogout=ALL
CustomizeCompatibility=%sudo
CustomizeSecurity=NONE
Shutdown=ALL
SaveSession=ALL
""")

    if setting == 'shutdown':
        with open(kioskrc, 'r') as f:
            content = f.read()
        content = re.sub(r'^Shutdown=.*$', f'Shutdown={value}', content, flags=re.MULTILINE)
        with open(kioskrc, 'w') as f:
            f.write(content)
        print(f"  Logout shutdown options set to {value}.", flush=True)

    elif setting == 'login_power':
        if os.path.exists(pwrlogin):
            with open(pwrlogin, 'r') as f:
                content = f.read()
            if value == 'enable':
                content = re.sub(r'^indicators =.*$', 'indicators = ~host;~spacer;~clock;~spacer;~power', content, flags=re.MULTILINE)
                print("  Login power options enabled.", flush=True)
            else:
                content = re.sub(r'^indicators =.*$', 'indicators = ~host;~spacer;~clock;~spacer;~~Linux Lite', content, flags=re.MULTILINE)
                print("  Login power options disabled.", flush=True)
            with open(pwrlogin, 'w') as f:
                f.write(content)

    return 0


def root_action_preload(action):
    """Manage preload service."""
    if action == 'install':
        print("Stage 1: Installing preload...", flush=True)
        result = subprocess.run(['apt-get', 'install', 'preload', '-y'], capture_output=True, text=True)
        if result.returncode == 0:
            print("  Preload installed successfully.", flush=True)
        else:
            print(f"  ERROR: Installation failed (code {result.returncode})", flush=True)
        return result.returncode
    elif action == 'remove':
        print("Stage 1: Stopping preload service...", flush=True)
        subprocess.run(['service', 'preload', 'stop'], capture_output=True)
        print("Stage 2: Removing preload...", flush=True)
        result = subprocess.run(['apt-get', 'remove', 'preload', '-y'], capture_output=True, text=True)
        if result.returncode == 0:
            print("  Preload removed successfully.", flush=True)
        else:
            print(f"  ERROR: Removal failed (code {result.returncode})", flush=True)
        return result.returncode
    elif action == 'start':
        print("Stage 1: Starting preload service...", flush=True)
        result = subprocess.run(['service', 'preload', 'start'], capture_output=True, text=True)
        if result.returncode == 0:
            print("  Preload started.", flush=True)
        else:
            print(f"  ERROR: Failed to start (code {result.returncode})", flush=True)
        return result.returncode
    elif action == 'stop':
        print("Stage 1: Stopping preload service...", flush=True)
        result = subprocess.run(['service', 'preload', 'stop'], capture_output=True, text=True)
        if result.returncode == 0:
            print("  Preload stopped.", flush=True)
        else:
            print(f"  ERROR: Failed to stop (code {result.returncode})", flush=True)
        return result.returncode
    return 1


def root_action_zram(action):
    """Manage zRAM service."""
    if action == 'install':
        print("Stage 1: Installing zRAM...", flush=True)
        result = subprocess.run(['apt-get', 'install', 'zram-config', '-y'], capture_output=True, text=True)
        if result.returncode == 0:
            print("  zRAM installed", flush=True)
            print("Stage 2: Starting zRAM service...", flush=True)
            subprocess.run(['systemctl', 'start', 'zram-config'], capture_output=True)
            print("  zRAM started.", flush=True)
        else:
            print(f"  ERROR: Installation failed (code {result.returncode})", flush=True)
        return result.returncode
    elif action == 'remove':
        print("Stage 1: Stopping zRAM service...", flush=True)
        subprocess.run(['service', 'zram-config', 'stop'], capture_output=True)
        print("Stage 2: Removing zRAM...", flush=True)
        result = subprocess.run(['apt-get', 'remove', 'zram-config', '-y'], capture_output=True, text=True)
        if result.returncode == 0:
            print("  zRAM removed successfully.", flush=True)
        else:
            print(f"  ERROR: Removal failed (code {result.returncode})", flush=True)
        return result.returncode
    elif action == 'start':
        print("Stage 1: Starting zRAM service...", flush=True)
        result = subprocess.run(['service', 'zram-config', 'start'], capture_output=True, text=True)
        if result.returncode == 0:
            print("  zRAM started.", flush=True)
        else:
            print(f"  ERROR: Failed to start (code {result.returncode})", flush=True)
        return result.returncode
    elif action == 'stop':
        print("Stage 1: Stopping zRAM service...", flush=True)
        result = subprocess.run(['service', 'zram-config', 'stop'], capture_output=True, text=True)
        if result.returncode == 0:
            print("  zRAM stopped.", flush=True)
        else:
            print(f"  ERROR: Failed to stop (code {result.returncode})", flush=True)
        return result.returncode
    return 1


def root_action_tlp(action):
    """Manage TLP service.

    On tlp 1.6+ the bare `tlp start` CLI command applies current power
    settings once and exits — it does NOT activate the systemd unit, so
    the GUI's status check immediately reads the service back as Stopped.
    Use systemctl to manage the unit; tlp-stat is unreliable for status.
    """
    if action == 'install':
        print("Stage 1: Installing TLP...", flush=True)
        result = subprocess.run(['apt-get', 'install', 'tlp', 'tlp-rdw', 'smartmontools', 'ethtool', '-y'], capture_output=True, text=True)
        if result.returncode == 0:
            print("  TLP installed", flush=True)
            print("Stage 2: Enabling and starting TLP service...", flush=True)
            subprocess.run(['systemctl', 'enable', '--now', 'tlp.service'], capture_output=True)
            print("  TLP enabled and started.", flush=True)
        else:
            print(f"  ERROR: Installation failed (code {result.returncode})", flush=True)
        return result.returncode
    elif action == 'remove':
        print("Stage 1: Stopping and disabling TLP service...", flush=True)
        subprocess.run(['systemctl', 'disable', '--now', 'tlp.service'], capture_output=True)
        print("Stage 2: Removing TLP...", flush=True)
        result = subprocess.run(['apt-get', 'remove', 'tlp', 'tlp-rdw', 'smartmontools', 'ethtool', '-y'], capture_output=True, text=True)
        if result.returncode == 0:
            print("  TLP removed successfully.", flush=True)
        else:
            print(f"  ERROR: Removal failed (code {result.returncode})", flush=True)
        return result.returncode
    elif action == 'start':
        print("Stage 1: Enabling and starting TLP service...", flush=True)
        result = subprocess.run(['systemctl', 'enable', '--now', 'tlp.service'], capture_output=True, text=True)
        if result.returncode == 0:
            print("  TLP enabled and started.", flush=True)
        else:
            print(f"  ERROR: Failed to start (code {result.returncode})", flush=True)
        return result.returncode
    elif action == 'stop':
        print("Stage 1: Stopping and disabling TLP service...", flush=True)
        result = subprocess.run(['systemctl', 'disable', '--now', 'tlp.service'], capture_output=True, text=True)
        if result.returncode == 0:
            print("  TLP stopped and disabled.", flush=True)
        else:
            print(f"  ERROR: Failed to stop (code {result.returncode})", flush=True)
        return result.returncode
    return 1


def root_action_find_files(min_size, max_size, output_file):
    """Find large files within size range."""
    print(f"Finding files between {min_size}MB and {max_size}MB...")
    cmd = f"find / -size +{min_size}M -size -{max_size}M -exec du -mh {{}} + 2>/dev/null | grep '[0-9][MG]' | sort -h -r"
    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    with open(output_file, 'w') as f:
        f.write(result.stdout)
    print(f"Results saved to {output_file}")
    return 0


def handle_root_action():
    """Handle root action when called with --root-action."""
    if len(sys.argv) < 3:
        print("Usage: lite-tweaks --root-action ACTION [args...]")
        return 1

    action = sys.argv[2]
    args = sys.argv[3:]

    actions = {
        'apt-clean': lambda: root_action_apt_clean(),
        'clear-mem': lambda: root_action_clear_mem(),
        'log-archives': lambda: root_action_log_archives(),
        'tmp-old': lambda: root_action_tmp_old(),
        'vartmp-old': lambda: root_action_vartmp_old(),
        'crash-dumps': lambda: root_action_crash_dumps(),
        'systemd-coredump': lambda: root_action_systemd_coredump(),
        'apport-coredump': lambda: root_action_apport_coredump(),
        'resid-config': lambda: root_action_resid_config(),
        'remove-pkgs': lambda: root_action_remove_pkgs(args[0]) if args else 1,
        'fix-apt': lambda: root_action_fix_apt(),
        'fix-bootup': lambda: root_action_fix_bootup(),
        'hostname': lambda: root_action_hostname(args[0], args[1]) if len(args) >= 2 else 1,
        'numlock': lambda: root_action_numlock(args[0] == 'enable') if args else 1,
        'save-session': lambda: root_action_save_session(args[0]) if args else 1,
        'kiosk-mode': lambda: root_action_kiosk_mode(args[0], args[1]) if len(args) >= 2 else 1,
        'preload': lambda: root_action_preload(args[0]) if args else 1,
        'zram': lambda: root_action_zram(args[0]) if args else 1,
        'tlp': lambda: root_action_tlp(args[0]) if args else 1,
        'find-files': lambda: root_action_find_files(args[0], args[1], args[2]) if len(args) >= 3 else 1,
    }

    if action in actions:
        return actions[action]()
    else:
        print(f"Unknown action: {action}")
        return 1


# ============================================================================
# Dryapt functionality (background package check)
# ============================================================================

def run_dryapt():
    """Run dryapt check in background."""
    home = os.path.expanduser("~")
    dryapt_file = os.path.join(home, ".local/share/.dryapt")
    now = datetime.now().strftime("%Y-%m-%d %H")

    # Check if we need to run
    if os.path.exists(dryapt_file):
        try:
            with open(dryapt_file, 'r') as f:
                lines = f.readlines()
            if len(lines) >= 1:
                count = int(lines[0].strip())
                if count > 0:
                    return  # Already have a count
            if len(lines) >= 2:
                last_check = lines[1].strip()
                if last_check == now:
                    return  # Already checked this hour
        except:
            pass

    # Run the check
    try:
        result = subprocess.run(['apt-get', '-s', 'autoremove'], capture_output=True, text=True, timeout=60)
        count = 0
        for line in result.stdout.splitlines():
            if line.startswith('Remv '):
                count += 1

        os.makedirs(os.path.dirname(dryapt_file), exist_ok=True)
        with open(dryapt_file, 'w') as f:
            f.write(f"{count}\n{now}\n")
    except:
        pass


# ============================================================================
# Xauth cleanup functionality
# ============================================================================

def run_xauth_cleanup():
    """Clean up stale xauth entries."""
    try:
        hostname = subprocess.run(['hostname'], capture_output=True, text=True).stdout.strip()
        result = subprocess.run(['xauth', 'list'], capture_output=True, text=True)

        for line in result.stdout.splitlines():
            parts = line.split()
            if parts:
                entry_host = parts[0].split('/')[0]
                if entry_host != hostname:
                    subprocess.run(['xauth', 'remove', parts[0]], capture_output=True)

        # Remove the autostart file
        autostart_file = os.path.expanduser("~/.config/autostart/xauth-cleanup.desktop")
        if os.path.exists(autostart_file):
            os.remove(autostart_file)
    except:
        pass


# ============================================================================
# GTK4 GUI Application
# ============================================================================

class TweakItem:
    """Represents a selectable tweak item."""
    def __init__(self, id, name, task, category, grade, description,
                 enabled=False, visible=True, size_bytes=0):
        self.id = id
        self.name = name
        self.task = task
        self.category = category
        self.grade = grade
        self.description = description
        self.enabled = enabled
        self.visible = visible
        self.size_bytes = size_bytes  # Reclaimable space — used by One Click Clean's confirm dialog
        self.check_button = None
        self.row = None


class ProgressWindow(Gtk.Window):
    """Progress window for running tasks with task list."""
    def __init__(self, parent, title="Running Tasks"):
        super().__init__(title=title, transient_for=parent, modal=True)
        self.set_default_size(450, 400)

        self.task_rows = {}  # task_id -> (row, status_icon)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        box.set_margin_top(16)
        box.set_margin_bottom(16)
        box.set_margin_start(16)
        box.set_margin_end(16)

        # Current status label
        self.status_label = Gtk.Label(label="Starting...")
        self.status_label.set_wrap(True)
        self.status_label.add_css_class("title-4")
        box.append(self.status_label)

        # Progress bar
        self.progress_bar = Gtk.ProgressBar()
        self.progress_bar.set_show_text(True)
        box.append(self.progress_bar)

        # Task list in a scrolled window
        scrolled = Gtk.ScrolledWindow()
        scrolled.set_vexpand(True)
        scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)

        self.task_list = Gtk.ListBox()
        self.task_list.set_selection_mode(Gtk.SelectionMode.NONE)
        self.task_list.add_css_class("boxed-list")
        scrolled.set_child(self.task_list)
        box.append(scrolled)

        self.set_child(box)

    def add_task(self, task_id, task_name):
        """Add a task to the list."""
        def _add():
            row = ActionRow(title=task_name)
            status_icon = Gtk.Image.new_from_icon_name("content-loading-symbolic")
            status_icon.add_css_class("dim-label")
            row.add_suffix(status_icon)
            self.task_list.append(row)
            self.task_rows[task_id] = (row, status_icon)
        GLib.idle_add(_add)

    def set_task_running(self, task_id):
        """Mark a task as currently running."""
        def _update():
            if task_id in self.task_rows:
                row, icon = self.task_rows[task_id]
                icon.set_from_icon_name("media-playback-start-symbolic")
                icon.remove_css_class("dim-label")
                icon.remove_css_class("success")
                icon.add_css_class("accent")
        GLib.idle_add(_update)

    def set_task_completed(self, task_id):
        """Mark a task as completed."""
        def _update():
            if task_id in self.task_rows:
                row, icon = self.task_rows[task_id]
                icon.set_from_icon_name("emblem-ok-symbolic")
                icon.remove_css_class("dim-label")
                icon.remove_css_class("accent")
                icon.add_css_class("success")
        GLib.idle_add(_update)

    def set_status(self, text):
        GLib.idle_add(self._set_status, text)

    def _set_status(self, text):
        self.status_label.set_text(text)

    def set_progress(self, fraction):
        GLib.idle_add(self._set_progress, fraction)

    def _set_progress(self, fraction):
        self.progress_bar.set_fraction(fraction)
        self.progress_bar.set_text(f"{int(fraction * 100)}%")


class DiskItem(GObject.Object):
    """GObject wrapper for disk info."""
    def __init__(self, device, fs_type, size, used, free, percent, mount):
        super().__init__()
        self.device = device
        self.fs_type = fs_type
        self.size = size
        self.used = used
        self.free = free
        self.percent = percent
        self.mount = mount


class DiskUsageWindow(Gtk.Window):
    """Window to display disk usage."""
    def __init__(self, parent):
        super().__init__(title="Disk Usage", transient_for=parent)
        self.set_default_size(700, 450)
        self.parent_window = parent

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        header = Gtk.HeaderBar()
        self.set_titlebar(header)

        # Create list model
        self.list_store = Gio.ListStore(item_type=DiskItem)

        # Create column view
        self.column_view = Gtk.ColumnView()
        self.column_view.set_model(Gtk.SingleSelection.new(self.list_store))

        # Add columns
        for title, attr in [("Device", "device"), ("Type", "fs_type"), ("Size", "size"),
                           ("Used", "used"), ("Free", "free"), ("%Used", "percent"), ("Mount", "mount")]:
            factory = Gtk.SignalListItemFactory()
            factory.connect("setup", self._on_factory_setup)
            factory.connect("bind", self._on_factory_bind, attr)
            column = Gtk.ColumnViewColumn(title=title, factory=factory)
            column.set_resizable(True)
            self.column_view.append_column(column)

        scrolled = Gtk.ScrolledWindow()
        scrolled.set_child(self.column_view)
        scrolled.set_vexpand(True)

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

        info_label = Gtk.Label(label="Double-click a partition to open in file manager. Click column headers to sort.")
        info_label.set_wrap(True)
        info_label.add_css_class("dim-label")
        box.append(info_label)
        box.append(scrolled)

        # Open button
        open_btn = Gtk.Button(label="Open in File Manager")
        open_btn.connect("clicked", self._on_open_clicked)
        open_btn.add_css_class("suggested-action")
        box.append(open_btn)

        box.set_vexpand(True)
        vbox.append(box)
        self.set_child(vbox)

        self._load_disk_info()

    def _on_factory_setup(self, factory, list_item):
        label = Gtk.Label()
        label.set_xalign(0)
        list_item.set_child(label)

    def _on_factory_bind(self, factory, list_item, attr):
        item = list_item.get_item()
        label = list_item.get_child()
        label.set_text(getattr(item, attr, ""))

    def _on_open_clicked(self, button):
        selection = self.column_view.get_model()
        item = selection.get_selected_item()
        if item and item.mount:
            subprocess.Popen(['xdg-open', item.mount])

    def _load_disk_info(self):
        result = subprocess.run(['df', '-h', '-T'], capture_output=True, text=True)
        for line in result.stdout.splitlines()[1:]:
            parts = line.split()
            if len(parts) >= 7 and parts[0].startswith('/dev/'):
                item = DiskItem(
                    device=parts[0],
                    fs_type=parts[1],
                    size=parts[2],
                    used=parts[3],
                    free=parts[4],
                    percent=parts[5],
                    mount=' '.join(parts[6:])
                )
                self.list_store.append(item)


class DefaultBrowserWindow(Gtk.Window):
    """Window to select default web browser."""
    def __init__(self, parent):
        super().__init__(title="Default Web Browser", transient_for=parent)
        self.set_default_size(500, 400)
        self.parent_window = parent

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        header = Gtk.HeaderBar()
        set_btn = Gtk.Button(label="Set Default")
        set_btn.add_css_class("suggested-action")
        set_btn.connect("clicked", self._on_set_clicked)
        header.pack_end(set_btn)
        self.set_titlebar(header)

        scrolled = Gtk.ScrolledWindow()
        scrolled.set_vexpand(True)

        self.pref_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24)
        self.pref_page.set_margin_top(12)
        self.pref_page.set_margin_bottom(12)
        self.pref_page.set_margin_start(12)
        self.pref_page.set_margin_end(12)
        self.pref_group = PreferencesGroup(title="Select your default web browser")
        self.pref_page.append(self.pref_group)

        self.check_group = Gtk.CheckButton()
        self.browser_rows = {}

        # Check which browsers are installed and which is default
        mimeapps = os.path.expanduser("~/.config/mimeapps.list")
        current_default = None
        if os.path.exists(mimeapps):
            with open(mimeapps, 'r') as f:
                for line in f:
                    if line.startswith('x-scheme-handler/http='):
                        desktop = line.split('=')[1].strip().rstrip(';').split(';')[0]
                        current_default = desktop
                        break

        for browser_id, cmd in BROWSER_COMMANDS.items():
            if check_command_exists(cmd):
                desktop_file = BROWSER_DESKTOP_FILES.get(browser_id, '')
                is_default = current_default == desktop_file if current_default else False

                name = browser_id.replace('_', ' ').title()
                if browser_id == 'edge':
                    name = 'Microsoft Edge'
                elif browser_id == 'palemoon':
                    name = 'Pale Moon'
                elif browser_id == 'chrome':
                    name = 'Google Chrome'

                status = "Default" if is_default else ""

                row = ActionRow(title=name, subtitle=status)
                check = Gtk.CheckButton()
                check.set_group(self.check_group)
                if is_default:
                    check.set_active(True)
                row.add_prefix(check)
                row.set_activatable_widget(check)

                self.browser_rows[browser_id] = (row, check)
                self.pref_group.add(row)

        scrolled.set_child(self.pref_page)
        vbox.append(scrolled)
        self.set_child(vbox)

    def _on_set_clicked(self, button):
        for browser_id, (row, check) in self.browser_rows.items():
            if check.get_active():
                self._set_default_browser(browser_id)
                self.close()
                return

    def _set_default_browser(self, browser_id):
        desktop_file = BROWSER_DESKTOP_FILES.get(browser_id)
        if not desktop_file:
            return

        mimeapps = os.path.expanduser("~/.config/mimeapps.list")

        # Ensure file exists with proper sections
        if not os.path.exists(mimeapps):
            with open(mimeapps, 'w') as f:
                f.write("[Added Associations]\n\n[Default Applications]\n")

        with open(mimeapps, 'r') as f:
            content = f.read()

        # Update mime associations
        mime_types = [
            'application/xhtml+xml',
            'text/html',
            'x-scheme-handler/http',
            'x-scheme-handler/https',
        ]

        for mime in mime_types:
            pattern = rf'^{re.escape(mime)}=.*$'
            replacement = f'{mime}={desktop_file};firefox.desktop;' if browser_id != 'firefox' else f'{mime}={desktop_file};'
            if re.search(pattern, content, re.MULTILINE):
                content = re.sub(pattern, replacement, content, flags=re.MULTILINE)
            else:
                if '[Default Applications]' in content:
                    content = content.replace('[Default Applications]', f'[Default Applications]\n{replacement}')

        with open(mimeapps, 'w') as f:
            f.write(content)

        # Also use xdg-settings
        subprocess.run(['xdg-settings', 'set', 'default-web-browser', desktop_file], capture_output=True)


class HibernateSuspendWindow(Gtk.Window):
    """Window to configure hibernate/suspend buttons."""
    def __init__(self, parent):
        super().__init__(title="Hibernate / Suspend Buttons", transient_for=parent)
        self.set_default_size(500, 500)
        self.parent_window = parent

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        header = Gtk.HeaderBar()
        self.set_titlebar(header)

        scrolled = Gtk.ScrolledWindow()
        scrolled.set_vexpand(True)

        self.pref_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24)
        self.pref_page.set_margin_top(12)
        self.pref_page.set_margin_bottom(12)
        self.pref_page.set_margin_start(12)
        self.pref_page.set_margin_end(12)
        group = PreferencesGroup(title="Show or hide buttons on the logout screen")
        self.pref_page.append(group)

        self.session_file = os.path.expanduser("~/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-session.xml")
        self._ensure_session_defaults()

        self.switches = {}

        for btn_name, prop_name in [("Hibernate", "ShowHibernate"),
                                    ("Suspend", "ShowSuspend"),
                                    ("Hybrid Sleep", "ShowHybridSleep"),
                                    ("Switch User", "ShowSwitchUser")]:
            row = ActionRow(title=btn_name, subtitle=f"Show {btn_name} button on logout screen")
            switch = Gtk.Switch()
            switch.set_valign(Gtk.Align.CENTER)
            switch.set_active(self._get_button_state(prop_name))
            switch.connect("notify::active", self._on_switch_toggled, prop_name)
            row.add_suffix(switch)
            self.switches[prop_name] = switch
            group.add(row)

        # Show/Hide all buttons
        group2 = PreferencesGroup(title="Quick Actions")
        self.pref_page.append(group2)

        show_all_row = ActionRow(title="Show All", subtitle="Show all buttons on logout screen")
        show_all_btn = Gtk.Button(label="Apply")
        show_all_btn.set_valign(Gtk.Align.CENTER)
        show_all_btn.connect("clicked", lambda b: self._set_all_buttons(True))
        show_all_row.add_suffix(show_all_btn)
        group2.add(show_all_row)

        hide_all_row = ActionRow(title="Hide All", subtitle="Hide all buttons on logout screen")
        hide_all_btn = Gtk.Button(label="Apply")
        hide_all_btn.set_valign(Gtk.Align.CENTER)
        hide_all_btn.connect("clicked", lambda b: self._set_all_buttons(False))
        hide_all_row.add_suffix(hide_all_btn)
        group2.add(hide_all_row)

        scrolled.set_child(self.pref_page)
        vbox.append(scrolled)
        self.set_child(vbox)

    def _ensure_session_defaults(self):
        if not os.path.exists(self.session_file):
            return

        with open(self.session_file, 'r') as f:
            content = f.read()

        if '<property name="shutdown" type="empty">' not in content:
            # Add default shutdown properties
            shutdown_props = '''    <property name="shutdown" type="empty">
        <property name="ShowHibernate" type="bool" value="true"/>
        <property name="ShowSuspend" type="bool" value="true"/>
        <property name="ShowHybridSleep" type="bool" value="true"/>
        <property name="ShowSwitchUser" type="bool" value="true"/>
    </property>
'''
            content = content.replace('</channel>', shutdown_props + '</channel>')
            with open(self.session_file, 'w') as f:
                f.write(content)

    def _get_button_state(self, prop_name):
        if not os.path.exists(self.session_file):
            return True

        with open(self.session_file, 'r') as f:
            content = f.read()

        match = re.search(rf'<property name="{prop_name}" type="bool" value="(true|false)"/>', content)
        return match.group(1) == 'true' if match else True

    def _on_switch_toggled(self, switch, param, prop_name):
        self._set_button_state(prop_name, switch.get_active())
        self._notify_reboot()

    def _set_button_state(self, prop_name, value):
        if not os.path.exists(self.session_file):
            return

        with open(self.session_file, 'r') as f:
            content = f.read()

        value_str = 'true' if value else 'false'
        content = re.sub(
            rf'<property name="{prop_name}" type="bool" value="(true|false)"/>',
            f'<property name="{prop_name}" type="bool" value="{value_str}"/>',
            content
        )

        with open(self.session_file, 'w') as f:
            f.write(content)

    def _set_all_buttons(self, value):
        for prop_name, switch in self.switches.items():
            switch.set_active(value)
            self._set_button_state(prop_name, value)
        self._notify_reboot()

    def _notify_reboot(self):
        """Show reboot required notification."""
        self.parent_window.show_toast("A reboot is required to apply changes")


class ServiceManagerWindow(Gtk.Window):
    """Generic service manager window for Preload/zRAM/TLP."""
    def __init__(self, parent, service_name, title, install_packages, check_running_func, description):
        super().__init__(title=title, transient_for=parent)
        self.set_default_size(450, 380)
        self.parent_window = parent
        self.service_name = service_name
        self.install_packages = install_packages
        self.check_running_func = check_running_func

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        header = Gtk.HeaderBar()
        self.set_titlebar(header)

        scrolled = Gtk.ScrolledWindow()
        scrolled.set_vexpand(True)

        self.pref_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24)
        self.pref_page.set_margin_top(12)
        self.pref_page.set_margin_bottom(12)
        self.pref_page.set_margin_start(12)
        self.pref_page.set_margin_end(12)

        # Status group
        status_group = PreferencesGroup(title="Status")
        self.pref_page.append(status_group)

        self.status_row = ActionRow(title="Service Status")
        status_group.add(self.status_row)

        # Actions group
        self.actions_group = PreferencesGroup(title="Actions")
        self.pref_page.append(self.actions_group)

        self.action_rows = {}

        scrolled.set_child(self.pref_page)
        vbox.append(scrolled)
        self.set_child(vbox)

        self._refresh_status()

    def _is_installed(self):
        result = subprocess.run(['dpkg', '-l'] + self.install_packages[:1], capture_output=True, text=True)
        return 'ii' in result.stdout

    def _refresh_status(self):
        # Clear existing action rows
        for row in list(self.action_rows.values()):
            self.actions_group.remove(row)
        self.action_rows.clear()

        installed = self._is_installed()

        if not installed:
            self.status_row.set_subtitle("Not installed")

            install_row = ActionRow(title="Install", subtitle=f"Install {self.service_name}")
            install_btn = Gtk.Button(label="Install")
            install_btn.set_valign(Gtk.Align.CENTER)
            install_btn.add_css_class("suggested-action")
            install_btn.connect("clicked", self._on_install)
            install_row.add_suffix(install_btn)
            self.actions_group.add(install_row)
            self.action_rows['install'] = install_row
        else:
            running = self.check_running_func()
            self.status_row.set_subtitle("Running" if running else "Stopped")

            if not running:
                start_row = ActionRow(title="Start", subtitle=f"Start {self.service_name} service")
                start_btn = Gtk.Button(label="Start")
                start_btn.set_valign(Gtk.Align.CENTER)
                start_btn.connect("clicked", self._on_start)
                start_row.add_suffix(start_btn)
                self.actions_group.add(start_row)
                self.action_rows['start'] = start_row
            else:
                stop_row = ActionRow(title="Stop", subtitle=f"Stop {self.service_name} service")
                stop_btn = Gtk.Button(label="Stop")
                stop_btn.set_valign(Gtk.Align.CENTER)
                stop_btn.connect("clicked", self._on_stop)
                stop_row.add_suffix(stop_btn)
                self.actions_group.add(stop_row)
                self.action_rows['stop'] = stop_row

            remove_row = ActionRow(title="Remove", subtitle=f"Uninstall {self.service_name}")
            remove_btn = Gtk.Button(label="Remove")
            remove_btn.set_valign(Gtk.Align.CENTER)
            remove_btn.add_css_class("destructive-action")
            remove_btn.connect("clicked", self._on_remove)
            remove_row.add_suffix(remove_btn)
            self.actions_group.add(remove_row)
            self.action_rows['remove'] = remove_row

    def _run_action(self, action):
        # Disable all action buttons and show progress
        for row in self.action_rows.values():
            row.set_sensitive(False)

        self.status_row.set_subtitle(f"{action.capitalize()}ing {self.service_name}...")

        # Add a progress label if not already present
        if not hasattr(self, 'progress_label'):
            self.progress_label = Gtk.Label(label="")
            self.progress_label.set_wrap(True)
            self.progress_label.set_xalign(0)
            self.progress_label.add_css_class("dim-label")
            self.progress_label.set_margin_start(12)
            self.progress_label.set_margin_end(12)
            self.pref_page.append(self.progress_label)

        def do_action():
            cmd = ['pkexec', '/usr/bin/lite-tweaks', '--root-action',
                   self.service_name.lower(), action]
            proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
            for line in proc.stdout:
                line = line.strip()
                if line:
                    GLib.idle_add(self.progress_label.set_text, line)
            proc.wait()
            GLib.idle_add(self._on_action_complete)

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

    def _on_action_complete(self):
        self._refresh_status()
        if hasattr(self, 'progress_label'):
            self.progress_label.set_text("")

    def _on_install(self, button):
        self._run_action('install')

    def _on_start(self, button):
        self._run_action('start')

    def _on_stop(self, button):
        self._run_action('stop')

    def _on_remove(self, button):
        self._run_action('remove')


class LiteTweaksApp(Gtk.Application):
    """Main application class."""

    def __init__(self):
        super().__init__(application_id=APP_ID)
        self.window = None
        self.tweaks = []
        self.selected_tweaks = set()

    def _apply_css(self):
        css = b"""
        .dark-text { color: #1e1e1e; }
        .dim-label { color: #4a4a4a; }
        .app-notification {
            background-color: #323232;
            color: #ffffff;
            border-radius: 8px;
            padding: 8px 16px;
        }
        button.success-action {
            background-color: #4caf50;
            color: #ffffff;
            border: none;
        }
        button.success-action:hover {
            background-color: #5cba60;
        }
        button.success-action:active {
            background-color: #3d9942;
        }
        """
        provider = Gtk.CssProvider()
        provider.load_from_data(css)
        Gtk.StyleContext.add_provider_for_display(
            Gdk.Display.get_default(), provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
        )

    def do_activate(self):
        self._apply_css()
        if not self.window:
            self.window = LiteTweaksWindow(application=self)
        self.window.present()


class LiteTweaksWindow(Gtk.ApplicationWindow):
    """Main application window."""

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.set_icon_name("lite-tweaks")
        self.set_title(APP_TITLE)
        self.set_default_size(900, 700)

        self.tweaks = []
        self.tweak_rows = {}
        self.selected_tweaks = set()

        # Main layout
        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)

        # Header bar
        header = Gtk.HeaderBar()
        header.set_title_widget(Gtk.Label(label=APP_TITLE))

        # Begin button
        self.begin_btn = Gtk.Button(label="Begin")
        self.begin_btn.add_css_class("suggested-action")
        self.begin_btn.connect("clicked", self._on_begin_clicked)
        header.pack_end(self.begin_btn)

        # One Click Clean — runs every Safe Clean-task in one go, with a
        # confirmation dialog. pack_end stacks right-to-left in call order,
        # so this lands to the LEFT of Begin (Begin stays the primary CTA).
        self.one_click_btn = Gtk.Button(label="One Click Clean")
        self.one_click_btn.add_css_class("success-action")
        self.one_click_btn.set_tooltip_text(
            "Run every Safe cleaning task in one go (caches, logs, "
            "thumbnails, package leftovers, old temp files)"
        )
        self.one_click_btn.connect("clicked", self._on_one_click_clean_clicked)
        header.pack_end(self.one_click_btn)

        self.set_titlebar(header)

        # Create scrolled window with preferences page
        scrolled = Gtk.ScrolledWindow()
        scrolled.set_vexpand(True)

        self.pref_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24)
        self.pref_page.set_margin_top(12)
        self.pref_page.set_margin_bottom(12)
        self.pref_page.set_margin_start(12)
        self.pref_page.set_margin_end(12)

        # Add info banner
        info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        info_box.set_margin_top(12)
        info_box.set_margin_bottom(6)
        info_box.set_margin_start(12)
        info_box.set_margin_end(12)

        info_label = Gtk.Label()
        info_label.set_markup(
            "Select tasks and click <b>Begin</b>. "
            "<span foreground='#4caf50'>Safe</span> = no risk, "
            "<span foreground='#e53935'>Caution</span> = system changes."
        )
        info_label.set_wrap(True)
        info_box.append(info_label)

        # Create preference groups for each category
        self._populate_groups()

        scrolled.set_child(self.pref_page)

        # Add info box above scrolled content
        main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        main_box.append(info_box)
        main_box.append(scrolled)

        main_box.set_vexpand(True)
        vbox.append(main_box)

        # Toast overlay for notifications
        self.toast_overlay = Gtk.Overlay()
        self.toast_overlay.set_child(vbox)
        self.set_child(self.toast_overlay)

        # Toast label (hidden by default)
        self._toast_label = Gtk.Label()
        self._toast_label.set_halign(Gtk.Align.CENTER)
        self._toast_label.set_valign(Gtk.Align.END)
        self._toast_label.set_margin_bottom(24)
        self._toast_label.add_css_class("app-notification")
        self._toast_label.set_visible(False)

        toast_frame = Gtk.Frame()
        toast_frame.set_child(self._toast_label)
        toast_frame.set_halign(Gtk.Align.CENTER)
        toast_frame.set_valign(Gtk.Align.END)
        toast_frame.set_margin_bottom(24)
        toast_frame.set_visible(False)
        self._toast_frame = toast_frame
        self.toast_overlay.add_overlay(toast_frame)

        # Run dryapt check in background
        threading.Thread(target=run_dryapt, daemon=True).start()

    def show_toast(self, message, timeout=3):
        """Show a brief toast notification at the bottom of the window."""
        self._toast_label.set_text(message)
        self._toast_label.set_visible(True)
        self._toast_frame.set_visible(True)
        GLib.timeout_add_seconds(timeout, self._dismiss_toast)

    def _dismiss_toast(self):
        self._toast_label.set_visible(False)
        self._toast_frame.set_visible(False)
        return False

    def _populate_groups(self):
        """Build all preference groups on the page."""
        self._create_cleaning_group()
        self._create_performance_group()
        self._create_system_group()
        self._create_information_group()
        self._create_preferences_group()


    def _refresh_groups(self):
        """Clear and rebuild all preference groups to reflect current state."""
        # Remove all children from pref_page
        child = self.pref_page.get_first_child()
        while child:
            next_child = child.get_next_sibling()
            self.pref_page.remove(child)
            child = next_child

        # Reset tweak tracking
        self.tweaks.clear()
        self.tweak_rows.clear()
        self.selected_tweaks.clear()

        # Rebuild
        self._populate_groups()

    def _create_tweak_row(self, tweak):
        """Create an ActionRow for a tweak item."""
        row = ActionRow(title=tweak.name, subtitle=tweak.description)

        check = Gtk.CheckButton()
        check.connect("toggled", self._on_check_toggled, tweak)
        row.add_prefix(check)
        row.set_activatable_widget(check)

        # Task label with grade color
        task_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)

        grade_label = Gtk.Label(label=tweak.grade)
        if tweak.grade == "Safe":
            grade_label.add_css_class("success")
        elif tweak.grade == "Caution":
            grade_label.add_css_class("error")
        task_box.append(grade_label)

        row.add_suffix(task_box)

        tweak.check_button = check
        tweak.row = row
        self.tweak_rows[tweak.id] = row

        return row

    def _on_check_toggled(self, check, tweak):
        if check.get_active():
            self.selected_tweaks.add(tweak.id)
        else:
            self.selected_tweaks.discard(tweak.id)

    def _create_cleaning_group(self):
        """Create the Cleaning preferences group."""
        group = PreferencesGroup(title="Cleaning", description="Clean caches, logs, and temporary files")
        self.pref_page.append(group)

        home = os.path.expanduser("~")

        # Browser caches
        for browser_id, cache_path in BROWSER_CACHES.items():
            full_path = os.path.join(home, cache_path)
            cache_bytes = get_dir_size_bytes(full_path) if os.path.exists(full_path) else 0
            if cache_bytes > 0:
                size = get_dir_size(full_path)
                name = browser_id.replace('_', ' ').title()
                if browser_id == 'edge':
                    name = 'Microsoft Edge'
                elif browser_id == 'palemoon':
                    name = 'Pale Moon'

                desc = f"Remove {size} from your {name} cache"
                tweak = TweakItem(f"browser_{browser_id}", f"{name} Cache", "Clean", "Internet", "Safe", desc,
                                size_bytes=cache_bytes)
                self.tweaks.append(tweak)
                group.add(self._create_tweak_row(tweak))

        # Thumbnail cache (XDG ~/.cache/thumbnails + legacy ~/.thumbnails
        # — old GTK2 apps still populate the latter)
        thumb_paths = [
            os.path.join(home, ".cache/thumbnails"),
            os.path.join(home, ".thumbnails"),
        ]
        thumb_size = sum(get_dir_size_bytes(p) for p in thumb_paths if os.path.exists(p))
        if thumb_size > 0:
            size_str = subprocess.run(['numfmt', '--to=iec', str(thumb_size)], capture_output=True, text=True).stdout.strip()
            tweak = TweakItem("thumbnails", "Thumbnail Cache", "Clean", "Images", "Safe",
                            f"Remove {size_str}B from your thumbnail cache",
                            size_bytes=thumb_size)
            self.tweaks.append(tweak)
            group.add(self._create_tweak_row(tweak))

        # Trash
        trash_path = os.path.join(home, ".local/share/Trash/files")
        if os.path.exists(trash_path) and os.listdir(trash_path):
            trash_bytes = get_dir_size_bytes(os.path.join(home, ".local/share/Trash"))
            size = get_dir_size(os.path.join(home, ".local/share/Trash"))
            tweak = TweakItem("trash", "Trash Bin", "Clean", "Home", "Safe",
                            f"Remove {size} from your Trash bin" if size else "Empty the Trash bin",
                            size_bytes=trash_bytes)
            self.tweaks.append(tweak)
            group.add(self._create_tweak_row(tweak))

        # Package cache
        apt_cache = "/var/cache/apt/archives"
        apt_size = get_dir_size_bytes(apt_cache)
        if os.path.exists(apt_cache) and apt_size > 0:
            size = get_dir_size(apt_cache)
            tweak = TweakItem("apt_clean", "Package Cache", "Clean", "Packages", "Safe",
                            f"Clean {size} from your package cache",
                            size_bytes=apt_size)
            self.tweaks.append(tweak)
            group.add(self._create_tweak_row(tweak))

        # Inactive log files (everything in /var/log including the systemd
        # journal). Files held open by a running process are truncated in
        # place at delete time; everything else is unlinked. The size shown
        # is an upper bound — as a regular user we can stat files but not
        # see other processes' FDs.
        #
        # We deliberately EXCLUDE the active journal files (system.journal,
        # user-NNNN.journal — anything in /var/log/journal/ without '@' in
        # the filename). journald keeps these open while the system is live,
        # so they cannot be reclaimed and shouldn't show as "stuff to clean"
        # — otherwise post-cleanup the tweak would still display ~10-30 MB
        # of unreclaimable baseline and look like cleanup did nothing.
        log_inactive_size = 0
        if os.path.exists('/var/log'):
            for root_dir, dirs, files in os.walk('/var/log'):
                in_journal = (root_dir == '/var/log/journal'
                              or root_dir.startswith('/var/log/journal/'))
                for f in files:
                    if in_journal and '@' not in f and f.endswith('.journal'):
                        continue  # active journal file — not reclaimable
                    try:
                        log_inactive_size += os.path.getsize(os.path.join(root_dir, f))
                    except OSError:
                        pass
        # Hide when only sub-megabyte regeneration noise remains. After a
        # clean run dpkg.log/cups/auth churn 100s of bytes immediately —
        # showing "Remove up to 297B" right after a cleanup just confuses
        # people. 1 MB is the cutoff for "worth offering as a tweak."
        if log_inactive_size > 1 * 1024 * 1024:
            size_str = subprocess.run(['numfmt', '--to=iec', str(log_inactive_size)], capture_output=True, text=True).stdout.strip()
            tweak = TweakItem("log_archives", "Inactive Log Files", "Clean", "System", "Safe",
                            f"Remove up to {size_str}B from /var/log including apt, dist-upgrade, samba, vmware and the systemd journal (active syslog/auth/kern logs are truncated in place)",
                            size_bytes=log_inactive_size)
            self.tweaks.append(tweak)
            group.add(self._create_tweak_row(tweak))

        # Note: systemd journal cleanup is now folded into the
        # log_archives tweak above (stage 4). No separate sysd_logs tweak.

        # Recent file lists
        recent_paths = [
            os.path.join(home, ".local/share/recently-used.xbel"),
            os.path.join(home, ".config/gtk-3.0/recently-used"),
            os.path.join(home, ".config/gtk-2.0/recently-used"),
        ]
        recents_size = 0
        for rp in recent_paths:
            if os.path.exists(rp):
                try:
                    recents_size += os.path.getsize(rp)
                except OSError:
                    pass
        if recents_size > 0:
            size_str = subprocess.run(['numfmt', '--to=iec', str(recents_size)], capture_output=True, text=True).stdout.strip()
            tweak = TweakItem("recents", "Recent File Lists", "Clean", "Home", "Safe",
                            f"Remove {size_str}B of recently-opened file history",
                            size_bytes=recents_size)
            self.tweaks.append(tweak)
            group.add(self._create_tweak_row(tweak))

        # Clipboard history (xfce4-clipman / parcellite)
        clipboard_paths = [
            os.path.join(home, ".cache/xfce4/clipman"),
            os.path.join(home, ".local/share/parcellite"),
        ]
        clipboard_size = 0
        for cp in clipboard_paths:
            if os.path.exists(cp):
                clipboard_size += get_dir_size_bytes(cp)
        if clipboard_size > 0:
            size_str = subprocess.run(['numfmt', '--to=iec', str(clipboard_size)], capture_output=True, text=True).stdout.strip()
            tweak = TweakItem("clipboard", "Clipboard History", "Clean", "Home", "Safe",
                            f"Remove {size_str}B of clipboard manager history",
                            size_bytes=clipboard_size)
            self.tweaks.append(tweak)
            group.add(self._create_tweak_row(tweak))

        # /tmp files older than 10 days (skipping live sockets + systemd-private dirs)
        tmp_old_size = 0
        if os.path.exists('/tmp'):
            cutoff = time.time() - (10 * 86400)
            protected_exact = {'/tmp/.X11-unix', '/tmp/.ICE-unix',
                               '/tmp/.font-unix', '/tmp/.Test-unix'}
            protected_prefixes = ('/tmp/.X11-unix/', '/tmp/.ICE-unix/',
                                  '/tmp/.font-unix/', '/tmp/.Test-unix/',
                                  '/tmp/systemd-private-')
            def _is_protected(path):
                if path in protected_exact:
                    return True
                return any(path.startswith(p) for p in protected_prefixes)
            try:
                for root_dir, dirs, files in os.walk('/tmp', topdown=True):
                    dirs[:] = [d for d in dirs
                               if not _is_protected(os.path.join(root_dir, d))]
                    if _is_protected(root_dir):
                        continue
                    for f in files:
                        fp = os.path.join(root_dir, f)
                        try:
                            st = os.lstat(fp)
                            if st.st_mtime < cutoff:
                                tmp_old_size += st.st_size
                        except OSError:
                            pass
            except Exception:
                pass
        if tmp_old_size > 0:
            size_str = subprocess.run(['numfmt', '--to=iec', str(tmp_old_size)], capture_output=True, text=True).stdout.strip()
            tweak = TweakItem("tmp_old", "Old Temporary Files", "Clean", "System", "Safe",
                            f"Remove {size_str}B from /tmp (files older than 10 days)",
                            size_bytes=tmp_old_size)
            self.tweaks.append(tweak)
            group.add(self._create_tweak_row(tweak))

        # Crash dumps in /var/crash
        crash_size = 0
        if os.path.exists('/var/crash'):
            try:
                for f in os.listdir('/var/crash'):
                    fp = os.path.join('/var/crash', f)
                    try:
                        if os.path.isfile(fp):
                            crash_size += os.path.getsize(fp)
                    except OSError:
                        pass
            except PermissionError:
                pass
        if crash_size > 0:
            size_str = subprocess.run(['numfmt', '--to=iec', str(crash_size)], capture_output=True, text=True).stdout.strip()
            tweak = TweakItem("crash_dumps", "Crash Dumps", "Clean", "System", "Safe",
                            f"Remove {size_str}B of crash reports from /var/crash",
                            size_bytes=crash_size)
            self.tweaks.append(tweak)
            group.add(self._create_tweak_row(tweak))

        # SystemD coredumps (raw cores from the kernel's core_pattern handler;
        # separate from Apport's /var/crash reports)
        sdc_size = 0
        sdc_dir = '/var/lib/systemd/coredump'
        if os.path.exists(sdc_dir):
            try:
                for f in os.listdir(sdc_dir):
                    fp = os.path.join(sdc_dir, f)
                    try:
                        if os.path.isfile(fp):
                            sdc_size += os.path.getsize(fp)
                    except OSError:
                        pass
            except PermissionError:
                pass
        if sdc_size > 0:
            size_str = subprocess.run(['numfmt', '--to=iec', str(sdc_size)], capture_output=True, text=True).stdout.strip()
            tweak = TweakItem("systemd_coredump", "SystemD Coredumps", "Clean", "System", "Safe",
                            f"Remove {size_str}B of systemd-coredump files from /var/lib/systemd/coredump",
                            size_bytes=sdc_size)
            self.tweaks.append(tweak)
            group.add(self._create_tweak_row(tweak))

        # Apport coredump staging area
        ap_size = 0
        ap_dir = '/var/lib/apport/coredump'
        if os.path.exists(ap_dir):
            try:
                for f in os.listdir(ap_dir):
                    fp = os.path.join(ap_dir, f)
                    try:
                        if os.path.isfile(fp):
                            ap_size += os.path.getsize(fp)
                    except OSError:
                        pass
            except PermissionError:
                pass
        if ap_size > 0:
            size_str = subprocess.run(['numfmt', '--to=iec', str(ap_size)], capture_output=True, text=True).stdout.strip()
            tweak = TweakItem("apport_coredump", "Apport Coredumps", "Clean", "System", "Safe",
                            f"Remove {size_str}B of Apport's staged coredumps from /var/lib/apport/coredump",
                            size_bytes=ap_size)
            self.tweaks.append(tweak)
            group.add(self._create_tweak_row(tweak))

        # /var/tmp files older than 30 days (systemd-tmpfiles' own age policy)
        vartmp_size = 0
        if os.path.exists('/var/tmp'):
            cutoff = time.time() - (30 * 86400)
            try:
                for root_dir, dirs, files in os.walk('/var/tmp', topdown=True):
                    for f in files:
                        fp = os.path.join(root_dir, f)
                        try:
                            st = os.lstat(fp)
                            if st.st_mtime < cutoff:
                                vartmp_size += st.st_size
                        except OSError:
                            pass
            except Exception:
                pass
        if vartmp_size > 0:
            size_str = subprocess.run(['numfmt', '--to=iec', str(vartmp_size)], capture_output=True, text=True).stdout.strip()
            tweak = TweakItem("vartmp_old", "Old Files in /var/tmp", "Clean", "System", "Safe",
                            f"Remove {size_str}B from /var/tmp (files older than 30 days)",
                            size_bytes=vartmp_size)
            self.tweaks.append(tweak)
            group.add(self._create_tweak_row(tweak))

        # Mesa shader cache — Caution-graded so One Click Clean leaves it
        # alone. Real space on gaming machines but clearing causes a few
        # seconds of shader recompile + stutter on the next game launch.
        #
        # Hidden unless > 100 MB. The shader cache regenerates with ANY
        # GPU activity (YouTube playback, Chromium, the compositor itself),
        # so 1-10 MB is constant background noise and the tweak would
        # reappear seconds after every cleanup. 100 MB is the threshold
        # where actual gamers have accumulated something worth reclaiming.
        mesa_paths = [
            os.path.join(home, ".cache/mesa_shader_cache"),
            os.path.join(home, ".cache/mesa_shader_cache_db"),
        ]
        mesa_size = sum(get_dir_size_bytes(p) for p in mesa_paths if os.path.exists(p))
        if mesa_size > 100 * 1024 * 1024:
            size_str = subprocess.run(['numfmt', '--to=iec', str(mesa_size)], capture_output=True, text=True).stdout.strip()
            tweak = TweakItem("mesa_shaders", "Mesa Shader Cache", "Clean", "System", "Caution",
                            f"Remove {size_str}B of compiled GPU shaders (will recompile on next game launch)",
                            size_bytes=mesa_size)
            self.tweaks.append(tweak)
            group.add(self._create_tweak_row(tweak))

        # Residual config
        result = subprocess.run(['dpkg', '-l'], capture_output=True, text=True)
        rc_count = sum(1 for line in result.stdout.splitlines() if line.startswith('rc'))
        if rc_count > 0:
            tweak = TweakItem("resid_config", "Residual Config Files", "Clean", "Packages", "Safe",
                            f"Remove {rc_count} residual configuration files")
            self.tweaks.append(tweak)
            group.add(self._create_tweak_row(tweak))

        # Autoremove
        dryapt_file = os.path.join(home, ".local/share/.dryapt")
        if os.path.exists(dryapt_file):
            try:
                with open(dryapt_file, 'r') as f:
                    count = int(f.readline().strip())
                if count > 0:
                    tweak = TweakItem("autoremove", "Autoremove Packages", "Clean", "Packages", "Safe",
                                    f"You can autoremove {count} packages")
                    self.tweaks.append(tweak)
                    group.add(self._create_tweak_row(tweak))
            except:
                pass

    def _create_performance_group(self):
        """Create the Performance preferences group."""
        group = PreferencesGroup(title="Performance", description="Optimize system performance")
        self.pref_page.append(group)

        # Clear memory
        tweak = TweakItem("clear_mem", "Clear Memory", "Performance", "System", "Safe",
                        "Free up memory on your system")
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

        # Preload
        is_preload_installed = subprocess.run(['dpkg', '-l', 'preload'], capture_output=True, text=True)
        preload_desc = "Preload is installed (select to modify)" if 'ii' in is_preload_installed.stdout else "Fetch commonly used apps into Memory"
        tweak = TweakItem("preload", "Preload Apps", "Performance", "System", "Safe", preload_desc)
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

        # zRAM
        is_zram_installed = subprocess.run(['dpkg', '-l', 'zram-config'], capture_output=True, text=True)
        zram_desc = "zRAM is installed (select to modify)" if 'ii' in is_zram_installed.stdout else "Use Memory more efficiently on older systems"
        tweak = TweakItem("zram", "zRAM", "Performance", "System", "Safe", zram_desc)
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

        # TLP
        is_tlp_installed = subprocess.run(['dpkg', '-l', 'tlp'], capture_output=True, text=True)
        tlp_desc = "TLP is installed (select to modify)" if 'ii' in is_tlp_installed.stdout else "TLP may help improve battery life in Laptops"
        tweak = TweakItem("tlp", "TLP (Laptops)", "Performance", "System", "Caution", tlp_desc)
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

    def _create_system_group(self):
        """Create the System preferences group."""
        group = PreferencesGroup(title="System", description="System configuration and repair")
        self.pref_page.append(group)

        # Hostname
        tweak = TweakItem("hostname", "Hostname", "Edit", "System", "Caution",
                        "Change the computer hostname")
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

        # Numlock
        tweak = TweakItem("numlock", "Numlock", "Edit", "System", "Safe",
                        "Enable/Disable Numlock at Login")
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

        # Manage Save Session
        tweak = TweakItem("save_session", "Manage Save Session", "Administration", "System", "Safe",
                        "Enable or disable saving of your session")
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

        # Login/Logout Options
        tweak = TweakItem("kiosk_mode", "Login & Logout Options", "Administration", "System", "Safe",
                        "Enable or disable basic Login & Logout options")
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

        # Bootup Fix
        tweak = TweakItem("fix_bootup", "Bootup Fix", "Fix", "Repair", "Caution",
                        "Restore the boot splash to Linux Lite")
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

        # Package System Repair
        tweak = TweakItem("fix_apt", "Package System Repair", "Fix", "Repair", "Caution",
                        "Restore the package management system")
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

        # Taskbar Restore
        tweak = TweakItem("taskbar_restore", "Taskbar Restore", "Fix", "UI", "Safe",
                        "Restore the Taskbar and Tray icons to default")
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

    def _create_information_group(self):
        """Create the Information preferences group."""
        group = PreferencesGroup(title="Information", description="View system information")
        self.pref_page.append(group)

        # Disk Usage
        tweak = TweakItem("disk_usage", "Display Disk Usage", "Information", "System", "Safe",
                        "Display overall disk usage")
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

        # Large Files
        tweak = TweakItem("large_files", "Locate Large Files", "Information", "System", "Caution",
                        "Find large files in your system")
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

    def _create_preferences_group(self):
        """Create the Preferences group."""
        group = PreferencesGroup(title="Preferences", description="Configure system preferences")
        self.pref_page.append(group)

        # Default Browser
        tweak = TweakItem("default_browser", "Default Web Browser", "Preferences", "Internet", "Safe",
                        "Set your default Web browser")
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

        # Hibernate/Suspend
        tweak = TweakItem("hibernate_suspend", "Hibernate, Suspend", "Preferences", "Session", "Safe",
                        "Hide or show these buttons at Logout")
        self.tweaks.append(tweak)
        group.add(self._create_tweak_row(tweak))

    def _on_begin_clicked(self, button):
        """Handle Begin button click."""
        if not self.selected_tweaks:
            show_alert(self, "No Tasks Selected", "Please select at least one task.",
                       [("ok", "OK")], lambda r: None)
            return

        # Process selected tweaks
        self._run_selected_tweaks()

    def _on_one_click_clean_clicked(self, button):
        """Confirm with the user, then tick every Safe Clean-task and run them.

        Deliberately excludes:
          - trash: user data, recoverable from file-manager — opt-in only.
          - clear_mem: drops kernel caches; not a disk-space reclaim.
          - Any task with grade != 'Safe'.
        Browser tweaks already only target Cache/ dirs, so history,
        cookies and passwords are untouched.
        """
        # Resolve eligible tweaks based on the currently-rendered set
        # (which already reflects "only show if there's something to free").
        EXCLUDE = {'trash', 'clear_mem'}
        eligible = [t for t in self.tweaks
                    if t.task == 'Clean' and t.grade == 'Safe'
                    and t.id not in EXCLUDE]
        # Autoremove is task='Clean' grade='Safe' already — but only present
        # if dryapt found orphans. It's included automatically.

        if not eligible:
            self.show_toast("Nothing to clean — your system is already tidy")
            return

        total_bytes = sum(t.size_bytes for t in eligible)
        if total_bytes > 0:
            size_str = subprocess.run(['numfmt', '--to=iec', str(total_bytes)],
                                      capture_output=True, text=True).stdout.strip()
            size_line = f"This will free approximately {size_str}B of disk space.\n\n"
        else:
            # Some eligible tasks (autoremove, residual config) don't expose
            # a byte count up-front, so an exact total isn't always knowable.
            size_line = ""

        listing = "\n".join(f"• {t.name}" for t in eligible)
        msg = (f"{size_line}"
               f"The following {len(eligible)} task(s) will run:\n\n"
               f"{listing}\n\n"
               f"All are Safe — only caches, logs, thumbnails, package "
               f"leftovers, recent-file lists, clipboard history, old "
               f"temporary files and crash reports are removed.\n\n"
               f"Your documents, browser history, cookies, saved passwords "
               f"and Trash bin are NOT touched.")

        def on_response(response):
            if response != "clean":
                return
            # Tick every eligible tweak's checkbox so the existing
            # _run_selected_tweaks() pipeline handles dispatch + progress.
            for t in eligible:
                if t.check_button:
                    t.check_button.set_active(True)
            self._run_selected_tweaks()

        show_alert(self, "Run One Click Clean?", msg,
                   [("cancel", "Cancel"), ("clean", "Clean Now")],
                   on_response,
                   appearances={"clean": "suggested-action"})

    def _run_selected_tweaks(self):
        """Run all selected tweaks."""
        # Separate into GUI actions and batch actions
        gui_actions = {'disk_usage', 'default_browser', 'hibernate_suspend',
                      'preload', 'zram', 'tlp', 'hostname', 'numlock',
                      'save_session', 'kiosk_mode', 'large_files'}

        batch_tasks = []

        for tweak_id in list(self.selected_tweaks):
            if tweak_id in gui_actions:
                self._run_gui_action(tweak_id)
            else:
                batch_tasks.append(tweak_id)

        if batch_tasks:
            self._run_batch_tasks(batch_tasks)

        # Clear selections
        for tweak in self.tweaks:
            if tweak.check_button:
                tweak.check_button.set_active(False)
        self.selected_tweaks.clear()

    def _run_gui_action(self, tweak_id):
        """Run a GUI-based action."""
        if tweak_id == 'disk_usage':
            win = DiskUsageWindow(self)
            win.present()
        elif tweak_id == 'default_browser':
            win = DefaultBrowserWindow(self)
            win.present()
        elif tweak_id == 'hibernate_suspend':
            win = HibernateSuspendWindow(self)
            win.present()
        elif tweak_id == 'preload':
            def check_preload():
                result = subprocess.run(['pgrep', 'preload'], capture_output=True)
                return result.returncode == 0
            win = ServiceManagerWindow(self, 'preload', 'Preload Apps', ['preload'], check_preload,
                                       "Preload monitors application usage and pre-loads common apps")
            win.present()
        elif tweak_id == 'zram':
            def check_zram():
                return os.path.exists('/dev/zram0')
            win = ServiceManagerWindow(self, 'zram', 'zRAM', ['zram-config'], check_zram,
                                       "zRAM creates compressed RAM-based swap")
            win.present()
        elif tweak_id == 'tlp':
            def check_tlp():
                # systemctl is-active is machine-readable and locale-stable;
                # tlp-stat's "State = enabled/disabled" line lies if the
                # service was activated only via `tlp start` (one-shot apply).
                result = subprocess.run(['systemctl', 'is-active', 'tlp.service'],
                                        capture_output=True, text=True)
                return result.stdout.strip() == 'active'
            win = ServiceManagerWindow(self, 'tlp', 'TLP Power Management', ['tlp'], check_tlp,
                                       "TLP optimizes power consumption")
            win.present()
        elif tweak_id == 'hostname':
            self._show_hostname_dialog()
        elif tweak_id == 'numlock':
            self._show_numlock_dialog()
        elif tweak_id == 'save_session':
            self._show_save_session_dialog()
        elif tweak_id == 'kiosk_mode':
            self._show_kiosk_mode_dialog()
        elif tweak_id == 'large_files':
            self._show_large_files_dialog()

    def _show_hostname_dialog(self):
        """Show hostname change dialog."""
        current = subprocess.run(['hostname'], capture_output=True, text=True).stdout.strip()

        entry = Gtk.Entry()
        entry.set_text(current)
        entry.set_margin_start(12)
        entry.set_margin_end(12)

        def on_response(response):
            if response == "change":
                new_hostname = entry.get_text().strip()
                if new_hostname and new_hostname != current:
                    if re.match(r'^[a-zA-Z0-9][-a-zA-Z0-9]*$', new_hostname):
                        username = os.environ.get('USER', 'user')
                        subprocess.run(['pkexec', '/usr/bin/lite-tweaks', '--root-action',
                                      'hostname', new_hostname, username])
                        self.show_toast(f"Hostname changed to {new_hostname}")
                    else:
                        show_alert(self, "Invalid Hostname",
                                   "Hostname must start with a letter/number and contain only letters, numbers, and hyphens.",
                                   [("ok", "OK")], lambda r: None)

        show_alert(self, "Change Hostname",
                   f"Current hostname: {current}\n\nEnter the new hostname:",
                   [("cancel", "Cancel"), ("change", "Change")],
                   on_response, extra_child=entry,
                   appearances={"change": "suggested-action"})

    def _show_numlock_dialog(self):
        """Show numlock configuration dialog."""
        # Check current state
        lightdm_conf = '/etc/lightdm/lightdm.conf'
        current_enabled = False
        if os.path.exists(lightdm_conf):
            with open(lightdm_conf, 'r') as f:
                current_enabled = 'numlockx on' in f.read()

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        box.set_margin_start(12)
        box.set_margin_end(12)

        enable_check = Gtk.CheckButton(label="Enable Numlock at login")
        enable_check.set_active(current_enabled)
        box.append(enable_check)

        def on_response(response):
            if response == "apply":
                enabled = enable_check.get_active()
                action = 'enable' if enabled else 'disable'
                subprocess.run(['pkexec', '/usr/bin/lite-tweaks', '--root-action', 'numlock', action])
                state = "enabled" if enabled else "disabled"
                self.show_toast(f"Numlock {state} at login")

        show_alert(self, "Numlock at Login", "Enable or disable Numlock at login.",
                   [("cancel", "Cancel"), ("apply", "Apply")],
                   on_response, extra_child=box,
                   appearances={"apply": "suggested-action"})

    def _show_save_session_dialog(self):
        """Show save session configuration dialog."""
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        box.set_margin_start(12)
        box.set_margin_end(12)

        group = Gtk.CheckButton()

        all_btn = Gtk.CheckButton(label="Enable for all users")
        all_btn.set_group(group)
        all_btn.set_active(True)
        box.append(all_btn)

        admin_btn = Gtk.CheckButton(label="Enable for admins only")
        admin_btn.set_group(group)
        box.append(admin_btn)

        none_btn = Gtk.CheckButton(label="Disable for all users")
        none_btn.set_group(group)
        box.append(none_btn)

        def on_response(response):
            if response == "apply":
                if all_btn.get_active():
                    mode = 'ALL'
                    desc = "all users"
                elif admin_btn.get_active():
                    mode = '%sudo'
                    desc = "admins only"
                else:
                    mode = 'NONE'
                    desc = "disabled"
                subprocess.run(['pkexec', '/usr/bin/lite-tweaks', '--root-action', 'save-session', mode])
                self.show_toast(f"Save session set to {desc}")

        show_alert(self, "Manage Save Session",
                   "Configure who can save their session at logout.",
                   [("cancel", "Cancel"), ("apply", "Apply")],
                   on_response, extra_child=box,
                   appearances={"apply": "suggested-action"})

    def _show_kiosk_mode_dialog(self):
        """Show login/logout options dialog."""
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        box.set_margin_start(12)
        box.set_margin_end(12)

        # Logout screen options
        logout_label = Gtk.Label(label="Logout Screen Shutdown Options:")
        logout_label.set_xalign(0)
        box.append(logout_label)

        logout_group = Gtk.CheckButton()

        all_btn = Gtk.CheckButton(label="Show all options for all users")
        all_btn.set_group(logout_group)
        all_btn.set_active(True)
        box.append(all_btn)

        admin_btn = Gtk.CheckButton(label="Show logout only, except for admins")
        admin_btn.set_group(logout_group)
        box.append(admin_btn)

        none_btn = Gtk.CheckButton(label="Show logout only for all users")
        none_btn.set_group(logout_group)
        box.append(none_btn)

        def on_response(response):
            if response == "apply":
                if all_btn.get_active():
                    value = 'ALL'
                    desc = "all users"
                elif admin_btn.get_active():
                    value = '%sudo'
                    desc = "admins only"
                else:
                    value = 'NONE'
                    desc = "logout only"
                subprocess.run(['pkexec', '/usr/bin/lite-tweaks', '--root-action', 'kiosk-mode', 'shutdown', value])
                self.show_toast(f"Shutdown options set to {desc}")

        show_alert(self, "Login & Logout Options",
                   "Configure power options visibility.",
                   [("cancel", "Cancel"), ("apply", "Apply")],
                   on_response, extra_child=box,
                   appearances={"apply": "suggested-action"})

    def _show_large_files_dialog(self):
        """Show large files finder dialog."""
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        box.set_margin_start(12)
        box.set_margin_end(12)

        min_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        min_label = Gtk.Label(label="Minimum size (MB):")
        min_spin = Gtk.SpinButton()
        min_spin.set_range(25, 5000)
        min_spin.set_value(25)
        min_spin.set_increments(25, 100)
        min_box.append(min_label)
        min_box.append(min_spin)
        box.append(min_box)

        max_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        max_label = Gtk.Label(label="Maximum size (MB):")
        max_spin = Gtk.SpinButton()
        max_spin.set_range(50, 5000)
        max_spin.set_value(500)
        max_spin.set_increments(25, 100)
        max_box.append(max_label)
        max_box.append(max_spin)
        box.append(max_box)

        def on_response(response):
            if response == "search":
                min_size = int(min_spin.get_value())
                max_size = int(max_spin.get_value())
                if max_size <= min_size:
                    show_alert(self, "Invalid Range",
                               "Maximum must be greater than minimum.",
                               [("ok", "OK")], lambda r: None)
                    return

                output_file = '/var/log/findfilesrange.log'
                subprocess.run(['pkexec', '/usr/bin/lite-tweaks', '--root-action',
                              'find-files', str(min_size), str(max_size), output_file])

                # Open results in text editor
                if os.path.exists(output_file):
                    subprocess.Popen(['xdg-open', output_file])

        show_alert(self, "Find Large Files",
                   "Specify the file size range to search for.",
                   [("cancel", "Cancel"), ("search", "Search")],
                   on_response, extra_child=box,
                   appearances={"search": "suggested-action"})

    def _run_batch_tasks(self, tasks):
        """Run batch tasks with progress."""
        # Task ID to human-readable name mapping
        TASK_NAMES = {
            'browser_brave': 'Clear Brave Cache',
            'browser_chrome': 'Clear Chrome Cache',
            'browser_chromium': 'Clear Chromium Cache',
            'browser_firefox': 'Clear Firefox Cache',
            'browser_edge': 'Clear Edge Cache',
            'browser_midori': 'Clear Midori Cache',
            'browser_opera': 'Clear Opera Cache',
            'browser_palemoon': 'Clear Pale Moon Cache',
            'browser_vivaldi': 'Clear Vivaldi Cache',
            'thumbnails': 'Clear Thumbnail Cache',
            'trash': 'Empty Trash',
            'taskbar_restore': 'Restore Taskbar',
            'apt_clean': 'Clean APT Cache',
            'clear_mem': 'Clear Memory',
            'log_archives': 'Remove Inactive Log Files',
            'recents': 'Clear Recent File Lists',
            'clipboard': 'Clear Clipboard History',
            'tmp_old': 'Clear Old Temporary Files',
            'vartmp_old': 'Clear Old /var/tmp Files',
            'crash_dumps': 'Clear Crash Dumps',
            'systemd_coredump': 'Clear SystemD Coredumps',
            'apport_coredump': 'Clear Apport Coredumps',
            'mesa_shaders': 'Clear Mesa Shader Cache',
            'resid_config': 'Remove Residual Configs',
            'autoremove': 'Autoremove Packages',
            'fix_apt': 'Repair Package System',
            'fix_bootup': 'Fix Bootup Issues',
        }

        progress_win = ProgressWindow(self, "Running Tasks")
        progress_win.present()

        # Add all tasks to the list
        time.sleep(0.1)  # Brief delay to let window render
        for task_id in tasks:
            task_name = TASK_NAMES.get(task_id, task_id.replace('_', ' ').title())
            progress_win.add_task(task_id, task_name)

        def do_tasks():
            home = os.path.expanduser("~")
            username = os.environ.get('USER', 'user')
            total = len(tasks)
            completed = 0

            # Separate root and non-root tasks
            root_tasks = []

            for task_id in tasks:
                task_name = TASK_NAMES.get(task_id, task_id.replace('_', ' ').title())
                progress_win.set_task_running(task_id)
                progress_win.set_status(f"{task_name}...")
                progress_win.set_progress(completed / total)

                # Non-root tasks
                if task_id.startswith('browser_'):
                    browser = task_id.replace('browser_', '')
                    cache_path = os.path.join(home, BROWSER_CACHES.get(browser, ''))
                    if os.path.exists(cache_path):
                        shutil.rmtree(cache_path, ignore_errors=True)
                    progress_win.set_task_completed(task_id)
                    completed += 1

                elif task_id == 'thumbnails':
                    # Both XDG dir and legacy ~/.thumbnails (old GTK2 apps)
                    for thumb_path in [
                        os.path.join(home, ".cache/thumbnails"),
                        os.path.join(home, ".thumbnails"),
                    ]:
                        if not os.path.exists(thumb_path):
                            continue
                        for item in os.listdir(thumb_path):
                            path = os.path.join(thumb_path, item)
                            try:
                                if os.path.isfile(path):
                                    os.remove(path)
                                else:
                                    shutil.rmtree(path, ignore_errors=True)
                            except OSError:
                                pass
                    progress_win.set_task_completed(task_id)
                    completed += 1

                elif task_id == 'mesa_shaders':
                    for mesa_path in [
                        os.path.join(home, ".cache/mesa_shader_cache"),
                        os.path.join(home, ".cache/mesa_shader_cache_db"),
                    ]:
                        if not os.path.exists(mesa_path):
                            continue
                        for item in os.listdir(mesa_path):
                            path = os.path.join(mesa_path, item)
                            try:
                                if os.path.isfile(path):
                                    os.remove(path)
                                else:
                                    shutil.rmtree(path, ignore_errors=True)
                            except OSError:
                                pass
                    progress_win.set_task_completed(task_id)
                    completed += 1

                elif task_id == 'trash':
                    trash_files = os.path.join(home, ".local/share/Trash/files")
                    trash_info = os.path.join(home, ".local/share/Trash/info")
                    for folder in [trash_files, trash_info]:
                        if os.path.exists(folder):
                            for item in os.listdir(folder):
                                path = os.path.join(folder, item)
                                if os.path.isfile(path):
                                    os.remove(path)
                                else:
                                    shutil.rmtree(path, ignore_errors=True)
                    progress_win.set_task_completed(task_id)
                    completed += 1

                elif task_id == 'taskbar_restore':
                    # Restore the panel from /etc/skel, then re-spawn the
                    # tray clients. On LL 8.0 (xfce4-panel 4.18 + nm-applet
                    # 1.34) tray apps DO NOT auto-re-register against the
                    # new StatusNotifierWatcher when the panel restarts —
                    # nm-applet keeps running but its icon vanishes. So we
                    # kill the well-known tray clients up front and respawn
                    # them after the panel's systray plugin is back.
                    script = (
                        f'xfce4-panel --quit; pkill xfconfd;'
                        f' pkill -TERM nm-applet 2>/dev/null;'
                        f' pkill -TERM blueman-applet 2>/dev/null;'
                        f' pkill -TERM xfce4-clipman 2>/dev/null;'
                        f' pkill -TERM pasystray 2>/dev/null;'
                        f' rm -rf {home}/.config/xfce4/panel;'
                        f' rm -rf {home}/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-panel.xml;'
                        f' sleep 2;'
                        f' cp -R /etc/skel/.config/xfce4/panel {home}/.config/xfce4/;'
                        f' cp /etc/skel/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-panel.xml'
                        f' {home}/.config/xfce4/xfconf/xfce-perchannel-xml/;'
                        f' xfce4-panel &'
                        f' sleep 3;'
                        f' for c in nm-applet blueman-applet xfce4-clipman pasystray; do'
                        f'   command -v "$c" >/dev/null 2>&1 &&'
                        f'   nohup "$c" >/dev/null 2>&1 </dev/null &'
                        f' done;'
                        f' disown -a 2>/dev/null || true'
                    )
                    subprocess.Popen(['bash', '-c', script])
                    time.sleep(8)
                    progress_win.set_task_completed(task_id)
                    completed += 1

                elif task_id == 'recents':
                    for p in [
                        os.path.join(home, ".local/share/recently-used.xbel"),
                        os.path.join(home, ".config/gtk-3.0/recently-used"),
                        os.path.join(home, ".config/gtk-2.0/recently-used"),
                    ]:
                        if os.path.exists(p):
                            try:
                                os.remove(p)
                            except OSError:
                                pass
                    progress_win.set_task_completed(task_id)
                    completed += 1

                elif task_id == 'clipboard':
                    for p in [
                        os.path.join(home, ".cache/xfce4/clipman"),
                        os.path.join(home, ".local/share/parcellite"),
                    ]:
                        if os.path.exists(p):
                            for item in os.listdir(p):
                                ip = os.path.join(p, item)
                                try:
                                    if os.path.isfile(ip):
                                        os.remove(ip)
                                    else:
                                        shutil.rmtree(ip, ignore_errors=True)
                                except OSError:
                                    pass
                    progress_win.set_task_completed(task_id)
                    completed += 1

                # Root tasks - collect for later
                elif task_id == 'apt_clean':
                    root_tasks.append((task_id, 'apt-clean', None, 'Cleaning APT cache...'))
                elif task_id == 'clear_mem':
                    root_tasks.append((task_id, 'clear-mem', None, 'Clearing memory...'))
                elif task_id == 'log_archives':
                    root_tasks.append((task_id, 'log-archives', None, 'Removing inactive log files...'))
                elif task_id == 'tmp_old':
                    root_tasks.append((task_id, 'tmp-old', None, 'Removing old /tmp files...'))
                elif task_id == 'vartmp_old':
                    root_tasks.append((task_id, 'vartmp-old', None, 'Removing old /var/tmp files...'))
                elif task_id == 'crash_dumps':
                    root_tasks.append((task_id, 'crash-dumps', None, 'Removing crash dumps...'))
                elif task_id == 'systemd_coredump':
                    root_tasks.append((task_id, 'systemd-coredump', None, 'Removing systemd coredumps...'))
                elif task_id == 'apport_coredump':
                    root_tasks.append((task_id, 'apport-coredump', None, 'Removing Apport coredumps...'))
                elif task_id == 'resid_config':
                    root_tasks.append((task_id, 'resid-config', None, 'Removing residual configs...'))
                elif task_id == 'autoremove':
                    root_tasks.append((task_id, 'remove-pkgs', username, 'Autoremove packages...'))
                elif task_id == 'fix_apt':
                    root_tasks.append((task_id, 'fix-apt', None, 'Repairing package system...'))
                elif task_id == 'fix_bootup':
                    root_tasks.append((task_id, 'fix-bootup', None, 'Fixing bootup issues...'))

                progress_win.set_progress(completed / total)

            # Run root tasks with individual progress
            for task_id, action, arg, status_msg in root_tasks:
                progress_win.set_task_running(task_id)
                progress_win.set_status(status_msg)

                cmd = ['pkexec', '/usr/bin/lite-tweaks', '--root-action', action]
                if arg:
                    cmd.append(arg)

                # Stage counts for sub-progress within root tasks
                stage_counts = {
                    'apt-clean': 1, 'clear-mem': 2, 'log-archives': 4,
                    'tmp-old': 2, 'vartmp-old': 2,
                    'crash-dumps': 2, 'systemd-coredump': 2,
                    'apport-coredump': 2, 'resid-config': 2,
                    'remove-pkgs': 3, 'fix-apt': 7, 'fix-bootup': 5,
                }
                total_stages = stage_counts.get(action, 1)

                proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
                for line in proc.stdout:
                    line = line.strip()
                    if line and line.startswith("Stage"):
                        progress_win.set_status(line)
                        try:
                            stage_num = int(line.split(':')[0].split()[1])
                            frac = (completed + stage_num / total_stages) / total
                            progress_win.set_progress(min(frac, 0.99))
                        except (ValueError, IndexError):
                            pass
                proc.wait()

                progress_win.set_task_completed(task_id)
                completed += 1
                progress_win.set_progress(completed / total)

            progress_win.set_status("All tasks completed!")
            progress_win.set_progress(1.0)

            # Close after a brief delay so user can see completion
            time.sleep(1.5)
            GLib.idle_add(self._on_batch_complete, progress_win)

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

    def _on_batch_complete(self, progress_win):
        """Called on main thread when batch tasks finish."""
        progress_win.close()
        self._refresh_groups()
        self.show_toast("All tasks completed successfully")


def main():
    """Main entry point."""
    # Check if running as root action
    if len(sys.argv) >= 2 and sys.argv[1] == '--root-action':
        sys.exit(handle_root_action())

    # Check if running xauth cleanup
    if len(sys.argv) >= 2 and sys.argv[1] == '--xauth-cleanup':
        run_xauth_cleanup()
        sys.exit(0)

    # Normal GUI mode
    app = LiteTweaksApp()
    sys.exit(app.run(sys.argv))


if __name__ == '__main__':
    main()
