#!/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 json
import gettext
import shutil
from typing import Optional
import concurrent.futures
import apt

from image import ImageHandler
from logging_config import logger
from apt_handler import AptInfo
from flatpak_handler import FlatPakInfo
from app_shared import (
    AppStreamPackageInfo,
    FilterKind
)

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


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


class DefaultIcon:
    """ Default icon """
    def __init__(self, images_dir: str):
        self.icon = None
        self.images_dir = images_dir

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

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

        return self.icon

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

    def __init__(self, main_model):
        self.model = main_model

        # Gtk icon theme
        display = Gdk.Display.get_default()
        self.icon_theme = Gtk.IconTheme.get_for_display(display)

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

        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

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

        # Create Flatpak and Apt objects
        self.default_icon = DefaultIcon(self.model.images_dir)
        self.flatpak_info: FlatPakInfo = FlatPakInfo()
        self.apt_info: AptInfo = AptInfo(
            self.icon_theme,
            self.default_icon
        )

        # Load data
        self.refresh_data()

        # Get categories from json file
        # Additional categories: get_main_categories()
        with open(file=os.path.join(self.model.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()
        logger.debug("apt.Cache: %.2f seconds", end - start)

        # Refresh apt and flatpak info
        self.apt_info.refresh()
        self.flatpak_info.refresh()

        # Load system-wide metadata
        start = time.time()
        self.pool.load()
        end = time.time()
        logger.debug("pool.load: %.2f seconds", end - start)

        # List all components
        start = time.time()
        self.components = self.pool.get_components().as_array()
        end = time.time()
        logger.debug("pool.get_components: %.2f seconds", end - start)

        # We tried threading this but got Gtk4 UI issues when updating apt
        self._get_updates()
        self._get_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 = []
        seen_ids = set()

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

            # De-duplicate Flatpak components
            if component.get_id() in seen_ids:
                continue  # skip duplicate Flatpak component
            seen_ids.add(component.get_id())

            # 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_component_summary(component)
            apps.append(pkg_info)

        end = time.time()
        logger.debug("filter_components: %.2f seconds", end - start)

        return self._sort_apps(apps)

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

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

        # --- 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:

            # De-duplicate Flatpak components
            if component.get_id() in seen_ids:
                continue  # skip duplicate Flatpak component
            seen_ids.add(component.get_id())

            pkg_info = self._get_component_summary(component)
            if pkg_info and pkg_info not in pkg_info_list:
                pkg_info_list.append(self._get_component_summary(component))

        end = time.time()
        logger.debug("get_installed_apps: %.2f seconds", end - start)

        # 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 = []
        seen_ids = set()

        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

            # De-duplicate Flatpak components
            if component.get_id() in seen_ids:
                continue  # skip duplicate Flatpak component
            seen_ids.add(component.get_id())

            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_component_summary(component)
            if pkg_info and pkg_info not in pkg_info_list:
                pkg_info_list.append(pkg_info)

        end = time.time()
        logger.debug("search_packages - %s: %.2f seconds", query, end - start)

        # Return the list
        return self._sort_apps(pkg_info_list)

    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

        for comp in self.components:
            comp_pkgname = comp.get_pkgname() or ''

            if comp.get_id() == package_id and comp.get_origin() == package_origin:
                component = comp
                break

            if comp_pkgname == package_id and comp.get_origin() == package_origin:
                component = comp
                break

        if component is None:
            # Package is not listed in AppStream
            if package_origin == 'flatpak':
                return self.flatpak_info.get_flatpak_package_details(package_id)
            return self.apt_info.get_apt_package_details(pkgname=package_id, apt_cache=self.apt)

        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.apt_info.get_apt_package_details(
                pkgname=pkg_info.package_name,
                apt_cache=self.apt
            )
            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
            if pkg_info.is_installed:
                pkg_info.executable = self.apt_info.get_executable(pkg_info.package_name)
        else:
            # Get flatpak information
            apt_info = self.flatpak_info.get_flatpak_package_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.architecture = apt_info.architecture
            pkg_info.installed_size = apt_info.installed_size
            pkg_info.additional_packages = apt_info.additional_packages
            pkg_info.executable = apt_info.executable

        return pkg_info

    def get_package_summary(self, pkgname: str, origin: str=None) -> Optional[AppStreamPackageInfo]:
        """
        Get details for a package.
        This is a wrapper for _get_apt_details and get_flatpak_package_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.
        """
        pkg_info = 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_component_summary(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.apt_info.get_apt_package_details(pkgname=pkgname, apt_cache=self.apt)

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

        return pkg_info

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

    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:
            logger.error("Invalid sort attribute: %s", sort_by)
            return pkg_info

    def _get_component_summary(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.flatpak_info.installed_flatpak_packages
        else:
            pkg_info.is_installed = component.get_pkgname() in self.apt_info.installed_apt_packages
        pkg_info.icon = self._get_icon(component)
        return pkg_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 _get_addons(self) -> list[AppStreamPackageInfo]:
        """ Fills self.addons with installed addons. """
        start = time.time()
        self.addons = self.filter_components(FilterKind.ADDON)
        end = time.time()
        logger.debug("cache_addons: %.2f seconds", end - start)
        return self.addons

    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.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.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.icon_theme.has_icon(icon_part):
                return icon_part

        # Use default icon
        return self.default_icon.get_default_icon(component)

    def _get_updates(self) -> list[AppStreamPackageInfo]:
        """ Fills self.updates with updatable packages. """
        start = time.time()
        upgradable = []
        pkg_info_list = []

        with concurrent.futures.ThreadPoolExecutor() as executor:
            apt_future = executor.submit(
                self.apt_info.get_apt_updates,
                self.flatpak_info.installed_flatpak_packages
            )
            flatpak_future = executor.submit(self.flatpak_info.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.apt_info.get_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_summary(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.apt_info.get_policy_versions(upg)
            if installed and candidate and installed == candidate:
                continue

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

        end = time.time()
        logger.debug("cache_updates: %.2f seconds", end - start)

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

    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

        flatpak_packages = self.flatpak_info.installed_flatpak_packages
        for component in self.components:
            if component.get_id() in flatpak_packages:
                flatpak_components.append(component)

        return flatpak_components
