#!/usr/bin/env python3
"""
Manages Flatpak package actions.

Reference: https://docs.flatpak.org/en/latest/flatpak-command-reference.html

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

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

from configparser import ConfigParser
from typing import Optional
from logging_config import logger
from app_shared import (
    AppStreamPackageInfo,
    PackageType,
    PackageAction,
    LogLevel
)

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


class FlatPakInfo():
    """
    Flatpak information class
    """
    def __init__(self):
        self.installed_flatpak_packages = None
        self._commit = None

    def refresh(self):
        """
        Refresh Flatpak info.
        """
        flatpak_packages = set(self.get_installed_flatpak_packages())
        self.installed_flatpak_packages = [line.strip() for line in flatpak_packages]

    def is_flatpak_pkg_installed(self, package_id: str) -> bool:
        """
        Check if Flatpak package is installed.

        Returns:
            bool: True if Flatpak is installed, False otherwise.
        """
        if package_id in self.get_installed_flatpak_packages():
            return True
        return False

    def get_flatpak_updates(self):
        """
        Checks for 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 self.installed_flatpak_packages]

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

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

        try:
            result = subprocess.run(
                ["flatpak", "list", "--all", "--columns=application"],
                capture_output=True,
                text=True,
                check=True
            )
            return result.stdout.splitlines()
        except subprocess.CalledProcessError as e:
            logger.warning("Failed to get flatpak packages: %s", e.stdout.strip())
        except Exception as e: # pylint: disable=broad-except
            logger.warning("Failed to get flatpak packages: %s", e)
        return []

    def get_flatpak_package_details(self, package_id: str) -> Optional[AppStreamPackageInfo]:
        """
        Get version, runtime, installed size, and metadata for ANY Flatpak package.
        Works for both apps and system packages (runtimes, etc.) using text parsing.
        """
        if not package_id or shutil.which('flatpak') is None:
            return None

        self._commit = None
        is_installed = self.is_flatpak_pkg_installed(package_id)

        info = AppStreamPackageInfo(
            package_id=package_id,
            package_name=package_id,
            icon=package_id,
            origin='flatpak',
            is_installed=is_installed,
            additional_packages=[],
            architecture='amd64'
        )

        try:
            if is_installed:
                # Get installed info
                self._get_installed_flatpak_info(package_id, info)

            # Get candidate info
            self._get_candidate_flatpak_info(package_id, info)

        except Exception as e:  # pylint: disable=broad-except
            logger.error("Error getting flatpak info for %s: %s", package_id, e)

        return info

    def _get_installed_flatpak_info(self, package_id: str, info: AppStreamPackageInfo):
        """Fetch installed package info using `flatpak info`."""
        try:
            result = subprocess.run(
                ['flatpak', 'info', package_id],
                capture_output=True,
                text=True,
                check=True,
                env={"LANG": "C", "LC_ALL": "C"},
                timeout=5
            )
            self._parse_flatpak_info_output(result.stdout, info, is_installed=True)
        except subprocess.CalledProcessError as e:
            logger.error("Failed to get installed info for %s: %s", package_id, e.stderr.strip())

    def _get_candidate_flatpak_info(self, package_id: str, info: AppStreamPackageInfo):
        """Fetch candidate package info using `flatpak info`."""
        try:
            result = subprocess.run(
                ['flatpak', 'remote-info', 'flathub', package_id],
                capture_output=True,
                text=True,
                check=True,
                env={"LANG": "C", "LC_ALL": "C"},
                timeout=5
            )
            self._parse_flatpak_info_output(result.stdout, info, is_installed=False)
        except subprocess.CalledProcessError as e:
            logger.error("Failed to get installed info for %s: %s", package_id, e.stderr.strip())

    def _parse_flatpak_info_output(self,
                                   output: str,
                                   info: AppStreamPackageInfo,
                                   is_installed: bool) -> None:
        """
        Parse `flatpak info` output.
        """
        lines = output.splitlines()
        subject = None

        # Set defaults for non-apps
        info.icon = "flatpak"
        info.summary = "Flatpak runtime package"

        for line in lines:
            line = line.strip()
            if not line:
                continue

            if ':' in line:
                key, _, value = line.partition(':')
                key = key.strip().lower()
                value = value.strip()

                if key == 'version':
                    if is_installed:
                        info.version = value
                    else:
                        info.candidate_version = value
                elif key == 'collection':
                    info.section = value
                elif key == 'installed':
                    info.installed_size = value
                    # 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)}"
                elif key == 'subject':
                    subject = value
                elif key == 'ref':
                    if value.startswith("app/"):
                        info.package_type = PackageType.APP
                        if is_installed:
                            info.executable = f"{shutil.which('flatpak')} run {info.package_id}"
                    else:
                        info.package_type = PackageType.RUNTIME
                elif key == 'commit':
                    # In Flatpak a commit indicates a new version
                    #   even if the version does not change
                    # Save the commit so we can compare it later
                    #   during candidate update check
                    if not self._commit:
                        self._commit = value
                    elif self._commit != value:
                        info.is_updatable = True

        if is_installed and info.executable:
            # Get section from desktop file
            try:
                result = subprocess.run(
                    ['flatpak', 'info', '-l', info.package_id],
                    capture_output=True,
                    text=True,
                    check=True
                )

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

                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' in parser:
                        desktop_data = dict(parser['Desktop Entry'])
                        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]

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

        # If version not found in metadata, extract from subject (e.g., "Add 595.80")
        if info.version is None and subject:
            match = re.search(r'(\d+(?:\.\d+)+)', subject)
            if match:
                info.version = match.group(1)

        # If still no version, try to extract from package_id or ref
        if info.version is None:
            # Example: org.freedesktop.Platform.GL.nvidia-595-58-03
            match = re.search(r'nvidia-(\d+(?:-\d+)+)', info.package_id)
            if match:
                info.version = match.group(1).replace('-', '.')

            # Or from Ref line (e.g., runtime/.../nvidia-595-58-03/x86_64/1.4)
            for line in lines:
                if line.startswith('Ref:'):
                    ref = line.split(':', 1)[1].strip()
                    match = re.search(r'/(\d+(?:-\d+)+)/', ref)
                    if match:
                        info.version = match.group(1).replace('-', '.')
                        break

        # Default name to package_id if not set
        if not info.name:
            info.name = info.package_name


