#!/usr/bin/env python3
"""
Hybrid APT/PackageKit handler.

- PackageKit for: simple install/remove, cache refresh
- APT (via pkexec) for: updates, backports, third-party repos, system upgrades

Reference:
- https://lazka.github.io/pgi-docs/PackageKitGlib-1.0

Author: Arjen Balfoort
Date: 2026-04-18
"""

import os
import glob
import threading
import subprocess
import gettext
import queue
import re
import shutil

from pathlib import Path
from typing import Optional
from configparser import ConfigParser

import psutil
import apt

from logging_config import logger
from app_shared import (
    AppStreamPackageInfo,
    PackageType,
    PackageAction,
    LogLevel,
    human_readable_size
)

import gi
gi.require_version("PackageKitGlib", "1.0")
from gi.repository import PackageKitGlib, GLib, Gio  #pylint: disable=wrong-import-position


_ = gettext.translation("deb-software", fallback=True).gettext


# =========================================
# PackageKit status/error maps
# =========================================

STATUS_MAP: dict[PackageKitGlib.StatusEnum, str] = {
    PackageKitGlib.StatusEnum.UNKNOWN: '',
    PackageKitGlib.StatusEnum.WAIT: _("Waiting"),
    PackageKitGlib.StatusEnum.UPDATE: _("Updating"),
    PackageKitGlib.StatusEnum.CLEANUP: _("Cleaning up"),
    PackageKitGlib.StatusEnum.DEP_RESOLVE: _("Resolving dependencies"),
    PackageKitGlib.StatusEnum.SIG_CHECK: _("Checking signature"),
    PackageKitGlib.StatusEnum.TEST_COMMIT: _("Testing commit"),
    PackageKitGlib.StatusEnum.COMMIT: _("Committing changes"),
    PackageKitGlib.StatusEnum.FINISHED: _("Finished"),
    PackageKitGlib.StatusEnum.CANCEL: _("Canceled"),
    PackageKitGlib.StatusEnum.SETUP: _("Preparing"),
    PackageKitGlib.StatusEnum.DOWNLOAD_REPOSITORY: _("Downloading repository information"),
    PackageKitGlib.StatusEnum.DOWNLOAD_PACKAGELIST: _("Downloading package list"),
    PackageKitGlib.StatusEnum.DOWNLOAD_FILELIST: _("Downloading file list"),
    PackageKitGlib.StatusEnum.DOWNLOAD_CHANGELOG: _("Downloading changelog"),
    PackageKitGlib.StatusEnum.DOWNLOAD_GROUP: _("Downloading group information"),
    PackageKitGlib.StatusEnum.DOWNLOAD_UPDATEINFO: _("Downloading update information"),
    PackageKitGlib.StatusEnum.REPACKAGING: _("Repackaging files"),
    PackageKitGlib.StatusEnum.LOADING_CACHE: _("Loading cache"),
    PackageKitGlib.StatusEnum.SCAN_APPLICATIONS: _("Scanning applications"),
    PackageKitGlib.StatusEnum.GENERATE_PACKAGE_LIST: _("Generating package list"),
    PackageKitGlib.StatusEnum.RUNNING: _("Running"),
    PackageKitGlib.StatusEnum.WAITING_FOR_LOCK: _("Waiting for package manager lock"),
    PackageKitGlib.StatusEnum.WAITING_FOR_AUTH: _("Waiting for authorization"),
    PackageKitGlib.StatusEnum.SCAN_PROCESS_LIST: _("Scanning process list"),
    PackageKitGlib.StatusEnum.CHECK_EXECUTABLE_FILES: _("Checking executable files"),
    PackageKitGlib.StatusEnum.CHECK_LIBRARIES: _("Checking libraries"),
    PackageKitGlib.StatusEnum.COPY_FILES: _("Copying files"),
    PackageKitGlib.StatusEnum.RUN_HOOK: _("Running package hook"),
    PackageKitGlib.StatusEnum.QUERY: _("Querying"),
    PackageKitGlib.StatusEnum.INFO: _("Retrieving information"),
    PackageKitGlib.StatusEnum.REMOVE: _("Removing"),
    PackageKitGlib.StatusEnum.REFRESH_CACHE: _("Updating cache"),
    PackageKitGlib.StatusEnum.DOWNLOAD: _("Downloading packages"),
    PackageKitGlib.StatusEnum.INSTALL: _("Installing"),
}

