#!/usr/bin/env python3

import os
import sys

# Preserve DBUS_SESSION_BUS_ADDRESS when running under sudo
if 'DBUS_SESSION_BUS_ADDRESS' not in os.environ and 'SUDO_USER' in os.environ:
    sudo_uid = os.environ.get('SUDO_UID')
    if sudo_uid:
        for path in (f'/run/user/{sudo_uid}/bus',):
            if os.path.exists(path):
                os.environ['DBUS_SESSION_BUS_ADDRESS'] = f'unix:path={path}'
                break

import json
import subprocess
import threading
from pathlib import Path

if os.geteuid() != 0:
    msg = 'Error: Onix Package Manager requires root privileges.\nPlease run with: sudo onix-package-manager\n'
    print(msg, file=sys.stderr)
    for n in ('/usr/bin/zenity', '/usr/bin/kdialog'):
        if os.path.exists(n):
            break
    else:
        n = None
    if n and 'DISPLAY' in os.environ:
        import subprocess as _sp
        _sp.run([n, '--error', '--title', 'Onix Package Manager',
                 '--text', 'Requires root privileges.\nRun with: sudo onix-package-manager'], check=False)
    sys.exit(1)

import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Gtk, Adw, GLib, Gio, GObject, Pango, Gdk

GLib.set_prgname('onix-package-manager')
GLib.set_application_name('Onix Package Manager')

APP_ID = 'org.onix.package_manager'

# ── Settings ────────────────────────────────────────────────────────────────────

SETTINGS_PATH = '/etc/onix/settings.json'
_settings = None

def load_settings():
    global _settings
    try:
        with open(SETTINGS_PATH) as f:
            _settings = json.load(f)
    except Exception:
        _settings = {'flatpak_enabled': True, 'aur_enabled': False}

def save_settings():
    global _settings
    try:
        os.makedirs(os.path.dirname(SETTINGS_PATH), exist_ok=True)
        with open(SETTINGS_PATH, 'w') as f:
            json.dump(_settings, f)
    except Exception as e:
        print(f'Warning: could not save settings: {e}', file=sys.stderr)

load_settings()

# ── Pacman lock check ───────────────────────────────────────────────────────────

def check_pacman_lock():
    lock = Path('/var/lib/pacman/db.lck')
    if not lock.exists():
        return
    try:
        import fcntl
        fd = os.open(str(lock), os.O_RDONLY)
        try:
            fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
            fcntl.flock(fd, fcntl.LOCK_UN)
            lock.unlink()
            print('Removed stale pacman database lock', file=sys.stderr)
        except IOError:
            pass
        finally:
            os.close(fd)
    except Exception:
        pass

check_pacman_lock()

# ── Utility checks ──────────────────────────────────────────────────────────────

FLATPAK_BIN = Path('/usr/bin/flatpak')
YAY_BIN = Path('/usr/bin/yay')

def flatpak_available():
    return FLATPAK_BIN.exists()

def yay_available():
    return YAY_BIN.exists()

def get_repos_from_conf():
    repos = []
    try:
        with open('/etc/pacman.conf') as f:
            for line in f:
                line = line.strip()
                if line.startswith('[') and line.endswith(']'):
                    name = line[1:-1]
                    if name.lower() != 'options':
                        repos.append(name)
    except Exception:
        repos = ['core', 'extra', 'community', 'multilib']
    return repos

def build_categories():
    cats = [
        ('all',          'All'),
        ('installed',    'Installed'),
        ('not-installed','Not Installed'),
        ('upgradable',   'Upgradable'),
        ('sep',          None),
    ]
    for r in get_repos_from_conf():
        cats.append((r.lower(), r))
    if _settings.get('flatpak_enabled') and flatpak_available():
        cats.append(('sep2', None))
        cats.append(('flatpak-all', 'Flatpak (All)'))
        cats.append(('flatpak-installed', 'Flatpak (Installed)'))
        cats.append(('flatpak-available', 'Flatpak (Available)'))
    if _settings.get('aur_enabled') and yay_available():
        if not any(k.startswith('sep') for k, _ in cats[-3:]):
            cats.append(('sep3', None))
        cats.append(('aur-all', 'AUR'))
    return cats

CATEGORIES = build_categories()

# ── Package GObject ────────────────────────────────────────────────────────────

class Pkg(GObject.Object):
    __gtype_name__ = 'Pkg'

    name = GObject.Property(type=str, default='')
    version = GObject.Property(type=str, default='')
    installed_version = GObject.Property(type=str, default='')
    description = GObject.Property(type=str, default='')
    repo = GObject.Property(type=str, default='')
    manager = GObject.Property(type=str, default='pacman')
    installed = GObject.Property(type=bool, default=False)
    mark = GObject.Property(type=str, default='')

    def get_upgradable(self):
        return bool(self.installed and self.installed_version and self.version
                    and self.installed_version != self.version)


# ── Store ──────────────────────────────────────────────────────────────────────

