#!/usr/bin/env python3
"""
RemovablePackages
Module for identifying removable packages on a Debian-based system.
Includes old kernel packages, their associated headers and kbuild packages (if installed),
and packages no longer available in the repositories (but keep held-back packages).

HeldBackPackages
Module for handling held-back packages on a Debian-based system.
It allows listing, setting, and removing held-back packages.

Author: Arjen Balfoort 
Date: 2026-02-12
Dependencies: aptitude, (dpkg, apt, coreutils, util-linux, bash)
"""

import os
import subprocess
import re
import gettext
from enum import StrEnum
from utils import (
    show_dialog,
    DialogIcon,
    DebSoftwareApp
)

_ = gettext.translation('solydxk-system', fallback=True).gettext

class AptAction(StrEnum):
    """ Possible apt actions """
    INSTALL = 'install'
    REMOVE = 'remove'
    HOLD = 'hold'
    UNHOLD = 'unhold'

class PackageFilter(StrEnum):
    """ Possible package filters """
    INSTALLED = "installed"
    AVAILABLE = "available"

def filter_packages(package_list: list[str],
                    package_filter: PackageFilter,
                    logging_handler=None) -> list[str]:
    """
    Filter a list of packages based on the specified filter (installed or available).

    Args:
        package_list (list): List of package names to check.
        package_filter (PackageFilter): Filter type (INSTALLED or AVAILABLE).

    Returns:
        list: Filtered list of package names.
    """
    filtered_packages = []
    for package in package_list:
        try:
            if package_filter == PackageFilter.INSTALLED:
                # Check if the package is installed
                subprocess.run(
                    [
                        "dpkg-query",
                        "-W",
                        r"--showformat=${Status}",
                        package
                    ],
                    check=True,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    text=True
                )
                filtered_packages.append(package)
            elif package_filter == PackageFilter.AVAILABLE:
                # Check if the package is available in the repositories
                result = subprocess.run(
                    ["apt-cache", "policy", package],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    text=True,
                    check=False
                )
                # If the exit code is 0, the package is known (available)
                if result.returncode == 0:
                    filtered_packages.append(package)
                elif logging_handler is not None:
                    logging_handler.debug("Package %s not found in repositories", package)
        except subprocess.CalledProcessError:
            # Only reached for INSTALLED check (AVAILABLE is handled above)
            if logging_handler is not None:
                logging_handler.debug("Package %s not installed", package)
            continue
    return filtered_packages


class RemovablePackages:
    """
    A class to identify removable packages on a Debian-based system.
    It includes old kernel packages, their associated headers and kbuild packages (if installed),
    and packages no longer available in the repositories (but keep held-back packages).
    """
    def __init__(self, app: DebSoftwareApp):
        self.app: DebSoftwareApp = app

    def _get_old_kernels_and_associated(self) -> list[str]:
        """
        Returns all installed versioned linux-image, linux-headers and linux-kbuild packages
        except those belonging to the running kernel.

        Returns:
            list: A list of old linux-* package names.
        """
        try:
            # Get the currently running kernel version
            current_kernel = subprocess.run(
                ["uname", "-r"],
                capture_output=True,
                text=True,
                check=True,
            ).stdout.strip()
        except subprocess.CalledProcessError:
            return []

        try:
            # List all installed linux-* packages
            result = subprocess.run(
                [
                    "dpkg-query",
                    "--show",
                    r"--showformat=${db:Status-Abbrev} ${Package}\n",
                    "linux-*"
                ],
                capture_output=True,
                text=True,
                check=True,
            ).stdout.split("\n")
        except subprocess.CalledProcessError:
            return []

        old_kernels = []

        # Regex patterns to exclude meta-packages
        meta_package_patterns = [
            r'^linux-image-[a-z]',
            r'^linux-headers-[a-z]',
        ]

        # Parse the output to get package names
        for line in result:
            parts = line.split()
            if len(parts) < 2:
                continue
            if parts[0] != 'ii':
                continue  # Skip non-installed packages
            package_name = parts[1]

            # Skip meta-packages
            is_meta = any(re.match(pattern, package_name) for pattern in meta_package_patterns)
            if is_meta:
                continue

            # Extract the version without architecture
            current_version = current_kernel.split("-")[0]

            # Check if the package is an old kernel
            for pattern in ['linux-image-', 'linux-headers-', 'linux-kbuild-']:
                # Check if the package matches the pattern
                if package_name.startswith(pattern):
                    version = package_name[len(pattern):].rsplit("-", 1)[0]
                    if version == current_version:
                        continue  # Skip the current kernel
                    old_kernels.append(package_name)

        return old_kernels

    def _get_autoremovable_packages(self) -> list[str]:
        """
        Returns a list of packages that would be removed by `apt-get autoremove`.
        Uses English locale for consistent parsing and handles errors gracefully.
        """
        try:
            # Force English output for consistent parsing
            env = os.environ.copy()
            env["LC_ALL"] = "C"
            result = subprocess.run(
                ["apt-get", "-s", "autoremove"],
                capture_output=True,
                text=True,
                check=True,
                env=env,
            )
            lines = result.stdout.splitlines()

            packages = []
            for line in lines:
                if line.startswith("Remv "):
                    package = line.split()[1]
                    packages.append(package)

            return packages
        except Exception as e:  # pylint: disable=broad-except
            err_msg = f"Unexpected error listing autoremovable packages: {str(e)}"
            show_dialog("Error", err_msg, self.app.window, DialogIcon.ERROR)
            return []

    def _get_obsolete_packages(self) -> list[str]:
        """
        Uses aptitude to find packages no longer available in the repositories,
        excluding packages held by either aptitude or apt-mark.

        Returns:
            list: A list of obsolete package names, or an empty list if none found.
        """
        try:
            # 1: ahold excludes aptitude-held packages (pre-dates apt-mark hold)
            obsolete = subprocess.run(
                ["aptitude", "search", "--display-format=%p", "~o !~ahold"],
                capture_output=True,
                text=True,
                check=True
            ).stdout.splitlines()

            # 2: Get dpkg-held packages
            # Use set in case of many obsolete packages
            held = set(
                subprocess.run(
                    ["apt-mark", "showhold"],
                    capture_output=True,
                    text=True,
                    check=True,
                ).stdout.splitlines()
            )

            # 3: Exclude held packages
            return [pkg for pkg in obsolete if pkg not in held]

        except Exception as e:  # pylint: disable=broad-except
            err_msg = f"Unexpected error listing obsolete packages: {str(e)}"
            show_dialog("Error", err_msg, self.app.window, DialogIcon.ERROR)
            return []

    def get_removable_packages(self) -> list[str]:
        """
        Collects all removable packages: old kernels and obsolete.

        Returns:
            list: A combined list of all removable package names.
        """
        old_kernels = self._get_old_kernels_and_associated()
        auto_removable_packages = self._get_autoremovable_packages()
        obsolete_packages = self._get_obsolete_packages()
        # Remove duplicates: convert the list into a set, which only stores unique values.
        return sorted(set(old_kernels + auto_removable_packages + obsolete_packages))