ERROR_MAP: dict[PackageKitGlib.ErrorEnum, str] = {
    PackageKitGlib.ErrorEnum.NO_NETWORK: _("No network connection."),
    PackageKitGlib.ErrorEnum.NOT_AUTHORIZED: _("Authorization failed."),
    PackageKitGlib.ErrorEnum.NO_CACHE: _("The package cache is not available."),
    PackageKitGlib.ErrorEnum.INTERNAL_ERROR: _("An internal error occurred."),
    PackageKitGlib.ErrorEnum.NOT_SUPPORTED: _("This operation is not supported."),
}


# =========================================
# APT high-level status/error mapping
# =========================================

def map_apt_status(line: str) -> str | None:
    """
    Map raw apt-get output lines to high-level, user-friendly messages.
    """
    l = line.lower()

    if "reading package lists" in l:
        return _("Reading package lists")
    if "building dependency tree" in l:
        return _("Resolving dependencies")
    if "reading state information" in l:
        return _("Checking installed packages")
    if "downloading" in l or "get:" in l or 'fetched' in l:
        return _("Downloading packages")
    if "preparing to unpack" in l:
        return _("Preparing to unpack packages")
    if "unpacking" in l:
        return _("Unpacking packages")
    if "setting up" in l:
        return _("Configuring packages")
    if "processing triggers" in l:
        return _("Processing triggers")
    if "done" == l.strip():
        return _("Finishing")

    return None


def map_apt_error(line: str) -> str | None:
    """
    Map apt-get error lines to high-level error messages.
    """
    l = line.lower()

    if "e:" in l and "unmet dependencies" in l:
        return _("Unmet dependencies prevent this operation.")
    if "e:" in l and "could not get lock" in l:
        return _("Another package manager is running.")
    if "e:" in l and "not found" in l:
        return _("One or more packages were not found.")
    if "e:" in l and "permission denied" in l:
        return _("Permission denied while running APT.")
    if "e:" in l:
        return _("APT reported an error.")

    return None