class FlatpakPackageHandler(threading.Thread):
    """
    Manages Flatpak package actions.
    """
    def __init__(self,
                 action: PackageAction,
                 packages: list[AppStreamPackageInfo] = None,
                 result_queue: queue.Queue = None,
                 on_done=None) -> None:
        super().__init__(daemon=True)
        self.result_queue: queue.Queue = result_queue
        self.packages: list[AppStreamPackageInfo] = packages
        self.action: PackageAction = action
        self.on_done: callable = on_done

        self.package_ids: list[str] = self.search_package_ids()

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

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

        # Write the line to the log file
        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)

    def search_package_ids(self) -> list[str]:
        """ Searches for package ids. """
        return [pkg.package_id for pkg in self.packages]

    def run(self) -> None:
        if self.action not in (
            PackageAction.INSTALL,
            PackageAction.REMOVE,
            PackageAction.UPDATE
        ):
            self.queue_message(LogLevel.ERROR, _("Unknown action."), 0)
            return

        if not self.package_ids:
            self.queue_message(LogLevel.ERROR, _("No matching packages were found."), 0)
            return

        total = len(self.package_ids)
        done = 0

        for pkg_id in self.package_ids:
            done += 1
            before_pct = int((done - 1) / total * 100)
            after_pct = int(done / total * 100)

            command = ["flatpak", "install", "-y", "--noninteractive", "flathub", pkg_id]
            if self.action == PackageAction.REMOVE:
                command = ["flatpak", "uninstall", "-y", "--noninteractive", pkg_id]
            elif self.action == PackageAction.UPDATE:
                command = ["flatpak", "update", "-y", "--noninteractive", pkg_id]

            msg = self._status_message() + f" {pkg_id}"
            self.queue_message(LogLevel.PROGRESS, msg, before_pct)

            # Run command with streaming output
            ok = self._run_flatpak_command(command, pkg_id)
            if not ok:
                continue

            # Success message
            msg = _("Finished") + f" {msg.lower()}"
            self.queue_message(LogLevel.SUCCESS, msg, after_pct)

        if self.on_done:
            self.on_done()

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

    def _run_flatpak_command(self, command: list[str], pkg_id: str) -> bool:

        with subprocess.Popen(
            command,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            bufsize=1,
            env={"LANG": "C", "LC_ALL": "C"}
        ) as process:

            status_text = self._status_message() + f" {pkg_id}..."

            # Read stderr line-by-line (Flatpak prints progress here)
            for line in process.stderr:
                line = line.strip()
                if not line:
                    continue

                pct = self._extract_percentage(line)
                self.queue_message(LogLevel.PROGRESS, status_text, pct)

                # Log raw Flatpak output for debugging
                logger.debug("flatpak: %s", line)

            returncode = process.wait()

            if returncode != 0:
                self.queue_message(
                    LogLevel.ERROR,
                    _("Flatpak failed with exit code %s") % returncode,
                    0
                )
                return False

        return True

    def _extract_percentage(self, line: str) -> int:
        match = re.search(r"(\d+)%", line)
        return int(match.group(1)) if match else 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")
        return _("Working")
