#!/usr/bin/env python3
"""
Manages fwupd firmware update actions.

Reference: https://fwupd.github.io/libfwupd

Testing:
sudo apt-get install fwupd-tests
sudo fwupdtool enable-test-devices
sudo fwupdtool disable-test-devices

Make sure you have the lvfs-testing and lvfs remotes enabled.

List disabled download remotes:
fwupdmgr get-remotes --json \
  | jq -r '.Remotes[] | select(.Enabled == false and .Kind == "download") | .Id'

Enable lvfs-testing and lvfs remotes:
sudo fwupdmgr -y enable-remote lvfs-testing
sudo fwupdmgr -y enable-remote lvfs


Author: Arjen Balfoort
Date: 2026-06-20
"""

import threading
import queue
import gettext
from typing import Optional, List

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

import gi
gi.require_version('Fwupd', '2.0')
gi.require_version('GLib', '2.0')
from gi.repository import Fwupd, GLib, Gio  #pylint: disable=wrong-import-position

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


class FwupdInfo:
    """
    Fwupd information class - manages firmware device info and updates.
    """

    def __init__(self):
        self.fwupd_client = Fwupd.Client.new()
        self.cancellable = Gio.Cancellable.new()
        self.devices = []
        self.updates = []
        self.remotes = []
        self._initialized = False
        self._connect_success = False

    def refresh(self) -> None:
        """Refresh fwupd information."""
        self.devices = []
        self.updates = []
        self.remotes = []
        self._initialized = False

        # Use async connect with a local main loop
        self.fwupd_client.connect_async(
            self.cancellable,
            self._on_connect_finish,
            None
        )

        # Run a local main loop to wait for async operation
        context = GLib.MainContext.default()
        while not self._connect_success:
            context.iteration(True)

        if not self._initialized:
            return

        self._fetch_devices()
        self._fetch_remotes()
        self._fetch_updates()

    def get_fwupd_updates(self) -> List[AppStreamPackageInfo]:
        """Get list of devices with available firmware updates."""
        return self.updates

    def _on_connect_finish(self, _client, res, _user_data):
        try:
            self.fwupd_client.connect_finish(res)
            self._initialized = True
        except GLib.Error as e:
            logger.error("Failed to connect to fwupd: %s", e)
            self._initialized = False
        self._connect_success = True

    def _fetch_devices(self) -> None:
        """Fetch all fwupd devices."""
        try:
            devices = self.fwupd_client.get_devices(self.cancellable)
            self.devices = [dev for dev in devices if dev]
        except GLib.Error as e:
            logger.error("Failed to fetch fwupd devices: %s", e)

    def _fetch_remotes(self) -> None:
        """Fetch all fwupd remotes."""
        try:
            remotes = self.fwupd_client.get_remotes(self.cancellable)
            self.remotes = [rem for rem in remotes if rem]
        except GLib.Error as e:
            logger.error("Failed to fetch fwupd remotes: %s", e)

    def _fetch_updates(self) -> None:
        """
        Get list of devices with available firmware updates.
        """
        if not self._initialized:
            return []

        try:
            for device in self.devices:
                if not self._is_device_updatable(device):
                    continue

                try:
                    releases = self.fwupd_client.get_upgrades(
                        device.get_id(),
                        self.cancellable
                    )
                    for release in releases:
                        info = self._create_package_info(device, release)
                        if info:
                            self.updates.append(info)
                except Exception as e:  # pylint: disable=broad-except
                    logger.debug(
                        "Could not get upgrades for %s: %s",
                        device.get_name(),
                        e
                    )
        except Exception as e:  # pylint: disable=broad-except
            logger.error("Error in get_fwupd_updates: %s", e)

    def _is_device_updatable(self, device) -> bool:
        """Check if device is supported and updatable."""
        flags = device.get_flags()
        return (
            bool(flags & Fwupd.DeviceFlags.UPDATABLE) and
            not bool(flags & Fwupd.DeviceFlags.LOCKED)
        )

    def _create_package_info(
        self,
        device: Fwupd.Device,
        release: Fwupd.Release
    ) -> Optional[AppStreamPackageInfo]:
        """
        Create AppStreamPackageInfo from fwupd device and release.
        """
        device_id = device.get_id()
        if not device_id:
            return None

        # release.get_locations() contains relative paths; we keep a hint only
        locations = release.get_locations()
        uri_hint = locations[0] if locations else ""

        # Get icon
        icon = "drive-harddisk"
        icons = device.get_icons()
        if icons and len(icons) > 0:
            icon = icons[0]

        # Determine if device needs reboot
        needs_reboot = bool(
            device.get_flags() & Fwupd.DeviceFlags.NEEDS_REBOOT
        )

        info = AppStreamPackageInfo(
            package_id=device_id,
            package_name=device.get_name() or device_id,
            package_type=PackageType.RUNTIME,
            name=device.get_name() or device_id,
            summary=device.get_summary() or "",
            description=release.get_description() or "",
            origin=release.get_remote_id() or "lvfs",
            homepage=release.get_homepage() or "",
            icon=icon,
            version=release.get_version() or "",
            section="Firmware",
            is_installed=True,
            installed_size=human_readable_size(release.get_size() or 0),
            architecture="",
            candidate_version=release.get_version() or "",
            additional_packages=[],
            executable="",
            download_path=uri_hint or "",
            is_updatable=True
        )

        # Store fwupd-specific objects for later use
        info.fwupd_device = device
        info.fwupd_release = release
        info.fwupd_needs_reboot = needs_reboot

        return info

    def get_fwupd_device_details(self, device_id: str) -> Optional[AppStreamPackageInfo]:
        """Get AppStreamPackageInfo for fwupd device."""
        for update in self.updates:
            if update.package_id == device_id:
                return update
        return None

    def is_fwupd_device_supported(self, device_id: str) -> bool:
        """Check if fwupd device is supported."""
        for device in self.devices:
            if device.get_id() == device_id:
                return self._is_device_updatable(device)
        return False