class AptInfo:
    """
    APT info handler.
    """
    def __init__(self, icon_theme=None, default_icon=None):
        self.installed_apt_packages = self.get_installed_apt_packages()
        self.icon_theme = icon_theme
        self.default_icon = default_icon

    def refresh(self):
        """
        Refresh APT info.
        """
        self.installed_apt_packages = self.get_installed_apt_packages()

    def get_apt_updates(self, installed_flatpak_packages):
        """
        Get updates from APT.
        """
        try:
            result = subprocess.run(
                ["apt", "list", "--upgradable"],
                capture_output=True, text=True, check=True
            )
            upgradable = []
            for line in result.stdout.splitlines():
                if '/' not in line:
                    continue
                parts = line.split()
                if len(parts) < 3:
                    continue
                # Extract package name (before /) and architecture (3rd column)
                pkg_name = parts[0].split('/')[0]
                arch = parts[2]
                # Only include if:
                # 1. Package is installed via Debian
                # 2. Architecture matches installed version
                # 3. Not installed via Flatpak (prevent duplicates)
                if (pkg_name in self.installed_apt_packages and
                    self.installed_apt_packages.get(pkg_name) in (arch, "all") and
                    pkg_name not in installed_flatpak_packages):
                    upgradable.append(pkg_name)
            return upgradable

        except subprocess.CalledProcessError as e:
            logger.error("Error (apt): %s", e.stderr.strip())
            return []
        except Exception as e: # pylint: disable=broad-except
            logger.error("Error (apt): %s", e)
            return []

    @staticmethod
    def get_installed_apt_packages() -> dict[str, str]:
        """ Returns a list of installed Debian package names. """
        pkg_names = {}
        result = subprocess.run(
            ["dpkg-query", "--show", "--showformat='${Package} ${Architecture}'\n"],
            capture_output=True,
            text=True,
            check=True
        )
        for line in result.stdout.splitlines():
            pkg, arch = line.replace("'", "").split()
            pkg_names[pkg] = arch

        return pkg_names

    def get_apt_package_details(self,
                                pkgname: str,
                                apt_cache: apt.Cache) -> Optional[AppStreamPackageInfo]:
        """
        Get version, section, installed size and additional packages from apt.
        
        Args:
            pkgname (str): The package name to get info for.
            minimal (bool): Only get id, name, summary, origin and icon.
        
        Returns:
            AppStreamPackageInfo object.
        """
        if not pkgname:
            return {}

        # Only get the package name
        pkgname = pkgname.split(':', 1)[0]
        info = AppStreamPackageInfo(package_id=pkgname,
                                    screenshots=[],
                                    categories=[])

        # Use apt-cache policy to get installed and candidate versions
        # apt.Cache might contain stale information after APT operations
        info.version, info.candidate_version = self.get_policy_versions(pkgname)
        is_updatable = info.version != info.candidate_version

        try:
            apt_pkg = apt_cache[pkgname]
            apt_pkg_installed = apt_pkg.installed
            apt_pkg_candidate = apt_pkg.candidate
            if apt_pkg_installed is None:
                # Not installed
                apt_pkg_installed = apt_pkg_candidate

            info.package_name = pkgname
            info.name = pkgname.capitalize()
            info.package_type = PackageType.from_apt_package(apt_pkg)
            info.summary = apt_pkg_installed.summary
            info.description = apt_pkg_installed.description
            if apt_pkg_candidate.origins:
                # Use info of the candidate.
                # Installed will be empty if this an update
                info.origin = f"{apt_pkg_candidate.origins[0].site.split('.')[-2]}-" \
                              f"{apt_pkg_candidate.origins[0].codename}-" \
                              f"{apt_pkg_candidate.origins[0].component}"
            info.homepage = apt_pkg_installed.homepage
            if self.icon_theme is not None:
                if self.icon_theme.has_icon(pkgname):
                    info.icon = pkgname
                elif self.default_icon is not None:
                    info.icon = self.default_icon.get_default_icon(None)
            info.section = apt_pkg_installed.section
            info.is_installed = apt_pkg_installed.is_installed
            info.is_updatable = is_updatable
            info.installed_size = human_readable_size(apt_pkg_installed.installed_size)
            info.architecture = apt_pkg_installed.architecture
            additional_packages = []
            for dep in apt_pkg_installed.dependencies + apt_pkg_installed.recommends:
                try:
                    if not apt_cache[dep[0].name].is_installed:
                        additional_packages.append(dep[0].name)
                except KeyError:
                    pass
            info.additional_packages = additional_packages
        except Exception as e: # pylint: disable=broad-except
            logger.error("Error getting apt info for %s: %s", pkgname, e)
            return None

        try:
            if info.is_installed:
                # Find and parse .desktop files
                arch = self.installed_apt_packages.get(pkgname)
                full_pkgname = f"{pkgname}:{arch}"
                result = subprocess.run(['dpkg', '--listfiles', full_pkgname],
                                        capture_output=True,
                                        text=True,
                                        check=False,
                                        env={"LANG": "C", "LC_ALL": "C"})
                if result.returncode != 0:
                    logger.error(
                        "Error getting desktop info for %s: %s", full_pkgname, result.stderr
                    )
                    return info

                desktop_files = [f for f in result.stdout.splitlines() if f.endswith('.desktop')]
                for desktop_file in desktop_files:
                    if not desktop_file.startswith("/usr/share/applications"):
                        continue

                    if os.path.exists(desktop_file):
                        # Parse .desktop file
                        # Use interpolation=None to disable interpolation (%u in Exec, etc.)
                        parser = ConfigParser(interpolation=None)
                        parser.optionxform = str  # Preserve key case
                        parser.read(desktop_file)
                        if 'Desktop Entry' not in parser:
                            continue

                        desktop_data = dict(parser['Desktop Entry'])
                        exec_line = desktop_data.get('Exec', '')
                        if not exec_line:
                            continue

                        binary = exec_line.split()[0]
                        for bin_dir in ['/usr/bin', '/usr/sbin', '/usr/games']:
                            bin_path = os.path.join(bin_dir, binary)
                            if os.path.exists(bin_path):
                                info.executable = binary
                                info.name = desktop_data.get('Name', '')
                                #info.summary = desktop_data.get('Comment', '')
                                info.icon = desktop_data.get('Icon', '')
                                categories = desktop_data.get('Categories', '').split(';')
                                info.categories = [cat for cat in categories if cat]
                                break

                        # Find the first executable
                        if info.executable:
                            break

        except subprocess.CalledProcessError as e:
            logger.error("Error getting desktop info for %s: %s", pkgname, e.stderr.strip())
            return None
        except Exception as e: # pylint: disable=broad-except
            logger.error("Error getting desktop info for %s: %s", pkgname, e)
            return None

        return info

    @staticmethod
    def get_executable(package_name: str) -> Optional[str]:
        """
        Get the path to the executable for a package.

        Args:
            package_name (str): The name of the package.

        Returns:
            str: The path to the executable, or None if not found.
        """
        try:
            # Step 1: Find the .desktop file path using dpkg
            dpkg_output = subprocess.run(
                ["dpkg", "-L", package_name],
                capture_output=True,
                text=True,
                check=True
            ).stdout
            desktop_files = [
                Path(line.strip())
                for line in dpkg_output.splitlines()
                if line.strip().endswith('.desktop')
            ]
            if not desktop_files:
                return None

            # Step 2: Read the Exec line from the .desktop file
            desktop_file = desktop_files[0]
            with desktop_file.open('r', encoding='utf-8') as f:
                for line in f:
                    if line.startswith('Exec='):
                        exec_line = line.split('=', 1)[1].strip()
                        executable = exec_line.split()[0]
                        break
                else:
                    return None

            # Step 3: Find the executable in PATH
            exe_path = shutil.which(executable)
            if not exe_path:
                print(f"Error: Executable '{executable}' not found in PATH.")
                return None
            return exe_path

        except (subprocess.CalledProcessError, FileNotFoundError, OSError) as e:
            print(f"Error: {e.stderr.strip()}")
            return None
        except Exception as e: # pylint: disable=broad-except
            print(f"Error: {e}")
            return None

    def get_policy_versions(self, pkgname: str) -> tuple[str | None, str | None]:
        """
        Returns (installed_version, candidate_version) using apt-cache policy.
        Always up-to-date immediately after APT operations.
        """
        try:
            output = subprocess.check_output(
                ["apt-cache", "policy", pkgname],
                text=True,
                stderr=subprocess.DEVNULL,
                env={"LANG": "C", "LC_ALL": "C"},
            )
        except Exception: # pylint: disable=broad-except
            return None, None

        installed = ''
        candidate = ''

        for line in output.splitlines():
            line = line.strip().lower()
            if not line:
                continue
            if installed and candidate:
                break
            if line.startswith("installed:"):
                installed = line.split(":", 1)[1].strip()
            elif line.startswith("candidate:"):
                candidate = line.split(":", 1)[1].strip()

        # Remove 'none'
        installed = '' if 'none' in installed else installed

        return installed, candidate