class Store:
    def __init__(self):
        self.store = Gio.ListStore.new(Pkg)

        self._category = 'all'
        self._search_q = ''
        self._aur_results: list[Pkg] | None = None
        self._saved_batch: list[Pkg] | None = None

        dummy_all = Gtk.CustomFilter.new(lambda *_: True, None, None)
        self.filter_model = Gtk.FilterListModel.new(self.store, dummy_all)

        self.sort_model = Gtk.SortListModel.new(self.filter_model, None)
        self.selection = Gtk.MultiSelection.new(self.sort_model)

        self._loading = False
        self._on_done = None
        self._on_marks_changed = None

    def _make_filter(self):
        cat = self._category
        q = self._search_q.strip().lower() if self._search_q else ''

        def match(item, *args):
            pkg = item
            if cat == 'all':
                pass
            elif cat == 'installed':
                if not pkg.installed:
                    return False
            elif cat == 'not-installed':
                if pkg.installed or pkg.manager != 'pacman':
                    return False
            elif cat == 'upgradable':
                if not pkg.get_upgradable():
                    return False
            elif cat == 'flatpak-all':
                if pkg.manager != 'flatpak':
                    return False
            elif cat == 'flatpak-installed':
                if pkg.manager != 'flatpak' or not pkg.installed:
                    return False
            elif cat == 'flatpak-available':
                if pkg.manager != 'flatpak' or pkg.installed:
                    return False
            elif cat == 'aur-all':
                if pkg.manager != 'aur':
                    return False
            else:
                if pkg.manager != 'pacman' or pkg.repo.lower() != cat:
                    return False
            if q:
                if q not in pkg.name.lower() and q not in pkg.description.lower():
                    return False
            return True

        return Gtk.CustomFilter.new(match, None, None)

    def _refresh_filter(self):
        self.filter_model.set_filter(self._make_filter())

    def load(self, on_done):
        self._on_done = on_done
        if self._loading:
            return
        self._loading = True
        threading.Thread(target=self._run, daemon=True).start()

    def _run(self):
        try:
            sl_r = [None]
            q_r = [None]
            fp_r = [None]
            fp_avail_r = [None]

            def get_sl():
                sl_r[0] = subprocess.run(['pacman', '-Sl'], capture_output=True, text=True, timeout=120)

            def get_q():
                q_r[0] = subprocess.run(['pacman', '-Q'], capture_output=True, text=True, timeout=60)

            def get_fp():
                if _settings.get('flatpak_enabled') and flatpak_available():
                    fp_r[0] = subprocess.run(
                        ['flatpak', 'list', '--columns=application,version,branch,origin'],
                        capture_output=True, text=True, timeout=30)

            def get_fp_avail():
                if _settings.get('flatpak_enabled') and flatpak_available():
                    fp_avail_r[0] = subprocess.run(
                        ['flatpak', 'remote-ls', '--columns=application,version,branch,origin,description'],
                        capture_output=True, text=True, timeout=60)

            threads = [
                threading.Thread(target=get_sl, daemon=True),
                threading.Thread(target=get_q, daemon=True),
                threading.Thread(target=get_fp, daemon=True),
                threading.Thread(target=get_fp_avail, daemon=True),
            ]
            for t in threads:
                t.start()
            for t in threads:
                t.join()

            installed_map = {}
            if q_r[0] and q_r[0].stdout:
                for line in q_r[0].stdout.splitlines():
                    parts = line.strip().split()
                    if len(parts) >= 2:
                        installed_map[parts[0]] = parts[1]

            # Track flatpak installed apps for dedup
            flatpak_installed = set()
            fp_installed_data = {}

            if fp_r[0] and fp_r[0].stdout:
                for line in fp_r[0].stdout.splitlines():
                    parts = line.strip().split('\t')
                    if len(parts) < 1 or parts[0].startswith('Application'):
                        continue
                    name = parts[0]
                    flatpak_installed.add(name)
                    fp_installed_data[name] = {
                        'ver': parts[1] if len(parts) > 1 else '',
                        'origin': parts[3] if len(parts) > 3 else 'unknown',
                    }

            batch = []
            if sl_r[0] and sl_r[0].stdout:
                for line in sl_r[0].stdout.splitlines():
                    parts = line.strip().split()
                    if len(parts) < 3:
                        continue
                    repo, name, ver = parts[0], parts[1], parts[2]
                    iv = installed_map.get(name, '')
                    desc = ''
                    if len(parts) > 3:
                        desc = ' '.join(parts[3:])
                    p = Pkg(name=name, version=ver, installed_version=iv, installed=bool(iv),
                            repo=repo, manager='pacman', description=desc)
                    batch.append(p)

            # Add installed flatpak packages
            for name, data in fp_installed_data.items():
                p = Pkg(name=name, version=data['ver'], installed_version=data['ver'],
                        installed=True, repo=data['origin'], manager='flatpak')
                batch.append(p)

            # Add available (not installed) flatpak packages from remote-ls
            if fp_avail_r[0] and fp_avail_r[0].stdout:
                seen = flatpak_installed.copy()
                for line in fp_avail_r[0].stdout.splitlines():
                    parts = line.strip().split('\t')
                    if len(parts) < 1 or parts[0].startswith('Application'):
                        continue
                    name = parts[0]
                    if name in seen:
                        continue
                    seen.add(name)
                    ver = parts[1] if len(parts) > 1 else ''
                    origin = parts[3] if len(parts) > 3 else 'unknown'
                    desc = parts[4] if len(parts) > 4 else ''
                    p = Pkg(name=name, version=ver, installed_version='', installed=False,
                            repo=origin, manager='flatpak', description=desc)
                    batch.append(p)

            batch.sort(key=lambda p: p.name.lower())
            self._saved_batch = batch

            def add_batch(start):
                end = min(start + 500, len(batch))
                for p in batch[start:end]:
                    self.store.append(p)
                if end < len(batch):
                    GLib.idle_add(lambda: add_batch(end))
                else:
                    self._loading = False
                    if self._on_done:
                        self._on_done(True, f'{len(batch)} packages')

            GLib.idle_add(lambda: add_batch(0))

        except Exception as e:
            self._loading = False
            if self._on_done:
                GLib.idle_add(lambda: self._on_done(False, str(e)))

    def set_category(self, cat: str):
        was_aur = self._category == 'aur-all'
        self._category = cat
        if cat == 'aur-all':
            self._search_q = ''
            self.store.remove_all()
            self._refresh_filter()
            if self._on_done:
                self._on_done(True, 'Type to search AUR packages')
        elif was_aur:
            self._restore_main_list()
        else:
            self._refresh_filter()

    def _restore_main_list(self):
        self.store.remove_all()
        if self._saved_batch:
            for p in self._saved_batch:
                self.store.append(p)
            self._refresh_filter()
            if self._on_done:
                self._on_done(True, f'{len(self._saved_batch)} packages')
        else:
            self.load(self._on_done or (lambda *_: None))

    def set_search(self, q: str):
        self._search_q = q
        if self._category == 'aur-all' and q.strip():
            self._trigger_aur_search(q.strip())
        else:
            self._refresh_filter()

    def _trigger_aur_search(self, q: str):
        threading.Thread(target=self._aur_search_thread, args=(q,), daemon=True).start()

    def _aur_search_thread(self, q: str):
        try:
            r = subprocess.run(
                [str(YAY_BIN), '-Ss', q],
                capture_output=True, text=True, timeout=30)
            results: list[Pkg] = []
            lines = r.stdout.splitlines()
            i = 0
            while i < len(lines):
                line = lines[i].strip()
                if line.startswith('aur/'):
                    parts = line.split()
                    if len(parts) >= 2:
                        name = parts[0].split('/')[1]
                        ver = parts[1]
                        desc = lines[i + 1].strip() if i + 1 < len(lines) else ''
                        results.append(Pkg(
                            name=name, version=ver, installed_version='',
                            installed=False, repo='aur', manager='aur',
                            description=desc))
                        i += 1
                i += 1
            self._aur_results = results
            GLib.idle_add(self._load_aur_results)
        except Exception as e:
            print(f'AUR search error: {e}', file=sys.stderr)

    def _load_aur_results(self):
        self.store.remove_all()
        for p in (self._aur_results or []):
            self.store.append(p)
        self._refresh_filter()
        if self._on_done:
            self._on_done(True, f'{len(self._aur_results or [])} AUR packages')

    def mark_count(self):
        inst = rem = 0
        n = self.store.get_n_items()
        for i in range(n):
            p = self.store.get_item(i)
            if p.mark == 'install':
                inst += 1
            elif p.mark == 'remove':
                rem += 1
        return inst, rem

    def get_selected_pkgs(self):
        result = []
        bitset = self.selection.get_selection()
        size = bitset.get_size()
        for i in range(size):
            pos = bitset.get_nth(i)
            pkg = self.sort_model.get_item(pos)
            result.append(pkg)
        return result

    def set_on_marks_changed(self, cb):
        self._on_marks_changed = cb

    def marks_changed(self):
        if self._on_marks_changed:
            self._on_marks_changed()

    def details(self, p: Pkg):
        if p.manager == 'flatpak':
            try:
                if p.installed:
                    r = subprocess.run(['flatpak', 'info', p.name], capture_output=True, text=True, timeout=15)
                else:
                    r = subprocess.run(['flatpak', 'remote-info', '--system', p.repo, p.name],
                                       capture_output=True, text=True, timeout=15)
                d = {'description': p.description, 'size': '', 'url': '',
                      'deps': [], 'optdeps': [], 'license': '', 'groups': []}
                for line in r.stdout.splitlines():
                    if ': ' not in line:
                        continue
                    key, val = line.split(': ', 1)
                    if key == 'Summary':
                        d['description'] = val
                    elif key == 'Installed':
                        d['size'] = val
                    elif key == 'Download':
                        d['size'] = val
                    elif key == 'Origin':
                        d['url'] = val
                    elif key == 'License':
                        d['license'] = val
                if not d['description']:
                    d['description'] = p.description
                return d
            except Exception:
                return {'description': p.description, 'size': '', 'url': '',
                        'deps': [], 'optdeps': [], 'license': '', 'groups': []}

        try:
            flag = '-Qi' if p.installed else '-Si'
            r = subprocess.run(['pacman', flag, p.name], capture_output=True, text=True, timeout=15)
            d = {'description': '', 'deps': [], 'optdeps': [], 'size': '', 'url': '', 'license': '', 'groups': []}
            for line in r.stdout.splitlines():
                s = line.strip()
                if s.startswith('Description') and ': ' in s:
                    d['description'] = s.split(': ', 1)[1]
                elif s.startswith('Depends On') and ': ' in s:
                    raw = s.split(': ', 1)[1].strip()
                    d['deps'] = [x.strip() for x in raw.split() if x.strip() and x != 'None']
                elif s.startswith('Optional Deps') and ': ' in s:
                    raw = s.split(': ', 1)[1].strip()
                    d['optdeps'] = [x.strip() for x in raw.split() if x.strip() and x != 'None']
                elif s.startswith('Installed Size') and ': ' in s:
                    d['size'] = s.split(': ', 1)[1]
                elif s.startswith('Download Size') and ': ' in s and not d['size']:
                    d['size'] = s.split(': ', 1)[1]
                elif s.startswith('URL') and ': ' in s:
                    d['url'] = s.split(': ', 1)[1]
                elif s.startswith('License') and ': ' in s:
                    d['license'] = s.split(': ', 1)[1]
                elif s.startswith('Groups') and ': ' in s:
                    raw = s.split(': ', 1)[1].strip()
                    d['groups'] = [x.strip() for x in raw.split() if x.strip() and x != 'None']
            return d
        except Exception:
            return {'description': '', 'deps': [], 'optdeps': [], 'size': '', 'url': '', 'license': '', 'groups': []}


