#!/usr/bin/env python3
"""
Debian Software Manager classes

Dependencies:
- python3-gi
- python3-apt
- python3-simplejson

References:
Gtk4: https://docs.gtk.org/gtk4/
AppStream: https://www.freedesktop.org/software/appstream/docs/api/
Apt: https://apt-team.pages.debian.net/python-apt/
Flatpak: https://docs.flatpak.org/en/latest/flatpak-command-reference.html
ComponentKind: https://www.freedesktop.org/software/appstream/docs/api/enum.ComponentKind.html
Categories: https://specifications.freedesktop.org/menu/latest/category-registry.html

Author: Arjen Balfoort 
Date: 2026-03-26
"""

import time
import os
import re
import json
import gettext
import subprocess
import shutil
from pathlib import Path
from typing import Optional
from configparser import ConfigParser
import concurrent.futures
import apt

from image import ImageHandler
from app_shared import (
    AppStreamPackageInfo,
    DebSoftwareApp,
    FilterKind
)

import gi
gi.require_version("AppStream", "1.0")
gi.require_version('Gtk', '4.0')
from gi.repository import AppStream, Gtk, Gdk, Pango


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


class SoftwareManager:
    """Manages parsing and caching of APT package info and icons."""

    def __init__(self, app: DebSoftwareApp):
        self.app: DebSoftwareApp = app

        # Image handler
        self.image_handler: ImageHandler = ImageHandler(self.app)

        self.components: list[AppStream.Component] = None
        self.updates: list[AppStreamPackageInfo] = None
        self.addons: list[AppStreamPackageInfo] = None
        self.apt: apt.Cache = None
        self.upgradable_count: int = 0
        self.installed_deb_packages: dict[str, str] = None
        self.installed_flatpak_packages: list[str] = None

        # Create an AppStream pool
        self.pool: AppStream.Pool = AppStream.Pool()

        # Load data
        self.refresh_data()

        # Get categories from json file
        # Additional categories: get_main_categories()
        with open(file=os.path.join(self.app.data_dir, 'categories.json'),
                  mode='r',
                  encoding='utf-8') as f:
            self.categories_dict = json.load(f)

        # List all categories
        self.categories: list[str] = []
        for category_key in list(self.categories_dict.keys()):
            self.categories.append(category_key)

    # =========================================
    # Global methods
    # =========================================

    def refresh_data(self):
        """Refreshes the data."""
        # Create apt cache
        start = time.time()
        self.apt = apt.Cache()
        end = time.time()
        self.app.logging.debug(f"  apt.Cache: {end - start:.2f} seconds")

        # Load system-wide metadata
        start = time.time()
        self.pool.load()
        end = time.time()
        self.app.logging.debug(f"  pool.load: {end - start:.2f} seconds")

        # List all components
        start = time.time()
        self.components = self.pool.get_components().as_array()
        end = time.time()
        self.app.logging.debug(f"  pool.get_components: {end - start:.2f} seconds")

        # Get installed packages
        self.installed_deb_packages = self._get_debian_package_names()
        self.installed_flatpak_packages = self._get_flatpak_package_ids()

        # We tried threading this but got Gtk4 UI issues when updating apt
        self._cache_updates()
        self._cache_addons()

    def filter_components(self,
                          components_filter: Optional[FilterKind] = None,
                          category_filter: Optional[str] = None) -> list[AppStreamPackageInfo]:
        """
        Filter the components list
        
        Args:
            components_filter (Optional[FilterKind]): The filter kind to use.
            category_filter (Optional[str]): The category to filter by.

        Returns:
            list: A list of filtered AppStreamPackageInfo objects.
        """
        start = time.time()
        apps = []

        for component in self.components:
            if not self._can_add_component(component, components_filter):
                continue

            # Skip apt components without a package name
            pck_name = component.get_pkgname() or ''
            origin = component.get_origin()
            if not pck_name and origin != 'flatpak':
                continue

            if category_filter is not None:
                # Check the category
                try:
                    cat_list = self.categories_dict[category_filter]
                except KeyError:
                    # Category not found (currently only for accessibility category)
                    cat_list = [category_filter]

                component_cat_list = component.get_categories()
                result = any(item.lower() in {x.lower()
                            for x in component_cat_list}
                            for item in cat_list)
                if not result:
                    # Category not found
                    continue

            # Initialize package info
            pkg_info = self.get_summary_info(component)
            apps.append(pkg_info)

        end = time.time()
        self.app.logging.debug(f"filter_components: {end - start:.2f} seconds")

        return self.sort_apps(apps)

    def get_installed_packages_info(self,
            components_filter: Optional[FilterKind] = None) -> list[AppStreamPackageInfo]:
        """
        Get installed apps.

        Returns:
            list: A list of AppStreamPackageInfo objects.
        """
        start = time.time()
        pkg_info_list = []

        # --- Parallelize ---
        with concurrent.futures.ThreadPoolExecutor() as executor:
            apt_future = executor.submit(self._get_apt_installed, components_filter)
            flatpak_future = executor.submit(self._get_flatpak_installed)
            installed_components = apt_future.result() + flatpak_future.result()

        for component in installed_components:
            pkg_info = self.get_summary_info(component)
            if pkg_info and pkg_info not in pkg_info_list:
                pkg_info_list.append(self.get_summary_info(component))

        end = time.time()
        self.app.logging.debug(f"get_installed_apps: {end - start:.2f} seconds")

        # Return the list
        return self.sort_apps(pkg_info_list)

    def search_packages(self,
            query: str,
            components_filter: Optional[FilterKind] = None) -> list[AppStreamPackageInfo]:
        """
        Search for packages.

        Args:
            query (str): The query to search for.

        Returns:
            list: A list of AppStreamPackageInfo objects.
        """
        start = time.time()
        pkg_info_list = []
        components_found = []

        query = query.lower().strip()
        query_terms = query.split()

        for component in self.components:
            # Exact query match first
            if not self._can_add_component(component, components_filter):
                continue

            search_string = f"{component.get_pkgname()} \
                              {component.get_name()} \
                              {component.get_id()} \
                              {component.get_summary()} \
                              {component.get_description()}".lower()

            if query in search_string:
                components_found.append(component)

        if not components_found:
            # All of the query words
            for component in self.components:
                if not self._can_add_component(component, components_filter):
                    continue

                search_string = f"{component.get_pkgname()} \
                                  {component.get_name()} \
                                  {component.get_id()} \
                                  {component.get_summary()} \
                                  {component.get_description()}".lower()

                if all(term in search_string for term in query_terms):
                    components_found.append(component)

        if not components_found:
            # Any of the query words
            for component in self.components:
                if not self._can_add_component(component, components_filter):
                    continue

                search_string = f"{component.get_pkgname()} \
                                  {component.get_name()} \
                                  {component.get_id()} \
                                  {component.get_summary()} \
                                  {component.get_description()}".lower()

                if any(term in search_string for term in query_terms):
                    components_found.append(component)

        # Generate AppStreamPackageInfo objects
        for component in components_found:
            pkg_info = self.get_summary_info(component)
            if pkg_info and pkg_info not in pkg_info_list:
                pkg_info_list.append(pkg_info)

        end = time.time()
        self.app.logging.debug(f"search_packages - {query}: {end - start:.2f} seconds")

        # Return the list
        return self.sort_apps(pkg_info_list)

    def get_summary_info(self, component: AppStream.Component) -> AppStreamPackageInfo:
        """ Builds a package info object. """
        pkg_info = AppStreamPackageInfo(package_id=component.get_id(),
                                        name=component.get_name(),
                                        summary=component.get_summary(),
                                        origin=component.get_origin(),
                                        is_installed=False)
        if pkg_info.origin == 'flatpak':
            pkg_info.is_installed = pkg_info.package_id in self.installed_flatpak_packages
        else:
            pkg_info.is_installed = component.get_pkgname() in self.installed_deb_packages
        pkg_info.icon = self._get_icon(component)
        return pkg_info

    def get_package_details(self, package_id: str, package_origin) -> AppStreamPackageInfo:
        """
        Get details for a single package.
        
        Args:
            package_id (str): package id.
            package_origin (str): package origin (apt or flatpak).
        
        Returns:
            dict: A AptPackageInfo object containing all available package details.
        """
        component = None

        # Get the component object for the package
        for comp in self.components:
            if comp.get_id() == package_id:
                if comp.get_origin() == package_origin:
                    component = comp
                    break

        if component is None:
            # Package is not listed in AppStream
            return self._get_apt_details(pkgname=package_id)

        pkg_info = AppStreamPackageInfo(package_id=package_id)
        pkg_info.package_name = component.get_pkgname() or ''
        pkg_info.name = component.get_name()
        pkg_info.summary = component.get_summary()
        pkg_info.description = component.get_description()
        pkg_info.categories = component.get_categories()
        pkg_info.origin = component.get_origin()
        pkg_info.homepage = component.get_url(AppStream.UrlKind.HOMEPAGE) or ''
        pkg_info.icon = self._get_icon(component)

        # Get screenshots
        screenshots = []
        screenshots_nodes = component.get_screenshots_all()
        for screenshot_node in screenshots_nodes:
            images = screenshot_node.get_images()
            if len(images) > 0:
                screenshots.append(images[0].get_url())
        pkg_info.screenshots = screenshots

        if pkg_info.package_name:
            # Get apt information
            apt_info = self._get_apt_details(pkg_info.package_name)
            pkg_info.version = apt_info.version
            pkg_info.section = apt_info.section
            pkg_info.is_installed = apt_info.is_installed
            pkg_info.installed_size = apt_info.installed_size
            pkg_info.architecture = apt_info.architecture
            pkg_info.candidate_version = apt_info.candidate_version
            pkg_info.additional_packages = apt_info.additional_packages

            # Get executable path
            pkg_info.executable = self._get_executable(pkg_info.package_name)
        else:
            # Get flatpak information
            apt_info = self._get_flatpak_details(pkg_info.package_id)
            pkg_info.version = apt_info.version
            pkg_info.candidate_version = apt_info.candidate_version
            pkg_info.section = apt_info.section
            pkg_info.is_installed = apt_info.is_installed
            pkg_info.installed_size = apt_info.installed_size
            pkg_info.additional_packages = apt_info.additional_packages

        return pkg_info

    def get_package_info(self, pkgname: str, origin: str=None) -> Optional[AppStreamPackageInfo]:
        """
        Get details for a package.
        This is a wrapper for _get_apt_details and _get_flatpak_details
        if the package is not found in AppStream
        
        Args:
            pkgname (str): A package name to get details for.
            origin (str): Package origin (debian or flatpak).
        
        Returns:
            dict: AppStreamPackageInfo objects or None.
        """
        for component in self.components:
            if component.get_pkgname() == pkgname or component.get_id() == pkgname:
                if origin and origin not in component.get_origin():
                    continue
                pkg_info = self.get_summary_info(component)
                return pkg_info

        # Create temporary object for packages that are not listed in AppStream
        if origin is None or origin != 'flatpak':
            pkg_info = self._get_apt_details(pkgname=pkgname)

        if pkg_info is None and (origin is None or origin == 'flatpak'):
            pkg_info = self._get_flatpak_details(pkgname)

        return pkg_info

    def sort_apps(self,
                  pkg_info: list[AppStreamPackageInfo],
                  sort_by: str = 'name',
                  reverse: bool = False) -> list[AppStreamPackageInfo]:
        """
        Sorts a list of AppStreamPackageInfo objects by the specified attribute.

        Args:
            pkg_info: List of AppStreamPackageInfo objects to sort.
            sort_by: Attribute to sort by (e.g., 'package_name', 'name'). Defaults to 'name'.
            reverse: If True, sorts in descending order. Defaults to False.

        Returns:
            Sorted list of AppStreamPackageInfo objects.
        """
        try:
            return sorted(
                pkg_info,
                key=lambda obj: getattr(obj, sort_by).lower(),
                reverse=reverse
            )
        except AttributeError:
            self.app.logging.error(f"Invalid sort attribute: {sort_by}")
            return pkg_info

    def get_gtk_box_summary(self, pkg_info: AppStreamPackageInfo) -> Gtk.Box:
        """
        Create a summary GTK box for a package.
        
        Args:
            pkg_info (AppStreamPackageInfo): A package info object.

        CSS Classes:
            summary_box
            summary_box label
            summary_box label.name
            summary_box label.summary
            summary_box label.origin
        
        Returns:
            tuple[str, Gtk.Box]: pkg_id, A GTK box for the package summary.
        """
        box = Gtk.Box()
        box.set_orientation(Gtk.Orientation.HORIZONTAL)
        box.set_spacing(12)
        box.set_css_classes(['summary_box'])

        # Create a hand cursor
        hand_cursor = Gdk.Cursor.new_from_name("pointer", None)
        box.set_cursor(hand_cursor)

        # Add icon
        icon = Gtk.Picture()
        self.image_handler.set_picture(icon, pkg_info.icon, 48)
        icon.set_content_fit(Gtk.ContentFit.SCALE_DOWN)
        icon.set_valign(Gtk.Align.CENTER)
        box.append(icon)

        # Add name, summary and origin to the label_box
        label_box = Gtk.Box()
        label_box.set_orientation(Gtk.Orientation.VERTICAL)
        label_box.set_hexpand(True)

        # Add name
        label_name = Gtk.Label(label=pkg_info.name)
        label_name.set_halign(Gtk.Align.START)
        context = label_name.get_style_context()
        context.add_class("name")
        label_box.append(label_name)

        # Add summary
        label_summary = Gtk.Label(label=pkg_info.summary)
        # Limit the text length to the width of the label
        label_summary.set_ellipsize(Pango.EllipsizeMode.END)
        label_summary.set_halign(Gtk.Align.START)
        context = label_summary.get_style_context()
        context.add_class("summary")
        label_box.append(label_summary)

        # Add origin
        label_origin = Gtk.Label(label=pkg_info.origin)
        label_origin.set_halign(Gtk.Align.START)
        context = label_origin.get_style_context()
        context.add_class("origin")
        label_box.append(label_origin)
        box.append(label_box)

        if pkg_info.is_installed:
            installed_icon = Gtk.Picture().new_for_filename(
                os.path.join(self.app.images_dir, "installed.svg")
            )
            installed_icon.set_tooltip_text(_("Installed"))
            installed_icon.set_content_fit(Gtk.ContentFit.SCALE_DOWN)
            installed_icon.set_valign(Gtk.Align.END)
            box.append(installed_icon)

        return box

    # =========================================
    # Private methods
    # =========================================

    @staticmethod
    def _get_debian_package_names() -> 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

    @staticmethod
    def _get_flatpak_package_ids() -> list[str]:
        """ Returns a list of installed Flatpak package IDs. """
        if shutil.which('flatpak') is None:
            return []

        result = subprocess.run(
            ["flatpak", "list", "--app", "--columns=application"],
            capture_output=True,
            text=True,
            check=True
        )
        return result.stdout.splitlines()

    def _get_apt_details(self, pkgname: str) -> 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._policy_versions(pkgname)

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

            info.package_name = pkgname
            info.name = pkgname.capitalize()
            info.summary = apt_pkg.summary
            info.description = apt_pkg.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.homepage
            if self.app.icon_theme.has_icon(pkgname):
                info.icon = pkgname
            else:
                info.icon = self._get_default_icon(None)
            info.section = apt_pkg.section
            info.is_installed = apt_pkg.is_installed
            info.installed_size = self._human_readable_size(apt_pkg.installed_size)
            info.architecture = apt_pkg.architecture
            additional_packages = []
            for dep in apt_pkg.dependencies + apt_pkg.recommends:
                try:
                    if not self.apt[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
            self.app.logging.error(f"Error getting apt info for {pkgname}: {e}")
            return None

        try:
            if info.is_installed:
                # Find and parse .desktop files
                arch = self.installed_deb_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:
                    self.app.logging.error(
                        f"Error getting desktop info for {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', '')
                                info.categories = desktop_data.get('Categories').split(';')
                                break

                        # Find the first executable
                        if info.executable:
                            break

        except subprocess.CalledProcessError as e:
            self.app.logging.error(f"Error getting desktop info for {pkgname}: {e.stderr.strip()}")
            return None
        except Exception as e: # pylint: disable=broad-except
            self.app.logging.error(f"Error getting desktop info for {pkgname}: {e}")
            return None

        return info

    def _get_flatpak_details(self, package_id: str) -> Optional[AppStreamPackageInfo]:
        """
        Get version, runtime, installed size, and additional packages (dependencies)
        for a Flatpak package, whether installed or available.

        Args:
            package_id (str): The Flatpak package ID to get info for.

        Returns:
            AppStreamPackageInfo object.
        """
        if not package_id or shutil.which('flatpak') is None:
            return {}

        info = AppStreamPackageInfo(package_id=package_id,
                                    package_name=package_id,
                                    icon=package_id,
                                    origin='flatpak',
                                    is_installed=self._is_flatpak_pkg_installed(package_id),
                                    executable=f"{shutil.which('flatpak')} run {package_id}",
                                    additional_packages=[])

        command_installed = ['flatpak', 'info', package_id]
        command_candidate = ['flatpak', 'remote-info', 'flathub', package_id]
        command_location = ['flatpak', 'info', '-l', package_id]
        command = command_installed if info.is_installed else command_candidate

        try:
            # Get info using flatpak remote-info (works for both installed and available)
            result = subprocess.run(
                command,
                capture_output=True,
                text=True,
                check=False,
                env={"LANG": "C", "LC_ALL": "C"},
                encoding='utf-8',
                timeout=5
            )
            if result.returncode != 0:
                self.app.logging.error(
                    f"Error getting flatpak info for {package_id}: {result.stderr}"
                )

            lines = result.stdout.splitlines()
            for line in lines:
                line = line.strip().lower()
                if not line:
                    continue
                if ':' in line:
                    if line.startswith('version:'):
                        version = line.split(':', 1)[1].strip()
                        info.version = version if info.is_installed else ''
                        info.candidate_version = version
                    elif line.startswith('collection:'):
                        info.section = line.split(':', 1)[1].strip()
                    elif line.startswith('installed:'):
                        info.installed_size = line.split(':', 1)[1].strip()
                        # Use regex to extract the number and unit (e.g., "1,3 MB")
                        match = re.search(r'([\d\.]+)\W*([a-zA-Z]+)', info.installed_size)
                        if match:
                            info.installed_size = f"{match.group(1)} {match.group(2)}"
                else:
                    parts = line.split('-')
                    if len(parts) == 2:
                        info.name = parts[0].strip()
                        info.summary = parts[1].strip()

        except subprocess.CalledProcessError as e:
            self.app.logging.error(
                f"Error getting flatpak info for {package_id}: {e.stderr.strip()}"
            )
            return info
        except Exception as e: # pylint: disable=broad-except
            self.app.logging.error(f"Error getting flatpak info for {package_id}: {e}")
            return info

        if info.is_installed:
            # Get section from desktop file
            try:
                result = subprocess.run(
                    command_location,
                    capture_output=True,
                    text=True,
                    check=True
                )

                desktop_file = os.path.join(result.stdout.splitlines()[0].strip(),
                                       f'files/share/applications/{package_id}.desktop')

                if os.path.exists(desktop_file):
                    parser = ConfigParser(interpolation=None)
                    parser.read(desktop_file)
                    if 'Desktop Entry' in parser:
                        desktop_entry = parser['Desktop Entry']
                        if 'Name' in desktop_entry:
                            info.name = desktop_entry['Name']
                        if 'Comment' in desktop_entry:
                            info.summary = desktop_entry['Comment']
                        if 'Icon' in desktop_entry:
                            info.icon = desktop_entry['Icon']
                        if 'Categories' in desktop_entry:
                            info.categories = desktop_entry['Categories'].split(';')

            except subprocess.CalledProcessError as e:
                self.app.logging.error(
                    f"Error getting flatpak section info for {package_id}: {e.stderr.strip()}"
                )
            except Exception as e: # pylint: disable=broad-except
                self.app.logging.error(
                    f"Error getting flatpak section info for {package_id}: {e}"
                )

            # Get candidate version
            try:
                result = subprocess.run(
                    command_candidate,
                    capture_output=True,
                    text=True,
                    check=False,
                    env={"LANG": "C", "LC_ALL": "C"},
                    encoding='utf-8',
                    timeout=5
                )
                if result.returncode != 0:
                    self.app.logging.error("Error getting candidate flatpak info for "
                                      f"{package_id}: {result.stderr}")

                lines = result.stdout.splitlines()
                for line in lines:
                    line = line.strip().lower()
                    if line.startswith('version:'):
                        info.candidate_version = line.split(':', 1)[1].strip()
                        break

            except subprocess.CalledProcessError as e:
                self.app.logging.error(
                    f"Error getting candidate flatpak info for {package_id}: {e.stderr.strip()}"
                )
            except Exception as e: # pylint: disable=broad-except
                self.app.logging.error(
                    f"Error getting candidate flatpak info for {package_id}: {e}"
                )

        return info

    @staticmethod
    def _can_add_component(component: AppStream.Component,
                           components_filter: Optional[FilterKind]) -> bool:
        """
        Check if a component can be added to the list of installed apps.
        """
        origin = component.get_origin() or ''
        kind = component.get_kind() or ''
        pkgname = component.get_pkgname() if origin != 'flatpak' else component.get_id()
        if not origin or not kind or not pkgname:
            return False
        if components_filter and kind not in components_filter.value:
            return False
        return True

    def _cache_addons(self):
        """ Fills self.addons with installed addons. """
        start = time.time()
        self.addons = self.filter_components(FilterKind.ADDON)
        end = time.time()
        self.app.logging.debug(f"  cache_addons: {end - start:.2f} seconds")

    def _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

    def _get_icon(self, component: AppStream.Component) -> str:
        """ Gets the icon for a component. """
        icon_paths = []
        icon_names = []
        icon_nodes = component.get_icons()
        for icon_node in icon_nodes:
            icon_path = icon_node.get_filename()
            icon_name = icon_node.get_name()
            if icon_path is not None:
                icon_paths.append(icon_path)
                continue
            if icon_name is not None and '.' not in icon_name:
                icon_names.append(icon_name)

        # Try to get the icon from the icon path
        for icon_path in reversed(icon_paths):
            if os.path.exists(icon_path):
                return icon_path

        # Try to get the icon from the icon name
        for icon_name in reversed(icon_names):
            if self.app.icon_theme.has_icon(icon_name):
                return icon_name

        # Try to get the icon from the package id
        icon = component.get_id().lower()
        if self.app.icon_theme.has_icon(icon):
            return icon

        # Try to get the icon from parts of the id
        for icon_part in icon.split('.'):
            if self.app.icon_theme.has_icon(icon_part):
                return icon_part

        # Use default icon
        return self._get_default_icon(component)

    def _get_default_icon(self, component: AppStream.Component=None) -> str:
        """ No icon: use default icons per component kind """
        icon = os.path.join(self.app.images_dir, "default.svg")
        if component is None:
            return icon

        if component.get_kind() == AppStream.ComponentKind.CONSOLE_APP:
            icon = os.path.join(self.app.images_dir, "console.svg")
        elif component.get_kind() == AppStream.ComponentKind.WEB_APP:
            icon = os.path.join(self.app.images_dir, "web.svg")
        elif component.get_kind() == AppStream.ComponentKind.DESKTOP_APP:
            icon = os.path.join(self.app.images_dir, "desktop.svg")

        return icon

    def _cache_updates(self):
        """ Fills self.updates with updatable packages. """
        start = time.time()
        upgradable = []
        pkg_info_list = []

        # Create set for O(1) lookups
        installed_flatpak_set = set(self.installed_flatpak_packages)

        def get_apt_updates():
            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_deb_packages and
                        self.installed_deb_packages.get(pkg_name) in (arch, "all") and
                        pkg_name not in installed_flatpak_set):
                        upgradable.append(pkg_name)
                return upgradable

            except subprocess.CalledProcessError as e:
                self.app.logging.error(f"Error (apt): {e.stderr.strip()}")
                return []
            except Exception as e: # pylint: disable=broad-except
                self.app.logging.error(f"Error (apt): {e}")
                return []

        def get_flatpak_updates():
            checked_ids = []
            if shutil.which('flatpak') is None:
                return checked_ids

            try:
                result = subprocess.run(
                    ["flatpak", "remote-ls", "--updates", "--columns=application"],
                    capture_output=True, text=True, check=True
                )
                flatpak_ids = [line for line in result.stdout.splitlines()
                            if line and not line.startswith("#")]
                # Include packages installed via Flatpak
                return [fid for fid in flatpak_ids if fid in installed_flatpak_set]

            except subprocess.CalledProcessError as e:
                self.app.logging.error(f"Error (flatpak): {e.stderr.strip()}")
                return []
            except Exception as e: # pylint: disable=broad-except
                self.app.logging.error(f"Error (flatpak): {e}")
                return []

        with concurrent.futures.ThreadPoolExecutor() as executor:
            apt_future = executor.submit(get_apt_updates)
            flatpak_future = executor.submit(get_flatpak_updates)
            upgradable = apt_future.result() + flatpak_future.result()

        self.upgradable_count = len(upgradable)

        for upg in apt_future.result():
            # Use apt-cache policy to get installed and candidate versions
            # apt.Cache might contain stale information after APT operations
            installed, candidate = self._policy_versions(upg)

            # If apt-cache policy says installed == candidate, skip it
            if installed and candidate and installed == candidate:
                continue

            pkg_info = self.get_package_info(pkgname=upg, origin='apt')
            if pkg_info and pkg_info not in pkg_info_list:
                pkg_info_list.append(pkg_info)

        for upg in flatpak_future.result():
            installed, candidate = self._policy_versions(upg)
            if installed and candidate and installed == candidate:
                continue

            pkg_info = self.get_package_info(pkgname=upg, origin='flatpak')
            if pkg_info and pkg_info not in pkg_info_list:
                pkg_info_list.append(pkg_info)

        end = time.time()
        self.app.logging.debug(f"  cache_updates: {end - start:.2f} seconds")

        # Save the list with updates
        self.updates = self.sort_apps(pkg_info_list)

    def _get_apt_installed(self,
                components_filter: Optional[FilterKind] = None) -> list[AppStream.Component]:
        """ Returns a list of installed apt components. """
        apt_components = []
        for component in self.components:
            if not self._can_add_component(component, components_filter):
                continue
            name = component.get_pkgname() or ''
            if name and self.apt[name].is_installed:
                apt_components.append(component)
        return apt_components

    def _get_flatpak_installed(self) -> list[AppStream.Component]:
        """ Returns a list of installed flatpak components. """
        flatpak_components = []
        if shutil.which('flatpak') is None:
            return flatpak_components

        try:
            flatpak_output = subprocess.check_output(
                ['flatpak', 'list', '--app', '--columns=application'],
                stderr=subprocess.PIPE, text=True
            ).decode('utf-8')
            flatpak_packages = [line.strip() for line in flatpak_output.splitlines()]
            for component in self.components:
                if component.get_id() in flatpak_packages:
                    flatpak_components.append(component)

        except subprocess.CalledProcessError as e:
            self.app.logging.warning(f"Failed to get flatpak packages: {e.stdout.strip()}")
        except Exception as e: # pylint: disable=broad-except
            self.app.logging.warning(f"Failed to get flatpak packages: {e}")
        return flatpak_components

    @staticmethod
    def _human_readable_size(size_bytes):
        """Convert size in bytes to a human-readable string, matching apt show output."""
        size = float(size_bytes)
        if size < 1000:
            return f"{size:.1f} B"
        size /= 1000  # KB
        if size < 1000:
            return f"{size:.1f} KB"
        size /= 1000  # MB
        if size < 1000:
            return f"{size:.1f} MB"
        size /= 1000  # GB
        if size < 1000:
            return f"{size:.1f} GB"
        size /= 1000  # TB
        return f"{size:.1f} TB"

    @staticmethod
    def _is_flatpak_pkg_installed(package_id: str) -> bool:
        """
        Check if Flatpak package is installed.

        Returns:
            bool: True if Flatpak is installed, False otherwise.
        """
        if shutil.which('flatpak') is None:
            return False

        # Check if the package is installed
        result = subprocess.run(
            ['flatpak', 'list', '--app', '--columns=application'],
            capture_output=True,
            text=True,
            check=True
        )
        if package_id in result.stdout:
            return True
        return False

    @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