class BaseHandler(threading.Thread):
    """
    Base class with shared queue/logging helpers.
    """

    def __init__(self,
                 script_dir: str,
                 action: PackageAction,
                 packages: list[AppStreamPackageInfo] | None = None,
                 result_queue: queue.Queue | None = None,
                 on_done=None) -> None:
        super().__init__(daemon=True)
        self.script_dir: str = script_dir
        self.result_queue: queue.Queue = result_queue
        self.packages: list[AppStreamPackageInfo] = packages or []
        self.action: PackageAction = action
        self.on_done: callable = on_done
        self._process: subprocess.Popen[str] | None = None
        special_scripts = glob.glob(
            os.path.join(self.script_dir, 'scripts', self.action.value, '*.sh')
        )
        self.specials: dict[str, str] = {}
        for script in special_scripts:
            self.specials[Path(script).stem] = script

    def queue_message(self,
                      level: LogLevel,
                      message: str,
                      percentage: int) -> None:
        """
        Add a message to the queue with its level (info/error).
        """
        if self.result_queue is not None:
            info_item = {
                'level': level,
                'message': message,
                'progress': percentage
            }
            self.result_queue.put(info_item)

        if logger is not None and message:
            message = message.replace('\n', ' ')
            if level in (LogLevel.INFO, LogLevel.SUCCESS, LogLevel.PROGRESS):
                logger.info(message)
            elif level in (LogLevel.WARNING, LogLevel.CANCELED):
                logger.warning(message)
            elif level == LogLevel.ERROR:
                logger.error(message)
            elif level == LogLevel.DEBUG:
                logger.debug(message)

    @staticmethod
    def _extract_percentage(line: str) -> int:
        match = re.search(r"(\d+)%", line)
        return int(match.group(1)) if match else 0

    @staticmethod
    def _extract_num_pkgs(line: str) -> tuple[bool, int]:
        upgraded = re.search(r"(\d+)\s+upgraded", line)
        newly = re.search(r"(\d+)\s+newly\s+installed", line)

        if upgraded or newly:
            up = int(upgraded.group(1)) if upgraded else 0
            new = int(newly.group(1)) if newly else 0
            return True, max(1, up + new)

        return False, 1

    @staticmethod
    def _apt_is_locked() -> bool:
        """Check if dpkg is locked."""
        filepath = "/var/lib/dpkg/lock"
        for proc in psutil.process_iter(['pid', 'name', 'open_files']):
            try:
                for file in proc.info['open_files'] or []:
                    if filepath in file.path:
                        return True
            except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
                continue
        return False

    # ---- APT subprocess runner -----------------------------------------

    def _run_apt_command(self, command: list[str]) -> None:
        if self._apt_is_locked():
            self.queue_message(
                LogLevel.ERROR,
                _("APT is currently busy.\nPlease wait for the ongoing operation to finish."),
                0
            )
            return
        logger.debug("Running APT command: %s", ' '.join(command))

        # Check if this is a flatpak install
        flatpak_install = any("flatpak" in l for l in command)

        with subprocess.Popen(
            command,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            bufsize=1,
        ) as process:
            self._process = process

            num_pkgs = 1
            cur_pkg = 1
            num_pkgs_done = False
            last_global_pct = 0
            seen_get_lines = 0

            try:
                for line in process.stdout:
                    line = line.rstrip()
                    if not line:
                        continue

                    if not num_pkgs_done:
                        num_pkgs_done, num_pkgs = self._extract_num_pkgs(line)

                    # Download progress
                    if "Get:" in line:
                        seen_get_lines += 1
                        if num_pkgs > 0:
                            pct = int((seen_get_lines / num_pkgs) * 100)
                            status_msg = map_apt_status(line)
                            if status_msg:
                                self.queue_message(LogLevel.PROGRESS, status_msg, pct)
                            else:
                                self.queue_message(LogLevel.DEBUG, line, pct)
                        continue

                    # Upacking progress
                    if "Preparing to unpack" in line:
                        cur_pkg += 1

                    extracted_pct = self._extract_percentage(line)
                    global_pct = int(
                        ((cur_pkg - 1) + extracted_pct / 100.0) / num_pkgs * 100
                    )
                    global_pct = min(max(global_pct, last_global_pct), 100)
                    last_global_pct = global_pct

                    status_msg = map_apt_status(line)
                    if status_msg:
                        self.queue_message(LogLevel.PROGRESS, status_msg, global_pct)
                    else:
                        self.queue_message(LogLevel.DEBUG, line, global_pct)

                try:
                    returncode = process.wait(timeout=3600)
                except subprocess.TimeoutExpired:
                    logger.error("APT command timed out")
                    self.queue_message(
                        LogLevel.ERROR,
                        _("APT operation timed out."),
                        last_global_pct,
                    )
                    return

                # Only show error messages if the return code is not 127 (user cancel)
                if returncode != 127:
                    for line in process.stderr:
                        line = line.rstrip()
                        if not line:
                            continue

                        err_msg = map_apt_error(line)
                        if err_msg:
                            self.queue_message(LogLevel.ERROR, err_msg, 0)
                            logger.error("APT error: %s", line)
                        else:
                            self.queue_message(
                                LogLevel.DEBUG, f"apt stderr: {line}", 0
                            )
            finally:
                self._process = None

        if returncode != 0:
            # user cancel
            if returncode not in (126, 127):
                self.queue_message(
                    LogLevel.ERROR,
                    _("APT failed with exit code %s") % returncode,
                    last_global_pct,
                )
            else:
                self.queue_message(
                    LogLevel.CANCELED,
                    _("Canceled by user"),
                    last_global_pct,
                )
        else:
            # Update AppStream when flatpak was installed
            if flatpak_install:
                command = ['flatpak', 'update', '--appstream']
                logger.debug("Running command: %s", ' '.join(command))
                self.queue_message(LogLevel.INFO,
                                _("Updating AppStream"), last_global_pct)
                subprocess.run(command, check=True)

            self.queue_message(
                LogLevel.SUCCESS,
                _("APT operation completed."),
                100,
            )

