"""Utility module to deal with Proton and umu"""

import json
import os
import re
from collections.abc import Generator
from gettext import gettext as _
from typing import TYPE_CHECKING

from lutris import settings
from lutris.exceptions import MissingExecutableError
from lutris.monitored_command import RUNNING_COMMANDS
from lutris.util import cache_single, system
from lutris.util.steam.config import get_steamapps_dirs
from lutris.util.strings import get_natural_sort_key

if TYPE_CHECKING:
    from lutris.game import Game

DEFAULT_GAMEID = "umu-default"

# Matches a umu log line such as
#   [umu.umu_proton:348] INFO: Downloading GE-Proton10-34.tar.gz...
_UMU_LOG_LINE_RE = re.compile(r"\[umu\.[\w_]+:\d+\]\s+(?:INFO|WARNING|ERROR):\s+(.+)")


class UmuLaunchStatusParser:
    """Scans umu's stdout for runtime setup progress and pushes it onto a
    Game's launch_status. The game appears frozen on 'Launching' while umu
    fetches GE-Proton / the sniper runtime; this surfaces the current step
    to the UI. MonitoredCommand delivers raw read chunks rather than whole
    lines, so the parser buffers partial lines across calls."""

    def __init__(self, game: "Game"):
        self.game = game
        self._buffer = ""

    def __call__(self, line: str) -> None:
        # MonitoredCommand delivers raw read chunks, not individual lines,
        # so this may contain zero, one, or many newlines.
        data = self._buffer + line
        lines = data.split("\n")
        self._buffer = lines[-1]
        for complete_line in lines[:-1]:
            self._process_line(complete_line)

    def _process_line(self, line: str) -> None:
        match = _UMU_LOG_LINE_RE.search(line)
        if not match:
            return
        message = match.group(1).strip()
        # "Using <Proton>" is logged at the end of umu's setup phase, right
        # before it hands off to Proton — clear any pending status then.
        if message.startswith("Using "):
            self.game.launch_status = ""
            return
        # Only the user-meaningful progress messages — skip lock acquisition,
        # version chatter, env dumps, etc.
        if message.startswith(("Downloading ", "Extracting ", "Checking updates")):
            self.game.launch_status = message


def is_proton_version(version: str | None) -> bool:
    """True if the version indicated specifies a Proton version of Wine; these
    require special handling. This does not recognize the special 'ge-proton' version
    found in configurations; this must be specially  handled elsewhere."""
    return version in get_proton_versions()


def is_umu_path(path: str | None) -> bool:
    """True if the path given actually runs Umu; this will run Proton-Wine in turn,
    but can be directed to particular Proton implementation by setting the env-var
    PROTONPATH, but if this is omitted it will default to the latest Proton it
    downloads."""
    return bool(path and (path.endswith("/umu_run.py") or path.endswith("/umu-run")))


def is_proton_path(wine_path: str | None) -> bool:
    """True if the path given actually runs Umu; this will run Proton-Wine in turn,
    but can be directed to particular Proton implementation by setting the env-var
    PROTONPATH, but if this is omitted it will default to the latest Proton it
    downloads.

    This function may be given the wine root directory or a file within such as
    the wine executable and will return true for either."""
    if not wine_path:
        return True
    for candidate_wine_path in get_proton_versions().values():
        if system.path_contains(candidate_wine_path, wine_path):
            return True
    return False


@cache_single
def get_umu_path() -> str:
    """Returns the path to the Umu launch script, which can be run to execute
    a Proton version. It can supply a default Proton, but if the env-var PROTONPATH
    is set this will direct it to a specific Proton installation.

    The path that this returns will be considered an Umu path by is_umu_path().

    If this script can't be found this will raise MissingExecutableError."""

    # 'umu-run' is normally the entry point, and is a zipapp full of Python code. But
    # We used to ship a directory of loose files, and the entry point then is 'umu_run.py'
    entry_points = ["umu-run", "umu_run.py"]

    custom_path = settings.read_setting("umu_path")
    if custom_path:
        for entry_point in entry_points:
            entry_path = os.path.join(custom_path, entry_point)
            if system.path_exists(entry_path):
                return entry_path

    # We only use 'umu-run' when searching the path since that's the command
    # line entry point.
    if system.can_find_executable("umu-run"):
        return system.find_required_executable("umu-run")
    path_candidates = (
        "/app/share",  # prioritize flatpak due to non-rolling release distros
        "/usr/local/share",
        "/usr/share",
        "/opt",
        settings.RUNTIME_DIR,
    )
    for path_candidate in path_candidates:
        for entry_point in entry_points:
            entry_path = os.path.join(path_candidate, "umu", entry_point)
            if system.path_exists(entry_path):
                return entry_path
    raise MissingExecutableError("Install umu to use Proton")


