import filecmp
import json
import re
import shutil
from collections import Counter
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, TypeVar

import mobase
from PyQt6.QtCore import QDateTime, QDir, qCritical, qInfo, qWarning

from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns
from ..basic_features.basic_save_game_info import (
    BasicGameSaveGame,
    BasicGameSaveGameInfo,
    format_date,
)
from ..basic_features.utils import is_directory
from ..basic_game import BasicGame


class CyberpunkModDataChecker(BasicModDataChecker):
    def __init__(self):
        super().__init__(
            GlobPatterns(
                move={
                    # archive and ArchiveXL
                    "*.archive": "archive/pc/mod/",
                    "*.xl": "archive/pc/mod/",
                },
                valid=[
                    "archive",
                    # redscript
                    "engine",
                    "r6",
                    "mods",  # RedMod
                    "red4ext",  # red4ext/RED4ext.dll is moved to root in .fix()
                    "bin",  # CET etc. gets handled below
                    "root",  # RootBuilder: hardlink / copy to game root
                ],
            )
        )

    _extra_files_to_move = {
        # Red4ext: only .dll files
        "red4ext/RED4ext.dll": "root/red4ext/",
        "bin/x64/winmm.dll": "root/bin/x64/",
        # CET: all files, folder gets handled in .fix()
        "bin/x64/version.dll": "root/bin/x64/",
        "bin/x64/global.ini": "root/bin/x64/",
        "bin/x64/plugins/cyber_engine_tweaks.asi": "root/bin/x64/plugins/",
    }
    _cet_path = "bin/x64/plugins/cyber_engine_tweaks/"

    def dataLooksValid(
        self, filetree: mobase.IFileTree
    ) -> mobase.ModDataChecker.CheckReturn:
        # fix: single root folders get traversed by Simple Installer
        parent = filetree.parent()
        if parent is not None and self.dataLooksValid(parent) is self.FIXABLE:
            return self.FIXABLE
        status = mobase.ModDataChecker.INVALID
        # Check extra fixes
        if any(filetree.exists(p) for p in self._extra_files_to_move):
            return mobase.ModDataChecker.FIXABLE
        rp = self._regex_patterns
        for entry in filetree:
            name = entry.name().casefold()
            if rp.move_match(name) is not None:
                status = mobase.ModDataChecker.FIXABLE
            elif rp.valid.match(name):
                if status is mobase.ModDataChecker.INVALID:
                    status = mobase.ModDataChecker.VALID
            elif self._valid_redmod(entry):
                # Archive with REDmod folders, not in mods/
                status = mobase.ModDataChecker.FIXABLE
            # Accept any other entry
        return status

    def _valid_redmod(self, filetree: mobase.IFileTree | mobase.FileTreeEntry) -> bool:
        return isinstance(filetree, mobase.IFileTree) and bool(
            filetree and filetree.find("info.json")
        )

    def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree:
        for source, target in self._extra_files_to_move.items():
            if file := filetree.find(source):
                parent = file.parent()
                filetree.move(file, target)
                clear_empty_folder(parent)
        if filetree := super().fix(filetree):
            filetree = self._fix_cet_framework(filetree)
            # REDmod
            for entry in list(filetree):
                if not self._regex_patterns.valid.match(
                    entry.name().casefold()
                ) and self._valid_redmod(entry):
                    filetree.move(entry, "mods/")
        return filetree

    def _fix_cet_framework(self, filetree: mobase.IFileTree) -> mobase.IFileTree:
        """Move CET framework to `root/`, except for `mods`.
        Only CET >= v1.27.0 (Patch 2.01) works with USVFS.

        See: https://github.com/maximegmd/CyberEngineTweaks/pull/877
        """
        if cet_folder := filetree.find(
            self._cet_path, mobase.FileTreeEntry.FileTypes.DIRECTORY
        ):
            assert is_directory(cet_folder)
            root_cet_path = f"root/{self._cet_path}"
            if not cet_folder.exists("mods"):
                parent = cet_folder.parent()
                filetree.move(cet_folder, root_cet_path.rstrip("/\\"))
            else:
                parent = cet_folder
                for entry in list(cet_folder):
                    if entry.name() != "mods":
                        filetree.move(entry, root_cet_path)
            clear_empty_folder(parent)
        return filetree