# =========================================
# PackageKit handler
# =========================================

class PackageKitHandler(BaseHandler):
    """
    Uses PackageKit for:
    - install
    - remove
    - refresh cache
    """

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self.client: PackageKitGlib.Client = PackageKitGlib.Client()
        self.package_ids: list[str] = []
        if self.action != PackageAction.REFRESH_CACHE:
            self.package_ids: list[str] = self.search_package_ids()

    def run(self) -> None:
        try:
            if self.action == PackageAction.INSTALL:
                # Special cases need scripts
                self._check_for_specials()

                # Normal packages
                if self.package_ids:
                    self._install_packages()

            elif self.action == PackageAction.REMOVE:
                # Special cases need scripts
                self._check_for_specials()

                # Normal packages
                if self.package_ids:
                    self._remove_packages()
            elif self.action == PackageAction.REFRESH_CACHE:
                self._refresh_cache()
            else:
                self.queue_message(LogLevel.ERROR,
                    _("Unsupported PackageKit action") + f": {self.action.value}", 0)
        finally:
            if self.on_done:
                self.on_done()

    def search_package_ids(self) -> list[str]:
        """ Searches for package ids. """
        ids: list[str] = []
        for pkg in self.packages:
            try:
                results = self.client.search_names(
                    PackageKitGlib.FilterEnum.NONE,
                    [pkg.package_name],
                    None,
                    self._progress_callback,
                    None,
                )
            except GLib.Error as e:
                self.queue_message(LogLevel.ERROR, str(e), 0)
                continue

            for p in results.get_package_array():
                if p.get_name() == pkg.package_name:
                    # Flatpak is a special case
                    if pkg.package_name not in self.specials:
                        ids.append(p.get_id())
                    break

        return ids

    def _check_for_specials(self) -> None:
        """
        Some packages need some special handling.
        Bash scripts are located in /usr/lib/deb-software/scripts

        Args:
            scripts_paths (list[str]): List of script paths to run
        """
        if self.action not in (PackageAction.INSTALL, PackageAction.REMOVE):
            return

        for package_name, script_path in self.specials.items():
            if package_name not in {pkg.package_name for pkg in self.packages}:
                continue

            command = [
                'pkexec',
                '--disable-internal-agent',
                '/usr/lib/deb-software/scripts/_run_script.sh',
                script_path
            ]

            self._run_apt_command(command)

    def _new_cancellable(self):
        return Gio.Cancellable()

    def _progress_callback(
        self,
        progress: PackageKitGlib.Progress,
        progress_type: PackageKitGlib.ProgressType,
        _user_data=None,
    ) -> None:
        if progress_type == PackageKitGlib.ProgressType.PERCENTAGE:
            pct = max(0, progress.get_percentage())
            status = progress.get_status()
            message = STATUS_MAP.get(status, "")
            self.queue_message(LogLevel.PROGRESS, message, pct)

    def _install_packages(self) -> None:
        install_pkgs = [pkg.package_name for pkg in self.packages]
        if not install_pkgs:
            self.queue_message(LogLevel.ERROR, _("No packages to install."), 0)
            return

        msg = self._status_message() + " " + ', '.join(install_pkgs)
        self.queue_message(LogLevel.PROGRESS, msg, 0)

        command = [
            "pkexec",
            "--disable-internal-agent",
            f"{self.script_dir}/scripts/_apt_install.sh"
        ] + install_pkgs

        try:
            self._run_apt_command(command)
        except GLib.Error as e:
            self._store_error(e)

    def _remove_packages(self) -> None:
        try:
            msg = self._status_message() + " " + \
                ', '.join([pkg.package_name for pkg in self.packages])
            self.queue_message(LogLevel.PROGRESS, msg, 0)
            results = self.client.remove_packages(
                PackageKitGlib.TransactionFlagEnum.NONE,
                self.package_ids,
                True, # remove dependencies
                True, # autoremove
                self._new_cancellable(),
                self._progress_callback,
                None,
            )
            self._store_results(results)
        except GLib.Error as e:
            self._store_error(e)

    def _refresh_cache(self) -> None:
        try:
            self.queue_message(LogLevel.PROGRESS, self._status_message(), 0)
            results = self.client.refresh_cache(
                True, # force: aggresively drop caches
                self._new_cancellable(),
                self._progress_callback,
                None,
            )
            self._store_results(results)
        except GLib.Error as e:
            self._store_error(e)

    def _store_results(self, results) -> None:
        exit_status = results.get_exit_code()
        error = results.get_error_code()

        if exit_status == PackageKitGlib.ExitEnum.SUCCESS:
            pkgs = ', '.join([pkg.package_name for pkg in self.packages])
            msg = _("Finished") + f" {self._status_message().lower()} {pkgs}"
            self.queue_message(LogLevel.SUCCESS,
                               msg,
                               100)
        elif error is not None:
            error_code = error.get_code()
            error_details = error.get_details()
            msg = ERROR_MAP.get(error_code, error_details)
            self.queue_message(LogLevel.ERROR, msg, 0)

    def _store_error(self, error: GLib.Error) -> None:
        # Skip user not providing sudo password
        if error.code == 303:
            self.queue_message(LogLevel.CANCELED, _("Canceled by user"), 0)
            return
        # Try to map PackageKit error to friendly text
        try:
            msg = ERROR_MAP.get(error.code, str(error))
        except KeyError:
            msg = str(error)
        self.queue_message(LogLevel.ERROR, msg, 0)

    def _status_message(self) -> str:
        if self.action == PackageAction.INSTALL:
            return _("Installing")
        if self.action == PackageAction.REMOVE:
            return _("Removing")
        if self.action == PackageAction.UPDATE:
            return _("Updating")
        if self.action == PackageAction.REFRESH_CACHE:
            return _("Updating cache")
        return _("Working")