class HeldBackPackages:
    """
    A class to handle held-back packages on a Debian-based system.
    It allows listing, setting, and removing held-back packages.
    """
    def __init__(self, app) -> None:
        self.app: DebSoftwareApp = app

    def get_held_back_packages(self) -> list[str]:
        """
        Lists all held-back packages on the system.

        Returns:
            list: A list of held-back package names.
        """
        try:
            result = subprocess.run(
                ["apt-mark", "showhold"],
                capture_output=True,
                text=True,
                check=True,
            ).stdout.splitlines()
            return [line.strip() for line in result if line.strip()]
        except subprocess.CalledProcessError as e:
            err_msg = f"Unexpected error listing held-back packages: {str(e)}"
            show_dialog("Error", err_msg, self.app.window, DialogIcon.ERROR)
            return []

    def get_installed_packages(self) -> list[str]:
        """
        Lists all installed packages on the system.

        Returns:
            list: A list of installed package names.
        """
        try:
            result = subprocess.run(
                ['dpkg', '--list'],
                capture_output=True,
                text=True,
                check=True
            )
            packages = []
            held_back_packages = set(self.get_held_back_packages())
            for line in result.stdout.splitlines():
                if re.match(r'^[a-z]i', line):
                    package = line.split()[1].split(':')[0]
                    if package not in held_back_packages and package not in packages:
                        packages.append(package)
            return packages
        except subprocess.CalledProcessError:
            return []

    def hold_back_package(self, package_name: str) -> bool:
        """
        Sets a given package to "held back".
        Note: needs sudo privileges.

        Args:
            package_name (str): The name of the package to set as held back.

        Returns:
            bool: True if successful, False otherwise.
        """
        # pkexec will prompt for root password via polkit
        command = ["pkexec",
                   "--disable-internal-agent",
                   "python3",
                   os.path.join(self.app.script_dir, "packages_sudo.py"),
                   AptAction.HOLD.value,
                   package_name]

        try:
            process = subprocess.Popen(
                command,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                universal_newlines=True,
                text=True,
                bufsize=1
            )
            # Wait for the process to finish
            _, _ = process.communicate()
            return process.returncode == 0
        except Exception:   # pylint: disable=broad-except
            return False

    def remove_held_back_package(self, package_name: str) -> bool:
        """
        Removes the "held back" status from a given package.
        Note: needs sudo privileges.

        Args:
            package_name (str): The name of the package to remove the held-back status from.

        Returns:
            bool: True if successful, False otherwise.
        """

        # pkexec will prompt for root password via polkit
        command = ["pkexec",
                   "--disable-internal-agent",
                   "python3",
                   os.path.join(self.app.script_dir, "packages_sudo.py"),
                   AptAction.UNHOLD.value,
                   package_name]

        try:
            process = subprocess.Popen(
                command,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                universal_newlines=True,
                text=True,
                bufsize=1
            )
            # Wait for the process to finish
            _, _ = process.communicate()
            return process.returncode == 0
        except Exception:   # pylint: disable=broad-except
            return False