# ── Category sidebar rows ─────────────────────────────────────────────────────

class CatRow(Gtk.ListBoxRow):
    def __init__(self, key: str, label: str | None):
        super().__init__()
        self.key = key
        if label is None:
            self.set_selectable(False)
            self.set_activatable(False)
            self.set_child(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
        else:
            self._lbl = Gtk.Label(label=label, xalign=0.0, margin_start=8, margin_top=5, margin_bottom=5)
            self.set_child(self._lbl)


# ── Details Panel ──────────────────────────────────────────────────────────────

class DetailsPanel(Gtk.Notebook):
    def __init__(self):
        super().__init__()
        self.set_size_request(-1, 200)
        self.set_show_border(False)

        self._desc = Gtk.Label(label='', wrap=True, xalign=0.0, yalign=0.0, vexpand=True)
        sw = Gtk.ScrolledWindow()
        sw.set_child(self._desc)
        sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        self.append_page(sw, Gtk.Label(label='Description'))

        self._deps = Gtk.Label(label='', xalign=0.0, yalign=0.0, vexpand=True)
        sw2 = Gtk.ScrolledWindow()
        sw2.set_child(self._deps)
        sw2.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        self.append_page(sw2, Gtk.Label(label='Dependencies'))

        self._meta = Gtk.Label(label='', xalign=0.0, yalign=0.0, vexpand=True)
        sw3 = Gtk.ScrolledWindow()
        sw3.set_child(self._meta)
        sw3.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        self.append_page(sw3, Gtk.Label(label='Details'))

    def show_pkg(self, p: Pkg, d: dict):
        self._desc.set_text(d.get('description', '') or '(no description)')

        deps = d.get('deps', [])
        opt = d.get('optdeps', [])
        parts = []
        if deps:
            parts.append('Required:\n  ' + '\n  '.join(deps))
        if opt:
            parts.append('\nOptional:\n  ' + '\n  '.join(opt))
        self._deps.set_text('\n'.join(parts) if parts else '(none)')

        lines = [
            f'Name:        {p.name}',
            f'Version:     {p.version}',
        ]
        if p.installed_version:
            lines.append(f'Installed:   {p.installed_version}')
        lines += [
            f'Repository:  {p.repo}',
            f'Manager:     {p.manager}',
        ]
        for k, label in [('size', 'Size'), ('url', 'URL'), ('license', 'License')]:
            if d.get(k):
                lines.append(f'{label}:        {d[k]}')
        if d.get('groups'):
            lines.append(f'Groups:      {", ".join(d["groups"])}')
        self._meta.set_text('\n'.join(lines))

    def clear(self):
        self._desc.set_text('')
        self._deps.set_text('')
        self._meta.set_text('')


# ── Column View factories ──────────────────────────────────────────────────────

def make_status_factory(store):
    factory = Gtk.SignalListItemFactory()
    factory.connect('setup', _status_setup)
    factory.connect('bind', lambda f, li: _status_bind(f, li, store))
    factory.connect('unbind', _status_unbind)
    return factory


def _status_setup(factory, list_item):
    box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2, halign=Gtk.Align.CENTER)
    img = Gtk.Image()
    img.set_margin_start(4)
    box.append(img)
    check = Gtk.CheckButton()
    box.append(check)
    list_item.set_child(box)