def clear_empty_folder(filetree: mobase.IFileTree | None):
    if filetree is None:
        return
    while not filetree:
        parent = filetree.parent()
        filetree.detach()
        if parent is None:
            break
        filetree = parent


def time_from_seconds(s: int | float) -> str:
    m, s = divmod(int(s), 60)
    h, m = divmod(int(m), 60)
    return f"{h:02}:{m:02}:{s:02}"


def parse_cyberpunk_save_metadata(save_path: Path, save: mobase.ISaveGame):
    metadata_file = save_path / "metadata.9.json"
    try:
        with open(metadata_file) as file:
            meta_data = json.load(file)["Data"]["metadata"]
            name = meta_data["name"]
            if name != (save_name := save.getName()):
                name = f"{save_name}  ({name})"
            return {
                "Name": name,
                "Date": format_date(meta_data["timestampString"], "hh:mm:ss, d.M.yyyy"),
                "Play Time": time_from_seconds(meta_data["playthroughTime"]),
                "Quest": meta_data["trackedQuestEntry"],
                "Level": int(meta_data["level"]),
                "Street Cred": int(meta_data["streetCred"]),
                "Life Path": meta_data["lifePath"],
                "Difficulty": meta_data["difficulty"],
                "Gender": f'{meta_data["bodyGender"]} / {meta_data["brainGender"]}',
                "Game version": meta_data["buildPatch"],
            }
    except (FileNotFoundError, json.JSONDecodeError):
        return None


class CyberpunkSaveGame(BasicGameSaveGame):
    _name_file = "NamedSave.txt"  # from mod: Named Saves

    def __init__(self, filepath: Path):
        super().__init__(filepath)
        try:  # Custom name from Named Saves
            with open(filepath / self._name_file) as file:
                self._name = file.readline()
        except FileNotFoundError:
            self._name = ""

    def getName(self) -> str:
        return self._name or super().getName()

    def getCreationTime(self) -> QDateTime:
        return QDateTime.fromSecsSinceEpoch(
            int((self._filepath / "sav.dat").stat().st_mtime)
        )


@dataclass
class ModListFile:
    list_path: Path
    mod_search_pattern: str
    reversed_priority: bool = False
    """True: load order priority is reversed compared to MO (first mod has priority)."""


_MOD_TYPE = TypeVar("_MOD_TYPE")