class FwupdHandler(threading.Thread):
    """
    Manages fwupd firmware update actions using fwupd's install_release_async.
    """

    def __init__(
        self,
        action: PackageAction,
        packages: List[AppStreamPackageInfo] = None,
        result_queue: queue.Queue = None,
        on_done: callable = None
    ) -> None:
        super().__init__(daemon=True)
        self.action: PackageAction = action
        self.result_queue: queue.Queue = result_queue
        self.packages: List[AppStreamPackageInfo] = packages or []
        self.on_done: callable = on_done
        self.cancellable = Gio.Cancellable.new()

        self._cancelled: bool = False

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

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

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

    def run(self) -> None:
        """Main thread execution."""
        logger.debug("FwupdHandler started")
        if not self.packages:
            self.queue_message(
                LogLevel.ERROR,
                _("No firmware updates were found."),
                0
            )
            if self.on_done:
                self.on_done()
            return

        if self.action != PackageAction.UPDATE:
            self.queue_message(
                LogLevel.ERROR,
                _("Unsupported firmware update action."),
                0
            )
            if self.on_done:
                self.on_done()
            return

        fwupd_client = Fwupd.Client()

        # Update firmware metadata
        self._update_metadata(fwupd_client)

        # Install firmware updates
        progress = 0
        for i, package in enumerate(self.packages):
            progress = int((i / len(self.packages)) * 100)
            device = getattr(package, 'fwupd_device', None)
            release = getattr(package, 'fwupd_release', None)
            fwupd_needs_reboot = getattr(package, 'fwupd_needs_reboot', False)

            if device and release:
                if not device.has_flag(Fwupd.DeviceFlags.SUPPORTED):
                    self.queue_message(
                        LogLevel.WARNING,
                        f"Device {device.get_id()} not supported",
                        progress
                    )
                    continue
                if device.has_flag(Fwupd.DeviceFlags.LOCKED):
                    self.queue_message(
                        LogLevel.WARNING,
                        f"Device {device.get_id()} is locked",
                        progress
                    )
                    continue

                success = fwupd_client.install_release(
                    device,
                    release,
                    Fwupd.InstallFlags.NONE,
                    Fwupd.ClientDownloadFlags.NONE,
                    self.cancellable
                )

                if success:
                    msg = _("Firmware update installed successfully.")
                    if fwupd_needs_reboot:
                        msg += "\nReboot required."
                    self.queue_message(
                        LogLevel.SUCCESS,
                        msg,
                        progress
                    )
                else:
                    self.queue_message(
                        LogLevel.ERROR,
                        _("Failed to install firmware update."),
                        progress
                    )

        if self.on_done:
            self.on_done()

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

    def _update_metadata(self, client) -> None:
        """Refresh fwupd remotes to update metadata from LVFS."""
        self.queue_message(LogLevel.INFO, "Refreshing firmware metadata...", 0)

        try:
            remotes = client.get_remotes(None)
            if not remotes:
                self.queue_message(LogLevel.WARNING, "No fwupd remotes found", 0)
                return

            has_enabled_download_remote = False
            for remote in remotes:
                if self._cancelled:
                    break

                remote_id = remote.get_id()

                try:
                    enabled = remote.get_property('enabled')
                except Exception:   # pylint: disable=broad-except
                    # Fallback for older fwupd versions
                    enabled = False

                if not enabled:
                    logger.debug("Skipping disabled remote: %s", remote_id)
                    continue

                # Skip non-downloadable remotes (LOCAL, DIRECTORY)
                if remote.get_kind() in (Fwupd.RemoteKind.LOCAL, Fwupd.RemoteKind.DIRECTORY):
                    continue

                # Refresh this remote (typically LVFS)
                self.queue_message(
                    LogLevel.INFO,
                    f"Refreshing remote: {remote_id}",
                    0
                )

                try:
                    success = client.refresh_remote(
                        remote,
                        Fwupd.ClientDownloadFlags.NONE,
                        None
                    )

                    if success:
                        self.queue_message(
                            LogLevel.INFO,
                            f"Remote {remote_id} refreshed successfully",
                            0
                        )
                        has_enabled_download_remote = True
                    else:
                        self.queue_message(
                            LogLevel.WARNING,
                            f"Failed to refresh remote: {remote_id}",
                            0
                        )
                except GLib.GError as e:
                    self.queue_message(
                        LogLevel.WARNING,
                        f"Failed to refresh remote: {str(e)}",
                        0
                    )

            if not has_enabled_download_remote:
                self.queue_message(
                    LogLevel.WARNING,
                    "No enabled download remotes found. Check fwupd configuration.",
                    0
                )

        except Exception as e:  # pylint: disable=broad-except
            self.queue_message(
                LogLevel.ERROR,
                f"Failed to refresh metadata: {str(e)}",
                0
            )