# =========================================
# APT subprocess handler (via pkexec)
# =========================================

class AptSubprocessHandler(BaseHandler):
    """
    Uses apt-get via pkexec for:
    - updating specific packages
    - full system upgrades
    - backports / third-party repos
    """

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self.apt_cache: apt.Cache = apt.Cache()

    def run(self) -> None:
        try:
            if self.action in (PackageAction.UPDATE, PackageAction.INSTALL):
                self._apt_update_packages()
            elif self.action == PackageAction.SYSTEM_UPGRADE:
                self._apt_dist_upgrade()
            else:
                self.queue_message(LogLevel.ERROR, _("Unsupported APT action."), 0)
        finally:
            if self.on_done:
                self.on_done()

    def cancel(self) -> None:
        """
        Cancels the APT operation.
        Hook cancel() to a “Cancel” button.

        Currently not used: PackageKit and Flatpak not implemented, yet
        """
        proc = self._process
        if proc and proc.poll() is None:
            logger.info("Cancelling APT operation...")
            try:
                proc.terminate()
            except Exception: # pylint: disable=broad-except
                pass
            try:
                proc.kill()
            except Exception: # pylint: disable=broad-except
                pass

    # ---- APT operations ------------------------------------------------
    def _apt_update_packages(self) -> None:
        install_pkgs = []
        for pkg in self.packages:
            if pkg.download_path:
                # This is a downloaded package
                install_pkgs.append(pkg.download_path)
            else:
                install_pkgs.append(pkg.package_name)

        if not install_pkgs:
            self.queue_message(LogLevel.ERROR, _("No packages to update."), 0)
            return

        # pkexec will prompt for root password via polkit
        command = ["pkexec",
                   "--disable-internal-agent",
                   f"{self.script_dir}/scripts/_apt_install.sh"] + install_pkgs
        self.queue_message(LogLevel.PROGRESS, _("Updating selected packages"), 0)
        self._run_apt_command(command)

    def _apt_dist_upgrade(self) -> None:
        # Check if we have downloaded packages
        downloaded_pkgs = [pkg.download_path for pkg in self.packages if pkg.download_path]
        if downloaded_pkgs:
            self._apt_update_packages()
            return

        # pkexec will prompt for root password via polkit
        command = ["pkexec",
                "--disable-internal-agent",
                f"{self.script_dir}/scripts/_dist_upgrade.sh"]
        self.queue_message(LogLevel.PROGRESS, _("Upgrading system"), 0)
        self._run_apt_command(command)