class ModListFileManager(dict[_MOD_TYPE, ModListFile]):
    """Manages modlist files for specific mod types."""

    def __init__(self, organizer: mobase.IOrganizer, **kwargs: ModListFile) -> None:
        self._organizer = organizer
        super().__init__(**kwargs)

    def update_modlist(
        self, mod_type: _MOD_TYPE, mod_files: list[str] | None = None
    ) -> tuple[Path, list[str], list[str]]:
        """
        Updates the mod list file for `mod_type` with the current load order.
        Removes the file if it is not needed.

        Args:
            mod_type: Which modlist to update.
            mod_files (optional): By default mod files in order of `mod_type` priority
                (respecting `self[mod_type].reversed_priority`).

        Returns:
            `(modlist_path, new_mod_list, old_mod_list)`
        """
        if mod_files is None:
            mod_files = list(self.modfile_names(mod_type))
        modlist_path = self.absolute_modlist_path(mod_type)
        old_modlist = (
            modlist_path.read_text().splitlines() if modlist_path.exists() else []
        )
        if not mod_files or len(mod_files) == 1:
            # No load order required
            if old_modlist:
                qInfo(f"Removing {mod_type} load order {modlist_path}")
                modlist_path.unlink()
            return modlist_path, [], old_modlist
        else:
            qInfo(f'Updating {mod_type} load order "{modlist_path}" with: {mod_files}')
            modlist_path.parent.mkdir(parents=True, exist_ok=True)
            modlist_path.write_text("\n".join(mod_files))
            return modlist_path, mod_files, old_modlist

    def absolute_modlist_path(self, mod_type: _MOD_TYPE) -> Path:
        modlist_path = self[mod_type].list_path
        if not modlist_path.is_absolute():
            existing = self._organizer.findFiles(modlist_path.parent, modlist_path.name)
            overwrite = self._organizer.overwritePath()
            modlist_path = (
                Path(existing[0]) if (existing) else Path(overwrite, modlist_path)
            )
        return modlist_path

    def modfile_names(self, mod_type: _MOD_TYPE) -> Iterable[str]:
        """Get all file names from the `mod_type` in MOs load order
        (reversed with `self[mod_type].reversed_priority = True`).
        """
        yield from (file.name for file in self.modfiles(mod_type))

    def modfiles(self, mod_type: _MOD_TYPE) -> Iterable[Path]:
        """Get all files from the `mod_type` in MOs load order
        (reversed with `self[mod_type].reversed_priority = True`).
        """
        mod_search_pattern = self[mod_type].mod_search_pattern
        for mod_path in self.active_mod_paths(self[mod_type].reversed_priority):
            yield from mod_path.glob(mod_search_pattern)

    def active_mod_paths(self, reverse: bool = False) -> Iterable[Path]:
        """Yield the path to active mods in MOs load order."""
        mods_path = Path(self._organizer.modsPath())
        modlist = self._organizer.modList()
        mods_load_order = modlist.allModsByProfilePriority()
        for mod in reversed(mods_load_order) if reverse else mods_load_order:
            if modlist.state(mod) & mobase.ModState.ACTIVE:
                yield mods_path / mod


@dataclass
class PluginDefaultSettings:
    organizer: mobase.IOrganizer
    plugin_name: str
    settings: Mapping[str, mobase.MoVariant]

    def is_plugin_enabled(self) -> bool:
        return self.organizer.isPluginEnabled(self.plugin_name)

    def apply(self) -> bool:
        if not self.is_plugin_enabled():
            return False
        for setting, value in self.settings.items():
            self.organizer.setPluginSetting(self.plugin_name, setting, value)
        return True