def _status_bind(factory, list_item, store):
    pkg = list_item.get_item()
    box = list_item.get_child()
    img = box.get_first_child()
    check = box.get_last_child()

    if pkg.get_upgradable():
        img.set_from_icon_name('software-update-available-symbolic')
        img.set_tooltip_text('Upgradable')
    elif pkg.installed:
        img.set_from_icon_name('object-select-symbolic')
        img.set_tooltip_text('Installed')
    else:
        img.clear()
        img.set_tooltip_text('')

    check.set_active(bool(pkg.mark))

    def on_toggle(cb):
        _check_toggled(cb, pkg, store)

    check._handler = check.connect('toggled', on_toggle)

    # Sync checkbox when pkg.mark changes externally
    def on_mark_changed(obj, pspec):
        if hasattr(check, '_handler'):
            check.handler_block(check._handler)
        check.set_active(bool(pkg.mark))
        if hasattr(check, '_handler'):
            check.handler_unblock(check._handler)

    pkg._mark_handler = pkg.connect('notify::mark', on_mark_changed)


def _status_unbind(factory, list_item):
    box = list_item.get_child()
    if box:
        check = box.get_last_child()
        if hasattr(check, '_handler'):
            try:
                check.disconnect(check._handler)
            except (TypeError, AttributeError):
                pass
    pkg = list_item.get_item()
    if pkg and hasattr(pkg, '_mark_handler'):
        try:
            pkg.disconnect(pkg._mark_handler)
        except (TypeError, AttributeError):
            pass


def _check_toggled(check, pkg, store):
    if check.get_active():
        pkg.mark = 'remove' if pkg.installed else 'install'
    else:
        pkg.mark = ''
    store.marks_changed()


def make_text_factory(get_text, css_class=None):
    factory = Gtk.SignalListItemFactory()
    factory.connect('setup', lambda f, li: _txt_setup(f, li, css_class))
    factory.connect('bind', lambda f, li: _txt_bind(f, li, get_text, css_class))
    return factory


def _txt_setup(factory, list_item, css_class):
    lbl = Gtk.Label(xalign=0.0, margin_start=6, margin_end=6, hexpand=True, ellipsize=Pango.EllipsizeMode.END)
    if css_class:
        lbl.add_css_class(css_class)
    list_item.set_child(lbl)


def _txt_bind(factory, list_item, get_text, css_class):
    pkg = list_item.get_item()
    lbl = list_item.get_child()
    lbl.set_text(get_text(pkg))
    if css_class == 'upgradable-ver':
        if pkg.get_upgradable():
            lbl.add_css_class('accent')
        else:
            lbl.remove_css_class('accent')


def make_desc_factory(store):
    factory = Gtk.SignalListItemFactory()
    factory.connect('setup', _desc_setup)
    factory.connect('bind', lambda f, li: _desc_bind(f, li, store))
    return factory


def _desc_setup(factory, list_item):
    lbl = Gtk.Label(xalign=0.0, margin_start=6, margin_end=6, hexpand=True, ellipsize=Pango.EllipsizeMode.END)
    lbl.add_css_class('dim-label')
    list_item.set_child(lbl)


def _desc_bind(factory, list_item, store):
    pkg = list_item.get_item()
    lbl = list_item.get_child()
    txt = pkg.description
    if len(txt) > 80:
        txt = txt[:80] + '…'
    lbl.set_text(txt)


def make_installed_factory():
    factory = Gtk.SignalListItemFactory()
    factory.connect('setup', _inst_setup)
    factory.connect('bind', _inst_bind)
    return factory