def get_proton_wine_path(version: str) -> str:
    """Get the wine path for the specified proton version"""
    wine_path = get_proton_versions().get(version)
    if wine_path:
        wine_path_dist = os.path.join(wine_path, "dist/bin/wine")
        if os.path.exists(wine_path_dist):
            return wine_path_dist

        wine_path_files = os.path.join(wine_path, "files/bin/wine")
        if os.path.exists(wine_path_files):
            return wine_path_files

    raise MissingExecutableError(_("Proton version '%s' is missing its wine executable and can't be used.") % version)


def get_proton_path_by_path(wine_path: str) -> str:
    # Split the path to get the directory containing the file
    directory_path = os.path.dirname(wine_path)

    # Navigate up two levels to reach the version directory
    version_directory = os.path.dirname(os.path.dirname(directory_path))

    return version_directory


def list_proton_versions() -> list[str]:
    """Return the list of Proton versions installed in Steam, in sorted order."""
    return sorted(get_proton_versions().keys(), key=get_natural_sort_key, reverse=True)


@cache_single
def get_proton_versions() -> dict[str, str]:
    """Return the dict of Proton versions installed in Steam, which is cached.
    The keys are the versions, and the values are the paths to those versions,
    which are their wine-paths."""
    try:
        # We can only use a Proton install via the Umu launcher script.
        _ = get_umu_path()
    except MissingExecutableError:
        return {}

    versions = dict()
    for proton_path in _iter_proton_locations():
        if os.path.isdir(proton_path):
            for version in os.listdir(proton_path):
                if version not in versions:
                    wine_path = os.path.join(proton_path, version)
                    if os.path.isfile(os.path.join(wine_path, "proton")):
                        versions[version] = wine_path
    return versions


def _iter_proton_locations() -> Generator[str, None, None]:
    """Iterate through all potential Proton locations"""
    yield settings.WINE_DIR

    try:
        steamapp_dirs = get_steamapps_dirs()
    except:
        return  # in case of corrupt or unreadable Steam configuration files!

    for path in [os.path.join(p, "common") for p in steamapp_dirs]:
        yield path

    for path in [os.path.join(p, "") for p in steamapp_dirs]:
        yield path


def update_proton_env(wine_path: str, env: dict[str, str], game_id: str = DEFAULT_GAMEID, umu_log: str = "") -> None:
    """Add various env-vars to an 'env' dict for use by Proton and Umu; this won't replace env-vars, so they can still
    be pre-set before we get here. This sets the PROTONPATH so the Umu launcher will know what Proton to use,
    and the WINEARCH to win64, which is what we expect Proton to always be. GAMEID is required, but we'll use a default
    GAMEID if you don't pass one in.

    This also propagates LC_ALL to HOST_LC_ALL, if LC_ALL is set."""

    if "PROTONPATH" not in env:
        if is_umu_path(wine_path):
            env["PROTONPATH"] = "GE-Proton"
        else:
            env["PROTONPATH"] = get_proton_path_by_path(wine_path)

    if "GAMEID" not in env:
        env["GAMEID"] = game_id

    if "UMU_LOG" not in env and umu_log:
        env["UMU_LOG"] = umu_log

    if "WINEARCH" not in env:
        env["WINEARCH"] = "win64"

    if "PROTON_VERB" not in env:
        # Proton fixes are only applied with waitforexitandrun, so we want to use that
        # but only if we're the first process start - the next concurrent process should
        # use run so it does not wait.
        prefix = env.get("WINEPREFIX")
        if prefix and prefix in (c.env.get("WINEPREFIX") for c in RUNNING_COMMANDS):
            env["PROTON_VERB"] = "runinprefix"  # do *not* re-initialize a running prefix!
        else:
            env["PROTON_VERB"] = "waitforexitandrun"  # does full initialization with proton-fixes

    locale = env.get("LC_ALL")
    host_locale = env.get("HOST_LC_ALL")
    if locale and not host_locale:
        env["HOST_LC_ALL"] = locale


def get_game_id(game, env) -> str:
    if not game:
        return DEFAULT_GAMEID

    game_id = env.get("UMU_ID")
    if game_id:
        return game_id

    games_path = os.path.join(settings.RUNTIME_DIR, "umu-games/umu-games.json")

    if not os.path.exists(games_path):
        return DEFAULT_GAMEID

    with open(games_path, "r", encoding="utf-8") as games_file:
        umu_games = json.load(games_file)

    for umu_game in umu_games:
        if (
            umu_game["store"]
            and (
                umu_game["store"] == game.service or (umu_game["store"] == "humble" and game.service == "humblebundle")
            )
            and umu_game["appid"] == game.appid
        ):
            return umu_game["umu_id"]
    return DEFAULT_GAMEID