class Cyberpunk2077Game(BasicGame):
    Name = "Cyberpunk 2077 Support Plugin"
    Author = "6788, Zash"
    Version = "2.3.1"

    GameName = "Cyberpunk 2077"
    GameShortName = "cyberpunk2077"
    GameBinary = "bin/x64/Cyberpunk2077.exe"
    GameLauncher = "REDprelauncher.exe"
    GameDataPath = "%GAME_PATH%"
    GameDocumentsDirectory = "%USERPROFILE%/AppData/Local/CD Projekt Red/Cyberpunk 2077"
    GameSavesDirectory = "%USERPROFILE%/Saved Games/CD Projekt Red/Cyberpunk 2077"
    GameSaveExtension = "dat"
    GameSteamId = 1091500
    GameGogId = 1423049311
    GameSupportURL = (
        r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/"
        "Game:-Cyberpunk-2077"
    )

    _redmod_binary = Path("tools/redmod/bin/redMod.exe")
    _redmod_log = Path("tools/redmod/bin/REDmodLog.txt")
    _redmod_deploy_path = Path("r6/cache/modded/")
    _redmod_deploy_args = "deploy -reportProgress"
    """Deploy arguments for `redmod.exe`, -modlist=... is added."""

    def init(self, organizer: mobase.IOrganizer) -> bool:
        super().init(organizer)
        self._register_feature(BasicLocalSavegames(self.savesDirectory()))
        self._register_feature(
            BasicGameSaveGameInfo(
                lambda p: Path(p or "", "screenshot.png"),
                parse_cyberpunk_save_metadata,
            )
        )
        self._register_feature(CyberpunkModDataChecker())

        self._modlist_files = ModListFileManager[Literal["archive", "redmod"]](
            organizer,
            archive=ModListFile(
                Path("archive/pc/mod/modlist.txt"),
                "archive/pc/mod/*",
                reversed_priority=bool(self._get_setting("reverse_archive_load_order")),
            ),
            redmod=ModListFile(
                Path(self._redmod_deploy_path, "MO_REDmod_load_order.txt"),
                "mods/*/",
                reversed_priority=bool(self._get_setting("reverse_redmod_load_order")),
            ),
        )
        self._rootbuilder_settings = PluginDefaultSettings(
            organizer,
            "RootBuilder",
            {
                "usvfsmode": False,
                "linkmode": False,
                # Available with RootBuilder v4.5+
                # Currently bugged / incompatible with MO 2.5.2 (Python 3.12)
                # https://github.com/Kezyma/ModOrganizer-Plugins/issues/36
                "linkonlymode": False,
                "backup": True,
                "cache": True,
                "autobuild": True,
                "redirect": False,
                "installer": False,
                "exclusions": "archive,setup_redlauncher.exe,tools",
                "linkextensions": "dll,exe",
            },
        )

        def apply_rootbuilder_settings_once(*args: Any):
            if not self.isActive() or not self._get_setting("configure_RootBuilder"):
                return
            if self._rootbuilder_settings.apply():
                qInfo(f"RootBuilder configured for {self.gameName()}")
                self._set_setting("configure_RootBuilder", False)

        organizer.onUserInterfaceInitialized(apply_rootbuilder_settings_once)
        organizer.onPluginEnabled("RootBuilder", apply_rootbuilder_settings_once)
        organizer.onAboutToRun(self._onAboutToRun)

        organizer.onPluginSettingChanged(self._on_settings_changed)
        return True

    def _on_settings_changed(
        self,
        plugin_name: str,
        setting: str,
        old: mobase.MoVariant,
        new: mobase.MoVariant,
    ):
        if self.name() == plugin_name:
            if setting == "reverse_archive_load_order":
                self._modlist_files["archive"].reversed_priority = bool(new)
            elif setting == "reverse_remod_load_order":
                self._modlist_files["redmod"].reversed_priority = bool(new)

    def iniFiles(self):
        return ["UserSettings.json"]

    def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]:
        ext = self._mappings.savegameExtension.get()
        return [
            CyberpunkSaveGame(path.parent)
            for path in Path(folder.absolutePath()).glob(f"**/*.{ext}")
        ]

    def settings(self) -> list[mobase.PluginSetting]:
        return [
            mobase.PluginSetting(
                "skipStartScreen",
                (
                    'Skips the "Breaching..." start screen on game launch'
                    " (can also skip loading of GOG rewards)"
                ),
                False,
            ),
            mobase.PluginSetting(
                "enforce_archive_load_order",
                (
                    "Enforce the current load order via"
                    " <code>archive/pc/mod/modlist.txt</code>"
                ),
                False,
            ),
            mobase.PluginSetting(
                "reverse_archive_load_order",
                (
                    "Reverse MOs load order in"
                    " <code>archive/pc/mod/modlist.txt</code>"
                    " (first loaded mod wins = last one / highest prio in MO)"
                ),
                False,
            ),
            mobase.PluginSetting(
                "enforce_redmod_load_order",
                "Enforce the current load order on redmod deployment",
                True,
            ),
            mobase.PluginSetting(
                "reverse_redmod_load_order",
                (
                    "Reverse MOs load order on redmod deployment"
                    " (first loaded mod wins = last one / highest prio in MO)"
                ),
                False,
            ),
            mobase.PluginSetting(
                "auto_deploy_redmod",
                "Deploy redmod before game launch if necessary",
                True,
            ),
            mobase.PluginSetting(
                "clear_cache_after_game_update",
                (
                    'Clears "overwrite/r6/cache/*" if the original game files changed'
                    " (after update)"
                ),
                True,
            ),
            mobase.PluginSetting(
                "configure_RootBuilder",
                "Configures RootBuilder for Cyberpunk if installed and enabled",
                True,
            ),
        ]

    def _get_setting(self, key: str) -> mobase.MoVariant:
        return self._organizer.pluginSetting(self.name(), key)

    def _set_setting(self, key: str, value: mobase.MoVariant):
        self._organizer.setPluginSetting(self.name(), key, value)

    def executables(self) -> list[mobase.ExecutableInfo]:
        game_name = self.gameName()
        game_dir = self.gameDirectory()
        bin_path = game_dir.absoluteFilePath(self.binaryName())
        skip_start_screen = (
            " -skipStartScreen" if self._get_setting("skipStartScreen") else ""
        )
        return [
            # Default, runs REDmod deploy if necessary
            mobase.ExecutableInfo(
                f"{game_name}",
                bin_path,
            ).withArgument(f"--launcher-skip -modded{skip_start_screen}"),
            # Start game without REDmod
            mobase.ExecutableInfo(
                f"{game_name} - skip REDmod deploy",
                bin_path,
            ).withArgument(f"--launcher-skip {skip_start_screen}"),
            # Deploy REDmods only
            mobase.ExecutableInfo(
                "Manually deploy REDmod",
                self._get_redmod_binary(),
            ).withArgument("deploy -reportProgress -force %modlist%"),
            # Launcher
            mobase.ExecutableInfo(
                "REDprelauncher",
                game_dir.absoluteFilePath(self.getLauncherName()),
            ).withArgument(f"{skip_start_screen}"),
        ]

    def _get_redmod_binary(self) -> Path:
        """Absolute path to redmod binary"""
        return Path(self.gameDirectory().absolutePath(), self._redmod_binary)

    def _onAboutToRun(self, app_path_str: str, wd: QDir, args: str) -> bool:
        if not self.isActive():
            return True
        app_path = Path(app_path_str)
        if app_path == self._get_redmod_binary():
            if m := re.search(r"%modlist%", args, re.I):
                # Manual deployment: replace %modlist% variable
                (
                    modlist_path,
                    modlist,
                    _,
                ) = self._modlist_files.update_modlist("redmod")
                modlist_param = f'-modlist="{modlist_path}"' if modlist else ""
                args = f"{args[:m.start()]}{modlist_param}{args[m.end():]}"
                qInfo(f"Manual modlist deployment: replacing {m[0]}, new args = {args}")
                self._check_redmod_result(
                    self._organizer.waitForApplication(
                        self._organizer.startApplication(app_path_str, [args], wd),
                        False,
                    )
                )
                return False  # redmod with new args started
            return True  # No recursive redmod call
        if (
            self._get_setting("auto_deploy_redmod")
            and app_path == Path(self.gameDirectory().absolutePath(), self.binaryName())
            and "-modded" in args
            and not self._check_redmod_result(self._deploy_redmod())
        ):
            qWarning("Aborting game launch.")
            return False  # Auto deploy failed
        self._map_cache_files()
        if self._get_setting("enforce_archive_load_order"):
            self._modlist_files.update_modlist("archive")
        return True

    def _check_redmod_result(self, result: tuple[bool, int]) -> bool:
        if result == (True, 0):
            return True
        if result[1] < 0:
            qWarning(f"REDmod deployment aborted (exit code {result[1]}).")
        else:
            qCritical(
                f"REDmod deployment failed with exit code {result[1]} !"
                f" Check {Path('GAME_FOLDER/', self._redmod_log)}"
            )
        return False

    def _deploy_redmod(self) -> tuple[bool, int]:
        """Deploys redmod. Clears deployed files if no redmods are active.
        Recreates deployed files to force load order when necessary.

        Returns:
            (success?, exit code)
        """
        # Add REDmod load order if none is specified
        redmod_list = list(self._modlist_files.modfile_names("redmod"))
        if not redmod_list:
            qInfo("Cleaning up redmod deployed files")
            self._clean_deployed_redmod()
            return True, 0
        args = self._redmod_deploy_args
        if self._get_setting("enforce_redmod_load_order"):
            modlist_path, _, old_redmods = self._modlist_files.update_modlist(
                "redmod", redmod_list
            )
            if (
                Counter(redmod_list) == Counter(old_redmods)
                and not redmod_list == old_redmods
            ):
                # Only load order changed: recreate redmod deploys
                # Fix for redmod not detecting change of load order.
                # Faster than -force https://github.com/E1337Kat/cyberpunk2077_ext_redux/issues/297  # noqa: E501
                qInfo("Redmod order changed, recreate deployed files")
                self._clean_deployed_redmod(modlist_path)
            qInfo(f"Deploying redmod with modlist: {modlist_path}")
            args += f' -modlist="{modlist_path}"'
        else:
            qInfo("Deploying redmod")
        redmod_binary = self._get_redmod_binary()
        return self._organizer.waitForApplication(
            self._organizer.startApplication(
                redmod_binary, [args], redmod_binary.parent
            ),
            False,
        )

    def _clean_deployed_redmod(self, modlist_path: Path | None = None):
        """Delete all files from `_redmod_deploy_path` except for `modlist_path`."""
        for file in self._organizer.findFiles(self._redmod_deploy_path, "*"):
            file_path = Path(file)
            if modlist_path is None or file_path != modlist_path:
                file_path.unlink()

    def _map_cache_files(self):
        """
        Copy cache files (`final.redscript` etc.) to overwrite to catch
        overwritten game files.
        """
        data_path = Path(self.dataDirectory().absolutePath())
        overwrite_path = Path(self._organizer.overwritePath())
        cache_files = [
            file.relative_to(data_path) for file in data_path.glob("r6/cache/*")
        ]
        if self._get_setting("clear_cache_after_game_update") and any(
            self._is_cache_file_updated(file, data_path) for file in cache_files
        ):
            qInfo('Updated game files detected, clearing "overwrite/r6/cache/*"')
            shutil.rmtree(overwrite_path / "r6/cache")
            new_cache_files = cache_files
        else:
            new_cache_files = list(self._unmapped_cache_files(data_path))
        for file in new_cache_files:
            qInfo(f'Copying "{file}" to overwrite (to catch file overwrites)')
            dst = overwrite_path / file
            dst.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(data_path / file, dst)

    def _is_cache_file_updated(self, file: Path, data_path: Path) -> bool:
        """Checks if a cache file is updated (in game dir).

        Args:
            file: Relative to data dir.
        """
        game_file = data_path.absolute() / file
        mapped_files = self._organizer.findFiles(file.parent, file.name)
        return bool(
            mapped_files
            and (mapped_file := mapped_files[0])
            and not (
                game_file.samefile(mapped_file)
                or filecmp.cmp(game_file, mapped_file)
                or (  # different backup file
                    (
                        backup_files := self._organizer.findFiles(
                            file.parent, f"{file.name}.bk"
                        )
                    )
                    and filecmp.cmp(game_file, backup_files[0])
                )
            )
        )

    def _unmapped_cache_files(self, data_path: Path) -> Iterable[Path]:
        """Yields unmapped cache files relative to `data_path`."""
        for file in self._organizer.findFiles("r6/cache", "*"):
            try:
                yield Path(file).absolute().relative_to(data_path)
            except ValueError:
                continue