def _inst_setup(factory, list_item):
    lbl = Gtk.Label(xalign=0.0, margin_start=6, margin_end=6, width_chars=14, ellipsize=Pango.EllipsizeMode.END)
    lbl.add_css_class('dim-label')
    list_item.set_child(lbl)


def _inst_bind(factory, list_item):
    pkg = list_item.get_item()
    lbl = list_item.get_child()
    lbl.set_text(pkg.installed_version or '—')


# ── Main Window ────────────────────────────────────────────────────────────────

class MainWindow(Gtk.ApplicationWindow):
    def __init__(self, store: Store, **kwargs):
        super().__init__(**kwargs)
        self.set_title('Onix Package Manager')
        self.set_default_size(1100, 800)
        self.set_icon_name('onix')

        # Ensure icon is registered with the theme
        GLib.idle_add(self._register_onix_icon)

        self._store = store
        self._selected: Pkg | None = None

        self._store.set_on_marks_changed(self._update_status)

        self._build_actions()
        self._build_ui()
        GLib.idle_add(self._register_onix_icon)

    def _register_onix_icon(self):
        try:
            theme = Gtk.IconTheme.get_for_display(self.get_display())
            if not theme.has_icon('onix'):
                subprocess.run(
                    ['gtk-update-icon-cache', '-f', '-t', '/usr/share/icons/hicolor'],
                    capture_output=True, timeout=10)
        except Exception:
            pass

    def _build_actions(self):
        actions = [
            ('reload', self._reload),
            ('apply', self._apply),
            ('mark-upgrades', self._mark_upgrades),
            ('clear-marks', self._clear_marks),
            ('updater', self._launch_updater),
            ('settings', self._show_settings),
            ('about', self._show_about),
            ('quit', lambda *_: self.close()),
        ]
        for name, cb in actions:
            a = Gio.SimpleAction.new(name, None)
            a.connect('activate', cb)
            self.add_action(a)

        theme_action = Gio.SimpleAction.new_stateful(
            'color-scheme',
            GLib.VariantType.new('s'),
            GLib.Variant('s', 'default'),
        )
        theme_action.connect('change-state', self._on_theme_change)
        self.add_action(theme_action)

    def _build_ui(self):
        # ── Header bar with window controls ──
        header = Adw.HeaderBar()
        icon_img = Gtk.Image.new_from_icon_name('onix')
        icon_img.set_pixel_size(24)
        icon_img.set_margin_end(6)
        header.pack_start(icon_img)
        header.set_title_widget(Gtk.Label(label='Onix Package Manager'))
        header.set_decoration_layout('menu:minimize,maximize,close')
        self.set_titlebar(header)

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.set_child(vbox)

        # ── Menu bar ──
        menu = Gio.Menu()

        fm = Gio.Menu()
        fm.append('_Reload', 'win.reload')
        fm.append('_Settings', 'win.settings')
        fm.append('_Updater', 'win.updater')
        fm.append('_Quit', 'quit')
        menu.append_submenu('_File', fm)

        pm = Gio.Menu()
        pm.append('_Clear All Marks', 'win.clear-marks')
        pm.append('Mark All _Upgrades', 'win.mark-upgrades')
        pm.append('_Apply', 'win.apply')
        menu.append_submenu('_Package', pm)

        vm = Gio.Menu()
        vm.append('System Theme', 'win.color-scheme::default')
        vm.append('Light',       'win.color-scheme::force-light')
        vm.append('Dark',        'win.color-scheme::force-dark')
        menu.append_submenu('_View', vm)

        hm = Gio.Menu()
        hm.append('_About', 'win.about')
        menu.append_submenu('_Help', hm)

        vbox.append(Gtk.PopoverMenuBar.new_from_model(menu))

        # ── Toolbar ──
        tb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=3)
        tb.set_margin_top(3)
        tb.set_margin_bottom(3)
        tb.set_margin_start(6)
        tb.set_margin_end(6)

        def tbtn(label, icon, css, cb):
            box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
            if icon:
                img = Gtk.Image.new_from_icon_name(icon)
                box.append(img)
            lbl = Gtk.Label(label=label)
            box.append(lbl)
            b = Gtk.Button()
            b.set_child(box)
            if css:
                b.add_css_class(css)
            b.connect('clicked', lambda *_: cb(None, None))
            tb.append(b)
            return b

        tbtn('Reload', 'view-refresh', None, self._reload)
        tbtn('Mark Upgrades', 'software-update-available-symbolic', None, self._mark_upgrades)
        tbtn('Clear', 'edit-clear-all-symbolic', None, self._clear_marks)

        self._apply_btn = tbtn('Apply', 'emblem-ok-symbolic', 'suggested-action', self._apply)
        tb.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL))
        self._apply_btn.set_sensitive(False)

        tb.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL))

        self._search = Gtk.SearchEntry(hexpand=True, placeholder_text='Search packages…')
        self._search.connect('search-changed', self._on_search)
        tb.append(self._search)

        self._spinner = Gtk.Spinner()
        self._spinner.set_margin_start(6)
        self._spinner.set_margin_end(6)
        self._spinner.set_visible(False)
        tb.append(self._spinner)

        vbox.append(tb)

        # ── Paned (sidebar | main) ──
        paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
        paned.set_position(200)
        paned.set_wide_handle(True)
        vbox.append(paned)

        self._build_sidebar(paned)
        self._build_main(paned)

        # ── Status bar ──
        st = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        st.set_margin_top(2)
        st.set_margin_bottom(2)
        st.set_margin_start(6)
        st.set_margin_end(6)
        st.add_css_class('dim-label')

        self._sleft = Gtk.Label(label='Starting…', halign=Gtk.Align.START, hexpand=True, xalign=0.0)
        st.append(self._sleft)

        self._sright = Gtk.Label(halign=Gtk.Align.END, xalign=1.0)
        st.append(self._sright)
        vbox.append(st)

        # Default category (select "All")
        i = 0
        while True:
            r = self._cats.get_row_at_index(i)
            if r is None:
                break
            if isinstance(r, CatRow) and r.key == 'all':
                self._cats.select_row(r)
                break
            i += 1
        self._update_status()

        # Start loading
        self._spinner.set_visible(True)
        self._spinner.start()
        self._store.load(self._on_loaded)

    def _build_sidebar(self, paned):
        sb = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        sb.set_size_request(180, -1)

        sh = Gtk.Label(label='Sections', margin_top=6, margin_bottom=4, margin_start=8, xalign=0.0)
        sh.add_css_class('heading')
        sb.append(sh)

        self._cats = Gtk.ListBox()
        self._cats.set_selection_mode(Gtk.SelectionMode.SINGLE)
        self._cats.connect('row-selected', self._on_cat)
        for key, label in CATEGORIES:
            self._cats.append(CatRow(key, label))

        sc = Gtk.ScrolledWindow()
        sc.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        sc.set_vexpand(True)
        sc.set_child(self._cats)
        sb.append(sc)
        paned.set_start_child(sb)

    def _build_main(self, paned):
        right = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        right.set_spacing(2)

        # ── ColumnView for package list ──
        # S column
        scol = Gtk.ColumnViewColumn.new('S', make_status_factory(self._store))
        scol.set_fixed_width(60)
        scol.set_resizable(False)

        # Package name column
        ncol = Gtk.ColumnViewColumn.new('Package', make_text_factory(lambda p: p.name, 'heading'))
        ncol.set_expand(True)
        ncol.set_resizable(True)

        # Installed version column
        icol = Gtk.ColumnViewColumn.new('Installed', make_installed_factory())
        icol.set_fixed_width(140)
        icol.set_resizable(True)

        # Latest version column
        lcol = Gtk.ColumnViewColumn.new('Latest', make_text_factory(lambda p: p.version, 'upgradable-ver'))
        lcol.set_fixed_width(140)
        lcol.set_resizable(True)

        # Description column
        dcol = Gtk.ColumnViewColumn.new('Description', make_desc_factory(self._store))
        dcol.set_expand(True)
        dcol.set_resizable(True)

        self._view = Gtk.ColumnView.new(self._store.selection)
        self._view.append_column(scol)
        self._view.append_column(ncol)
        self._view.append_column(icol)
        self._view.append_column(lcol)
        self._view.append_column(dcol)
        self._view.set_vexpand(True)
        self._view.set_hexpand(True)
        self._view.set_show_column_separators(True)
        self._view.set_show_row_separators(True)
        self._view.add_css_class('column-view')

        # Selection change
        self._store.selection.connect('selection-changed', self._on_selection_changed)

        # Right-click context menu
        gesture = Gtk.GestureClick(button=3)
        gesture.connect('pressed', self._on_right_click)
        self._view.add_controller(gesture)

        # Double-click
        dbl = Gtk.GestureClick(button=1)
        dbl.set_button(1)
        dbl.connect('pressed', self._on_double_click)
        self._view.add_controller(dbl)

        sw = Gtk.ScrolledWindow()
        sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        sw.set_vexpand(True)
        sw.set_child(self._view)
        right.append(sw)

        # Details
        self._details = DetailsPanel()
        right.append(self._details)

        paned.set_end_child(right)

    # ── Callbacks ──────────────────────────────────────────────────────────

    def _on_loaded(self, ok: bool, msg: str):
        self._spinner.stop()
        self._spinner.set_visible(False)
        if ok:
            self._update_status()
        else:
            self._sleft.set_text(f'Error: {msg}')

    def _on_cat(self, _lb, row: CatRow | None):
        if row and row.key and not row.key.startswith('sep'):
            self._store.set_category(row.key)
            self._update_status()

    def _on_search(self, _entry):
        self._store.set_search(self._search.get_text())
        self._update_status()

    def _on_selection_changed(self, sel, _pos, _n):
        self._update_status()
        bitset = sel.get_selection()
        if bitset.get_size() > 0:
            pos = bitset.get_nth(0)
            pkg = self._store.sort_model.get_item(pos)
            self._selected = pkg
            self._details.clear()
            self._details.show_pkg(pkg, {'description': 'Loading…'})
            threading.Thread(target=self._det_thread, args=(pkg,), daemon=True).start()
        else:
            self._details.clear()
            self._selected = None

    def _det_thread(self, p: Pkg):
        d = self._store.details(p)
        GLib.idle_add(self._det_done, p, d)

    def _det_done(self, p: Pkg, d: dict):
        if self._selected and self._selected.name == p.name:
            self._details.show_pkg(p, d)
        return False

    def _on_right_click(self, gesture, n_press, x, y):
        menu = Gio.Menu()
        menu.append('Clear Marks', 'win.clear-marks')

        pop = Gtk.PopoverMenu.new_from_model(menu)
        pop.set_parent(self._view)
        rect = Gdk.Rectangle()
        rect.x = int(x)
        rect.y = int(y)
        pop.set_pointing_to(rect)
        pop.popup()

    def _on_double_click(self, gesture, n_press, x, y):
        if n_press == 2:
            bitset = self._store.selection.get_selection()
            if bitset.get_size() > 0:
                pos = bitset.get_nth(0)
                pkg = self._store.sort_model.get_item(pos)
                if pkg.mark:
                    pkg.mark = ''
                else:
                    pkg.mark = 'remove' if pkg.installed else 'install'
                self._update_status()

    # ── Actions ───────────────────────────────────────────────────────────

    def _reload(self, *_):
        self._sleft.set_text('Reloading…')
        self._spinner.set_visible(True)
        self._spinner.start()
        self._store.store.remove_all()
        self._details.clear()
        self._selected = None
        self._store.load(self._on_loaded)

    def _mark_upgrades(self, *_):
        n = self._store.store.get_n_items()
        for i in range(n):
            p = self._store.store.get_item(i)
            if p.get_upgradable():
                p.mark = 'install'
        self._update_status()

    def _clear_marks(self, *_):
        n = self._store.store.get_n_items()
        for i in range(n):
            p = self._store.store.get_item(i)
            p.mark = ''
        self._update_status()

    def _update_status(self):
        inst, rem = self._store.mark_count()
        parts = []
        if inst:
            parts.append(f'{inst} to install')
        if rem:
            parts.append(f'{rem} to remove')
        total = self._store.filter_model.get_n_items()
        all_total = self._store.store.get_n_items()
        label = f'{total} packages'
        if total < all_total:
            label = f'{total} of {all_total} packages'

        sel_count = self._store.selection.get_selection().get_size()
        if sel_count > 0:
            label += f'  |  {sel_count} selected'

        self._sleft.set_text(label)
        self._sright.set_text('  |  '.join(parts))
        self._apply_btn.set_sensitive(bool(inst or rem))
        if inst or rem:
            self._apply_btn.set_label('Apply  ' + ('  |  '.join(parts)))
        else:
            self._apply_btn.set_label('Apply')

    def _show_about(self, *_):
        about = Adw.AboutWindow(
            transient_for=self,
            application_name='Onix Package Manager',
            application_icon='system-software-install',
            developer_name='Onix Team',
            version='1.0',
            comments='A Synaptic-like package manager for Onix Linux',
            license_type=Gtk.License.GPL_3_0,
        )
        about.present()

    def _launch_updater(self, *_):
        import subprocess as _sp
        script = Path(__file__).parent / 'onix_updater.py'
        if script.exists():
            _sp.Popen(['python3', str(script)])
        else:
            self._sleft.set_text('onix_updater.py not found')

    def _rebuild_cats(self):
        global CATEGORIES
        CATEGORIES = build_categories()
        self._cats.remove_all()
        for k, lbl in CATEGORIES:
            self._cats.append(CatRow(k, lbl))
        # Select "All" by default
        first = self._cats.get_row_at_index(0)
        if first:
            self._cats.select_row(first)
            self._store.set_category('all')

    def _show_settings(self, *_):
        prefs = Adw.PreferencesWindow()
        prefs.set_title('Settings')
        prefs.set_transient_for(self)
        prefs.set_modal(True)

        page = Adw.PreferencesPage()
        page.set_title('General')
        group = Adw.PreferencesGroup()
        group.set_title('Package Sources')
        group.set_description('Enable or disable package sources')

        fp_row = Adw.SwitchRow()
        fp_row.set_title('Flatpak Support')
        fp_row.set_subtitle('Show Flatpak packages in the list')
        fp_row.set_active(_settings.get('flatpak_enabled', True))
        fp_row.set_visible(flatpak_available())
        fp_row.connect('notify::active', lambda r, _: self._on_setting_toggle('flatpak_enabled', r.get_active()))
        group.add(fp_row)

        aur_row = Adw.SwitchRow()
        aur_row.set_title('AUR Support')
        aur_row.set_subtitle('Search and install packages from Arch User Repository')
        aur_row.set_active(_settings.get('aur_enabled', False))
        aur_row.set_visible(yay_available())
        aur_row.connect('notify::active', lambda r, _: self._on_setting_toggle('aur_enabled', r.get_active()))
        group.add(aur_row)

        page.add(group)
        prefs.set_content(page)
        prefs.present()

    def _on_setting_toggle(self, key: str, value: bool):
        _settings[key] = value
        save_settings()
        self._rebuild_cats()
        self._store._category = 'all'
        self._store.set_category('all')
        self._store.load(self._store._on_done or (lambda *_: None))

    def _on_theme_change(self, action, value):
        scheme_name = value.get_string()
        scheme_map = {
            'default': Adw.ColorScheme.DEFAULT,
            'force-light': Adw.ColorScheme.FORCE_LIGHT,
            'force-dark': Adw.ColorScheme.FORCE_DARK,
        }
        scheme = scheme_map.get(scheme_name, Adw.ColorScheme.DEFAULT)
        Adw.StyleManager.get_default().set_color_scheme(scheme)
        action.set_state(value)

    def _apply(self, *_):
        marks = []
        installs = []
        removes = []
        n = self._store.store.get_n_items()
        for i in range(n):
            p = self._store.store.get_item(i)
            if p.mark:
                marks.append((p.name, p.mark, p.manager, p.repo))
                if p.mark == 'install':
                    installs.append(p.name)
                else:
                    removes.append(p.name)

        if not marks:
            return

        # Confirmation dialog
        dlg = Gtk.Window()
        dlg.set_title('Confirm Changes')
        dlg.set_default_size(500, 400)
        dlg.set_transient_for(self)
        dlg.set_modal(True)

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

        title_lbl = Gtk.Label(label='Review package changes', xalign=0.0)
        title_lbl.add_css_class('title-3')
        vbox.append(title_lbl)

        info_lbl = Gtk.Label(label='The following changes will be applied:', xalign=0.0)
        info_lbl.set_margin_top(4)
        info_lbl.set_margin_bottom(8)
        vbox.append(info_lbl)

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

        list_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        sw.set_child(list_box)
        vbox.append(sw)

        if installs:
            h = Gtk.Label(label=f'To Install ({len(installs)}):', xalign=0.0)
            h.add_css_class('heading')
            h.set_margin_top(6)
            list_box.append(h)
            for name in installs:
                lbl = Gtk.Label(label=f'  + {name}', xalign=0.0)
                lbl.set_margin_start(12)
                list_box.append(lbl)

        if removes:
            h = Gtk.Label(label=f'To Remove ({len(removes)}):', xalign=0.0)
            h.add_css_class('heading')
            h.set_margin_top(6)
            list_box.append(h)
            for name in removes:
                lbl = Gtk.Label(label=f'  - {name}', xalign=0.0)
                lbl.set_margin_start(12)
                list_box.append(lbl)

        btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        btn_box.set_halign(Gtk.Align.END)
        btn_box.set_margin_top(12)

        cancel_btn = Gtk.Button(label='Cancel')
        cancel_btn.connect('clicked', lambda *_: dlg.close())
        btn_box.append(cancel_btn)

        confirm_btn = Gtk.Button(label='Apply')
        confirm_btn.add_css_class('suggested-action')
        confirm_btn.connect('clicked', lambda *_: (dlg.close(), self._do_apply(marks)))
        btn_box.append(confirm_btn)

        vbox.append(btn_box)
        dlg.present()

    def _do_apply(self, marks):
        self._apply_btn.set_sensitive(False)
        self._apply_btn.set_label('Running…')

        win = Gtk.Window()
        win.set_title('Applying Changes')
        win.set_default_size(700, 500)
        win.set_transient_for(self)
        win.set_modal(True)

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

        title_lbl = Gtk.Label(label='Applying package changes…', xalign=0.0)
        title_lbl.add_css_class('title-3')
        vbox.append(title_lbl)

        status_lbl = Gtk.Label(label='', xalign=0.0)
        vbox.append(status_lbl)

        scrolled = Gtk.ScrolledWindow()
        scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        scrolled.set_vexpand(True)

        terminal = Gtk.TextView()
        terminal.set_editable(False)
        terminal.set_monospace(True)
        terminal.set_wrap_mode(Gtk.WrapMode.WORD)
        terminal.set_vexpand(True)
        scrolled.set_child(terminal)
        vbox.append(scrolled)

        progress = Gtk.ProgressBar()
        progress.set_fraction(0.0)
        vbox.append(progress)

        btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        btn_box.set_halign(Gtk.Align.CENTER)

        cancel_btn = Gtk.Button(label='Cancel')
        cancel_btn.add_css_class('destructive-action')
        btn_box.append(cancel_btn)

        auto_check = Gtk.CheckButton(label='Close automatically when done', active=True)
        btn_box.append(auto_check)

        vbox.append(btn_box)

        win.present()

        cancelled = [False]
        done = [False]
        total = len(marks)

        def append_output(text):
            buf = terminal.get_buffer()
            end = buf.get_end_iter()
            buf.insert(end, text + '\n')

        def run():
            for i, (name, action, mgr, repo) in enumerate(marks):
                if cancelled[0]:
                    GLib.idle_add(append_output, 'Cancelled.')
                    break

                verb = 'Installing' if action == 'install' else 'Removing'
                GLib.idle_add(status_lbl.set_text, f'{verb} {name}…')
                GLib.idle_add(progress.set_fraction, i / total if total > 0 else 0.0)

                if mgr == 'flatpak':
                    fp_action = 'uninstall' if action == 'remove' else 'install'
                    cmd = ['flatpak', fp_action, '-y', '--noninteractive', repo, name]
                elif mgr == 'aur':
                    yay_cmd = 'yay'
                    if action == 'remove':
                        cmd = ['pacman', '-Rns', '--noconfirm', name]
                    else:
                        cmd = [yay_cmd, '-S', '--noconfirm', name]
                elif action == 'install':
                    cmd = ['pacman', '-S', '--noconfirm', '--needed', name]
                else:
                    cmd = ['pacman', '-Rns', '--noconfirm', name]

                try:
                    proc = subprocess.Popen(
                        cmd,
                        stdout=subprocess.PIPE,
                        stderr=subprocess.STDOUT,
                        text=True,
                        bufsize=1,
                    )
                    output_lines: list[str] = []
                    while True:
                        line = proc.stdout.readline()
                        if not line and proc.poll() is not None:
                            break
                        if line:
                            line_stripped = line.rstrip('\n')
                            output_lines.append(line_stripped)
                            GLib.idle_add(append_output, line_stripped)
                    combined = '\n'.join(output_lines)
                    if 'up to date' in combined:
                        GLib.idle_add(append_output, f'→ Already up to date: {name}')
                    elif 'is not installed' in combined.lower():
                        GLib.idle_add(append_output, f'→ Not installed: {name}')
                    else:
                        GLib.idle_add(append_output, f'→ {verb[:-3]}ed {name}')
                except Exception as e:
                    GLib.idle_add(append_output, f'Error: {e}')

            def _done():
                done[0] = True
            GLib.idle_add(_done)
            GLib.idle_add(progress.set_fraction, 1.0)
            GLib.idle_add(status_lbl.set_text, 'Done')
            GLib.idle_add(cancel_btn.set_label, 'Close')
            GLib.idle_add(cancel_btn.set_sensitive, True)
            GLib.idle_add(cancel_btn.remove_css_class, 'destructive-action')
            GLib.idle_add(self._apply_done)
            GLib.idle_add(lambda: auto_check.get_active() and win.close())

        def on_cancel(*_):
            if done[0]:
                win.close()
            else:
                cancelled[0] = True
                cancel_btn.set_sensitive(False)
                append_output('Cancelling…')

        win.connect('close-request', lambda *_: not done[0] or None)
        cancel_btn.connect('clicked', on_cancel)
        threading.Thread(target=run, daemon=True).start()

    def _apply_done(self):
        self._apply_btn.set_sensitive(True)
        self._apply_btn.set_label('Apply')
        self._sleft.set_text('Done – reloading…')
        self._clear_marks(None, None)
        self._reload(None, None)


# ── App ────────────────────────────────────────────────────────────────────────

class App(Adw.Application):
    def __init__(self):
        super().__init__(application_id=APP_ID)
        self.connect('activate', self._on)
        self._store = Store()

    def _on(self, app):
        w = MainWindow(store=self._store, application=app)
        w.present()


if __name__ == '__main__':
    import sys
    try:
        a = App()
        sys.exit(a.run(sys.argv))
    except Exception as e:
        print(f'Error: {e}')
        sys.exit(1)
