# manager.py
#
# Copyright 2025 mirkobrombin <brombin94@gmail.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, in version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
import os
import shlex
import shutil
from gettext import gettext as _
from typing import Optional

import icoextract  # type: ignore [import-untyped]

from bottles.backend.params import APP_ID

from bottles.backend.globals import Paths
from bottles.backend.logger import Logger
from bottles.backend.models.config import BottleConfig
from bottles.backend.models.result import Result
from bottles.backend.state import SignalManager, Signals
from bottles.backend.utils.generic import get_mime
from bottles.backend.utils.imagemagick import ImageMagickUtils

from gi.repository import GLib, Gio, Xdp

portal = Xdp.Portal()

logging = Logger()


class ManagerUtils:
    """
    This class contains methods (tools, utilities) that are not
    directly related to the Manager.
    """

    @staticmethod
    def open_filemanager(
        config: Optional[BottleConfig] = None,
        path_type: str = "bottle",
        component: str = "",
        custom_path: str = "",
    ):
        logging.info("Opening the file manager in the path …")
        path = ""

        if path_type == "bottle" and config is None:
            raise NotImplementedError("bottle type need a valid Config")

        if path_type == "bottle":
            bottle_path = ManagerUtils.get_bottle_path(config)
            if config.Environment == "Steam":
                bottle_path = config.Path
            path = f"{bottle_path}/drive_c"
        elif component != "":
            if path_type in ["runner", "runner:proton"]:
                path = ManagerUtils.get_runner_path(component)
            elif path_type == "dxvk":
                path = ManagerUtils.get_dxvk_path(component)
            elif path_type == "vkd3d":
                path = ManagerUtils.get_vkd3d_path(component)
            elif path_type == "nvapi":
                path = ManagerUtils.get_nvapi_path(component)
            elif path_type == "latencyflex":
                path = ManagerUtils.get_latencyflex_path(component)
            elif path_type == "runtime":
                path = Paths.runtimes
            elif path_type == "winebridge":
                path = Paths.winebridge

        if path_type == "custom" and custom_path != "":
            path = custom_path

        path = f"file://{path}"
        SignalManager.send(Signals.GShowUri, Result(data=path))

    @staticmethod
    def get_bottle_path(config: BottleConfig) -> str:
        if config.Environment == "Steam":
            return os.path.join(Paths.steam, config.CompatData)

        if config.Custom_Path:
            return config.Path

        return os.path.join(Paths.bottles, config.Path)

    @staticmethod
    def resolve_portal_path(path: str) -> str:
        """
        Resolve a document portal path (/run/user/<uid>/doc/<id>/...) to its real
        host path through the Documents portal. These paths are transient: the
        document id changes between sessions, so storing one (e.g. a program
        shortcut on a read-only filesystem) breaks the entry on the next launch.
        Returns the original path unchanged on any failure.
        """
        if not path or "/run/user/" not in path or "/doc/" not in path:
            return path

        def _to_str(value) -> str:
            if isinstance(value, bytes):
                raw = value
            elif isinstance(value, (list, tuple)):
                raw = bytes(value)
            else:
                return str(value)
            return raw.rstrip(b"\x00").decode("utf-8", "replace")

        try:
            documents = "org.freedesktop.portal.Documents"
            proxy = Gio.DBusProxy.new_sync(
                Gio.bus_get_sync(Gio.BusType.SESSION, None),
                Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES
                | Gio.DBusProxyFlags.DO_NOT_CONNECT_SIGNALS,
                None,
                documents,
                "/org/freedesktop/portal/documents",
                documents,
                None,
            )

            mount = _to_str(
                proxy.call_sync(
                    "GetMountPoint", None, Gio.DBusCallFlags.NONE, -1, None
                ).unpack()[0]
            )
            if not mount or not path.startswith(mount + "/"):
                return path

            doc_id, _, remainder = path[len(mount) + 1 :].partition("/")

            hosts = proxy.call_sync(
                "GetHostPaths",
                GLib.Variant("(as)", ([doc_id],)),
                Gio.DBusCallFlags.NONE,
                -1,
                None,
            ).unpack()[0]
            if doc_id not in hosts:
                return path

            host = _to_str(hosts[doc_id])
            if remainder and os.path.basename(host) != remainder.rstrip("/"):
                resolved = os.path.join(host, remainder)
            else:
                resolved = host

            return resolved if os.path.exists(resolved) else path
        except Exception as e:
            logging.warning(f"Could not resolve document portal path: {e}")
            return path

    @staticmethod
    def get_runner_path(runner: str) -> str:
        if runner.startswith("sys-"):
            return runner
        return f"{Paths.runners}/{runner}"

    @staticmethod
    def get_dxvk_path(dxvk: str) -> str:
        return f"{Paths.dxvk}/{dxvk}"

    @staticmethod
    def get_vkd3d_path(vkd3d: str) -> str:
        return f"{Paths.vkd3d}/{vkd3d}"

    @staticmethod
    def get_nvapi_path(nvapi: str) -> str:
        return f"{Paths.nvapi}/{nvapi}"

    @staticmethod
    def get_latencyflex_path(latencyflex: str) -> str:
        return f"{Paths.latencyflex}/{latencyflex}"

    @staticmethod
    def get_temp_path(dest: str) -> str:
        return f"{Paths.temp}/{dest}"

    @staticmethod
    def get_template_path(template: str) -> str:
        return f"{Paths.templates}/{template}"

    @staticmethod
    def move_file_to_bottle(
        file_path: str, config: BottleConfig, fn_update: callable = None
    ) -> str | bool:
        logging.info(f"Adding file {file_path} to the bottle …")
        bottle_path = ManagerUtils.get_bottle_path(config)

        if not os.path.exists(f"{bottle_path}/storage"):
            """
            If the storage folder does not exist for the bottle,
            create it before moving the file.
            """
            os.makedirs(f"{bottle_path}/storage")

        file_name = os.path.basename(file_path)
        file_size = os.path.getsize(file_path)
        file_new_path = f"{bottle_path}/storage/{file_name}"

        logging.info(f"Copying file {file_path} to the bottle …")
        try:
            if file_size == 0:
                with open(file_new_path, "wb"):
                    pass
                if fn_update:
                    fn_update(1)
                return file_new_path

            chunk_size = 64 * 1024
            bytes_copied = 0
            with open(file_path, "rb") as f_in:
                with open(file_new_path, "wb") as f_out:
                    while True:
                        chunk = f_in.read(chunk_size)
                        if not chunk:
                            break
                        f_out.write(chunk)
                        bytes_copied += len(chunk)

                        if fn_update:
                            fn_update(bytes_copied / file_size)

                    if fn_update:
                        fn_update(1)
            return file_new_path
        except (OSError, IOError):
            logging.error(f"Could not copy file {file_path} to the bottle.")
            return False

    @staticmethod
    def get_exe_parent_dir(config, executable_path):
        """Get parent directory of the executable."""
        if "\\" in executable_path:
            p = "\\".join(executable_path.split("\\")[:-1])
            p = p.replace("C:\\", "\\drive_c\\").replace("\\", "/")
            return ManagerUtils.get_bottle_path(config) + p
        return os.path.dirname(executable_path)

    @staticmethod
    def extract_icon(config: BottleConfig, program_name: str, program_path: str) -> str:
        from bottles.backend.wine.winepath import WinePath

        winepath = WinePath(config)
        icon = "com.usebottles.bottles-program"
        bottle_icons_path = os.path.join(ManagerUtils.get_bottle_path(config), "icons")

        try:
            if winepath.is_windows(program_path):
                program_path = winepath.to_unix(program_path)

            ico_dest_temp = os.path.join(bottle_icons_path, f"_{program_name}.png")
            ico_dest = os.path.join(bottle_icons_path, f"{program_name}.png")
            ico = icoextract.IconExtractor(program_path)
            os.makedirs(bottle_icons_path, exist_ok=True)

            if os.path.exists(ico_dest_temp):
                os.remove(ico_dest_temp)

            if os.path.exists(ico_dest):
                os.remove(ico_dest)

            ico.export_icon(ico_dest_temp)
            # Some ICO files are incorrectly identified as TARGA
            # See https://bugs.astron.com/view.php?id=723
            if get_mime(ico_dest_temp) in ["image/vnd.microsoft.icon", "image/x-tga"]:
                if not ico_dest_temp.endswith(".ico"):
                    shutil.move(ico_dest_temp, f"{ico_dest_temp}.ico")
                    ico_dest_temp = f"{ico_dest_temp}.ico"
                im = ImageMagickUtils(ico_dest_temp)
                im.convert(ico_dest)
                icon = ico_dest
            else:
                shutil.move(ico_dest_temp, ico_dest)
                icon = ico_dest
        except:  # TODO: handle those
            pass

        return icon

    @staticmethod
    def create_desktop_entry(
        config,
        program: dict,
        skip_icon: bool = False,
        custom_icon: str = "",
    ):
        icon = "com.usebottles.bottles-program"

        if not skip_icon and not custom_icon:
            icon = ManagerUtils.extract_icon(
                config, program.get("name"), program.get("path")
            )
        elif custom_icon:
            icon = custom_icon

        def create_manual_fallback(icon_path, exec_cmd):
            """Create desktop entry manually when portal is unavailable."""
            safe_name = "".join(
                [c for c in program.get("name") if c.isalnum() or c in ("-", "_")]
            )
            safe_bottle = "".join(
                [c for c in config.get("Name") if c.isalnum() or c in ("-", "_")]
            )
            filename = f"bottles-{safe_bottle}-{safe_name}.desktop"
            content = (
                f"[Desktop Entry]\n"
                f"Exec={exec_cmd}\n"
                f"Type=Application\n"
                f"Terminal=false\n"
                f"Categories=Game;\n"
                f"Comment=Launch {program.get('name')} using Bottles.\n"
                f"StartupWMClass={program.get('executable').lower()}\n"
                f"Name={program.get('name')}\n"
                f"Icon={icon_path}\n"
            )

            # Write to application menu
            apps_dir = os.path.expanduser("~/.local/share/applications")
            os.makedirs(apps_dir, exist_ok=True)
            apps_path = os.path.join(apps_dir, filename)
            try:
                with open(apps_path, "w") as f:
                    f.write(content)
                logging.info(f"Desktop entry created at {apps_path}")
            except Exception as e:
                logging.error(f"Failed to write desktop entry to applications: {e}")

            # Write to desktop surface
            desktop_dir = GLib.get_user_special_dir(
                GLib.UserDirectory.DIRECTORY_DESKTOP
            )
            if desktop_dir:
                desktop_path = os.path.join(desktop_dir, filename)
                try:
                    with open(desktop_path, "w") as f:
                        f.write(content)
                    # Make executable so KDE/GNOME will run it
                    os.chmod(desktop_path, 0o755)
                    logging.info(f"Desktop shortcut created at {desktop_path}")
                except Exception as e:
                    logging.error(f"Failed to write desktop shortcut: {e}")

            SignalManager.send(Signals.DesktopEntryCreated)

        def prepare_install_cb(self, result):
            exec_cmd = "bottles-cli run -p {} -b {} -- %u".format(
                shlex.quote(program.get("name")), shlex.quote(config.get("Name"))
            )

            # Handle portal preparation failure (e.g., KDE's broken implementation)
            try:
                ret = portal.dynamic_launcher_prepare_install_finish(result)
                if ret is None:
                    raise GLib.Error("Portal request was rejected or cancelled")
            except GLib.Error as e:
                logging.warning(
                    f"Dynamic Launcher portal preparation failed: {e}. "
                    "Falling back to manual creation."
                )
                create_manual_fallback(icon, exec_cmd)
                return

            launcher_id = f"{config.get('Name')}.{program.get('name')}"
            sum_type = GLib.ChecksumType.SHA1
            try:
                portal.dynamic_launcher_install(
                    ret["token"],
                    "{}.App_{}.desktop".format(
                        APP_ID,
                        GLib.compute_checksum_for_string(sum_type, launcher_id, -1),
                    ),
                    """[Desktop Entry]
                    Exec={}
                    Type=Application
                    Terminal=false
                    Categories=Game;
                    Comment=Launch {} using Bottles.
                    StartupWMClass={}""".format(
                        exec_cmd, program.get("name"), program.get("executable").lower()
                    ),
                )
                SignalManager.send(Signals.DesktopEntryCreated)
            except GLib.Error as e:
                logging.warning(
                    f"Dynamic Launcher portal install failed: {e}. "
                    "Falling back to manual creation."
                )
                create_manual_fallback(icon, exec_cmd)

        if icon != "com.usebottles.bottles-program" and not os.path.exists(icon):
            logging.warning(f"Icon file not found: {icon}. Falling back to default.")
            icon = "com.usebottles.bottles-program"

        if icon == "com.usebottles.bottles-program":
            icon += ".svg"
            _icon = Gio.File.new_for_uri(
                f"resource:/com/usebottles/bottles/icons/scalable/apps/{icon}"
            )
        else:
            _icon = Gio.File.new_for_path(icon)
        icon_v = Gio.BytesIcon.new(_icon.load_bytes()[0]).serialize()
        portal.dynamic_launcher_prepare_install(
            None,
            program.get("name"),
            icon_v,
            Xdp.LauncherType.APPLICATION,
            None,
            True,
            False,
            None,
            prepare_install_cb,
        )

    @staticmethod
    def browse_wineprefix(wineprefix: dict):
        """Presents a dialog to browse the wineprefix."""
        ManagerUtils.open_filemanager(
            path_type="custom", custom_path=wineprefix.get("Path")
        )

    @staticmethod
    def get_languages(
        from_name=None,
        from_locale=None,
        from_index=None,
        get_index=False,
        get_locales=False,
    ):
        locales = [
            "sys",
            "bg_BG",
            "cs_CZ",
            "da_DK",
            "de_DE",
            "el_GR",
            "en_US",
            "es_ES",
            "et_EE",
            "fi_FI",
            "fr_FR",
            "hr_HR",
            "hu_HU",
            "it_IT",
            "lt_LT",
            "lv_LV",
            "nl_NL",
            "no_NO",
            "pl_PL",
            "pt_PT",
            "ro_RO",
            "ru_RU",
            "sk_SK",
            "sl_SI",
            "sv_SE",
            "tr_TR",
            "zh_CN",
            "ja_JP",
            "zh_TW",
            "ko_KR",
        ]
        names = [
            _("System"),
            _("Bulgarian"),
            _("Czech"),
            _("Danish"),
            _("German"),
            _("Greek"),
            _("English"),
            _("Spanish"),
            _("Estonian"),
            _("Finnish"),
            _("French"),
            _("Croatian"),
            _("Hungarian"),
            _("Italian"),
            _("Lithuanian"),
            _("Latvian"),
            _("Dutch"),
            _("Norwegian"),
            _("Polish"),
            _("Portuguese"),
            _("Romanian"),
            _("Russian"),
            _("Slovak"),
            _("Slovenian"),
            _("Swedish"),
            _("Turkish"),
            _("Chinese"),
            _("Japanese"),
            _("Taiwanese"),
            _("Korean"),
        ]

        if from_name and from_locale:
            raise ValueError("Cannot pass both from_name, from_locale and from_index.")

        if from_name:
            if from_name not in names:
                raise ValueError("Given name not in list.")
            i = names.index(from_name)
            if get_index:
                return i
            return from_name, locales[i]

        if from_locale:
            if from_locale not in locales:
                raise ValueError("Given locale not in list.")
            i = locales.index(from_locale)
            if get_index:
                return i
            return from_locale, names[i]

        if isinstance(from_index, int):
            if from_index not in range(0, len(locales)):
                raise ValueError("Given index not in range.")
            return locales[from_index], names[from_index]

        if get_locales:
            return locales

        return names