# =========================================
# Hybrid handler - start here
# =========================================

class AptPackageHandler(BaseHandler):
    """
    Hybrid handler that chooses between PackageKit and APT.

    - PackageKit:
        * INSTALL
        * REMOVE
        * REFRESH_CACHE

    - APT (via pkexec):
        * UPDATE
        * SYSTEM_UPGRADE
    """

    def run(self) -> None:
        try:
            if hasattr(self, "special_command"):
                self._run_apt_command(self.special_command)
                return

            if self._should_use_packagekit():
                self._run_packagekit()
            else:
                self._run_apt()
        finally:
            if self.on_done:
                self.on_done()

    def _should_use_packagekit(self) -> bool:
        """
        Decide which backend to use.

        - Simple actions > PackageKit
        - Updates/system upgrades > APT
        """
        use_packagekit = False

        # If we don't have downloaded packages: check action
        downloaded_pkgs = [pkg.download_path for pkg in self.packages if pkg.download_path]
        if not downloaded_pkgs:
            if self.action in (
                PackageAction.INSTALL,
                PackageAction.REMOVE,
                PackageAction.REFRESH_CACHE,
            ):
                use_packagekit = True

        return use_packagekit

    def _run_packagekit(self) -> None:
        handler = PackageKitHandler(
            script_dir=self.script_dir,
            action=self.action,
            packages=self.packages,
            result_queue=self.result_queue,
            on_done=None,  # outer handler calls on_done
        )
        handler.run()

    def _run_apt(self) -> None:
        handler = AptSubprocessHandler(
            script_dir=self.script_dir,
            action=self.action,
            packages=self.packages,
            result_queue=self.result_queue,
            on_done=None,  # outer handler calls on_done
        )
        handler.run()
