"""
Profile Manager

A Minecraft profile manager that completely support official Minecraft Launcher.
"""
import copy
from dataclasses import dataclass
from pathlib import Path

from PySide6.QtCore import QObject, Signal
from datetime import datetime

from core_lib.common.exception import ProfileException, ProfileLoadException, ProfileSaveException
from core_lib.game.profile import read_profile_file, is_profiles_valid, \
    get_profile_sample, create_or_save_profile_file, list_profiles as list_profiles_raw, convert_profile_data_to_object, \
    is_profile_exists, create_profile as create_profile_raw, update_profile as update_profile_raw, \
    get_current_profile_id, set_current_profile_id, remove_profile, duplicate_profile
from core_lib.game.version_info import LATEST_VERSION_ALIASES
from manager.widgets import AskForRequest

DEFAuLT_PROFILE_RELATIVE_PATH = Path("launcher_profiles.json")

@dataclass(frozen=True)
class ProfileSummary:
    display_name: str
    id: str
    name: str
    version_id: str
    version_type: str
    icon: str
    last_used: datetime | None


@dataclass(frozen=True)
class ProfileDetail(ProfileSummary):
    game_dir: Path | None
    java_args: str
    resolution: tuple[int, int] | None
    created: datetime


@dataclass(frozen=True)
class CreateProfileRequest:
    name: str
    version_id: str
    version_type: str = "custom"
    game_dir: Path | None = None
    java_args: str = ""
    icon: str = ""
    resolution: tuple[int, int] | None = None

class _Profile_UnsetType:
    pass

_UNSET = _Profile_UnsetType()

@dataclass(frozen=True)
class UpdateProfileRequest:
    name: str | None = None
    version_id: str | None = None
    version_type: str | None = None
    game_dir: Path | None | _Profile_UnsetType = _UNSET
    java_args: str | None | _Profile_UnsetType = _UNSET
    icon: str | None = None
    resolution: tuple[int, int] | None | _Profile_UnsetType = _UNSET

@dataclass(frozen=True)
class LaunchProfile:
    display_name: str
    name: str
    profile_id: str
    version_id: str
    version_type: str
    game_dir: Path
    java_args: str
    resolution: tuple[int, int] | None

@dataclass(frozen=True)
class ValidationResult:
    errors: dict[str, str]

    @property
    def is_valid(self) -> bool:
        return not self.errors

class ProfileManager(QObject):
    profile_load = Signal()
    profiles_changed = Signal()
    profile_created = Signal(str)
    profile_updated = Signal(str)
    profile_removed = Signal(str)
    current_profile_changed = Signal(object)  # str | None
    error_occurred = Signal(str, str)  # error_code, message
    replace_relative_path = Signal(str)

    def __init__(self, app, widget_manager):
        QObject.__init__(self, app)
        self.app = app
        self.widget_manager = widget_manager
        self.profiles = {}


        self._path: Path | None = None
        self._is_modified = False

        self.profile_load.connect(
            self.load,
        )

        self.load()

    @property
    def default_profile_path(self):
        return Path(self.app.work_dir, DEFAuLT_PROFILE_RELATIVE_PATH)

    # File lifetime
    def create_profiles(self, path: str | Path | None=None) -> bool:
        path = Path(path) if path is not None else self.default_profile_path

        try:
            new_profiles = get_profile_sample()
            create_or_save_profile_file(new_profiles, path, create_backup=False)
            return True
        except ProfileSaveException as e:
            self.widget_manager.signal.show_error_message.emit(
                "Unable to create profile file: {}".format(e.user_message),
            )
            return False
        except Exception as e:
            self.widget_manager.signal.show_error_message.emit(
                "Unable to create profile file. An unexpected error occurred: {}".format(e),
            )
            return False

    def load(self, path: str | Path | None=None, return_value: bool=False) -> tuple[bool, None | dict]:
        """
        Load profiles
        :param path: If provided, this path becomes the current profile path after loading succeeds.
        :param return_value:
        :return:
        """
        path = Path(path) if path is not None else self.default_profile_path

        try:
            # Prevent existing data from being overwritten
            if self._is_modified:
                request = AskForRequest("ProfileManager", "Would you like to save the existing profile"
                                                          " before reload? (If not existing data will be overwritten)")

                self.widget_manager.signal.askyesno.emit(request)

                if not request.wait(60):
                    self.widget_manager.signal.show_error_message.emit(
                        "Timed out while waiting for confirmation. Please try again."
                    )
                    return False, None

                result = request.result()

                if result:
                    save_result = self.save()

                    if not save_result:
                        self.widget_manager.signal.show_error_message.emit(
                            "Unable to load profile due to saving error."
                        )
                        return False, None

            # Create new profile if the target profile file does not exist
            if not path.exists() and not self.create_profiles(path):
                return False, None

            result = read_profile_file(path)

            # Profile validation
            if not is_profiles_valid(result):
                self.widget_manager.signal.show_error_message.emit(
                    "Invalid profile file. Try restarting the profile in settings page.",
                )
                return False, None

            self.profiles = result
            self._path = path
            self._is_modified = False

            self.profiles_changed.emit()

            if return_value:
                return True, copy.deepcopy(result)

            return True, None
        except (ProfileException, ProfileLoadException) as e:
            self.widget_manager.signal.show_error_message.emit(
                "Unable to load profile file: {}".format(e.user_message),
            )
            return False, None
        except Exception as e:
            self.widget_manager.signal.show_error_message.emit(
                "Unable to load profile file. An unexpected error occurred: {}".format(e),
            )
            return False, None

    def save(self, path: str | Path | None=None, create_backup: bool=True) -> bool:
        path = Path(path) if path is not None else self.path

        try:
            if not is_profiles_valid(self.profiles):
                self.widget_manager.signal.show_error_message.emit(
                    "Current profile store in memory is not a valid profile.",
                )
                return False

            create_or_save_profile_file(
                self.profiles,
                path,
                create_backup=create_backup,
            )

            self._path = path
            self._is_modified = False
            return True
        except (ProfileException, ProfileSaveException) as e:
            self.widget_manager.signal.show_error_message.emit(
                "Unable to save profile file: {}".format(e.user_message),
            )
            return False
        except Exception as e:
            self.widget_manager.signal.show_error_message.emit(
                "Unable to save profile file. An unexpected error occurred: {}".format(e),
            )
            return False

    def reload(self) -> tuple[bool, dict | None]:
        return self.load(path=self.path)

    # Profile search
    def list_profiles(self) -> list[ProfileSummary]:
        result = []
        profiles = list_profiles_raw(self.profiles)

        for profile_id in profiles:
            profile = convert_profile_data_to_object(self.profiles, profile_id)
            profile_summary = ProfileSummary(
                display_name=self._get_profile_display_name(profile.name, profile.version_id),
                icon=str(profile.icon),
                id=profile_id,
                last_used=profile.last_used,
                name=profile.name,
                version_id=profile.version_id,
                version_type=profile.type
            )
            result.append(profile_summary)

        settings = self.profiles.get("settings", {})
        sorting = settings.get("profileSorting", "ByLastPlayed")
        current_id = get_current_profile_id(self.profiles)
        return sort_profile_summaries(result, sorting, current_id)

    def get_profile(self, profile_id: str) -> ProfileDetail | None:
        if not self.contains(profile_id, show_error=True):
            return None

        profile = convert_profile_data_to_object(
            self.profiles,
            profile_id,
        )

        resolution = None

        if profile.resolution:
            # Put resolution data to profile detail
            resolution = (
                profile.resolution["width"],
                profile.resolution["height"],
            )

        return ProfileDetail(
            display_name=self._get_profile_display_name(profile.name, profile.version_id),
            id=profile_id,
            name=profile.name,
            version_id=profile.version_id,
            version_type=profile.type,
            icon=str(profile.icon),
            last_used=profile.last_used,
            game_dir=Path(profile.game_dir) if profile.game_dir else None,
            java_args=profile.java_args,
            resolution=resolution,
            created=profile.created,
        )

    def contains(self, profile_id: str, show_error: bool=False) -> bool:
        result =  is_profile_exists(self.profiles, profile_id)

        if not result and show_error:
            self.widget_manager.signal.show_error_message.emit(
                "Profile ID {} does not exist.".format(profile_id),
            )

        return result

    # CRUD
    def create_profile(self, request: CreateProfileRequest) -> str | None:
        if not self.is_loaded:
            result, _ = self.load()

            if not result:
                return None

        validation: ValidationResult = self.validate(request)

        if not validation.is_valid:
            message = "\n".join(validation.errors.values())

            self.widget_manager.signal.show_error_message.emit(message)
            return None

        # Check game dir
        if request.game_dir is not None:
            game_dir = Path(request.game_dir).as_posix()
        else:
            default_game_dir = self.app.default_game_dir
            game_dir = default_game_dir.as_posix()

        try:
            # Apply resolution
            resolution = None
            if request.resolution is not None:
                width, height = request.resolution
                resolution = {
                    "width": width,
                    "height": height,
                }

            # Copy profiles before update
            updated_profiles = copy.deepcopy(self.profiles)

            _, profile_id = create_profile_raw(
                version_id=request.version_id,
                version_type=request.version_type,
                java_args=request.java_args,
                game_dir=game_dir,
                icon=request.icon,
                name=request.name,
                resolution=resolution,
                profiles=updated_profiles,
            )

            if not is_profiles_valid(updated_profiles):
                raise ProfileException("Created profile data is invalid.")

            self.profiles = updated_profiles

            # Update change event and flag
            self._is_modified = True
            self.profile_created.emit(profile_id)
            self.profiles_changed.emit()

            return profile_id

        except ProfileException as e:
            self.widget_manager.signal.show_error_message.emit(
                f"Unable to create profile: {e.user_message}"
            )
            return None
        except (TypeError, ValueError) as e:
            self.widget_manager.signal.show_error_message.emit(
                f"Invalid profile request: {e}"
            )
            return None


    def _get_profile_display_name(self, existing_name: str, version_id: str) -> str:
        if existing_name.strip():
            return existing_name.strip()

        return self._get_profile_alias_display_name(version_id)

    @staticmethod
    def _get_profile_alias_display_name(version_id: str) -> str:
        alias = LATEST_VERSION_ALIASES.get(version_id)

        if alias:
            return alias.get("displayName", version_id)

        return "Unnamed Profile"

    def update_profile(self, profile_id: str, changes: UpdateProfileRequest) -> bool:
        # Check profile exists
        if not self.contains(profile_id, show_error=True):
            return False

        try:
            # Copy before the actual update.
            updated_profiles = copy.deepcopy(self.profiles)
            profile_data = copy.deepcopy(
                list_profiles_raw(updated_profiles)[profile_id]
            )

            # Check profile value change
            if changes.name is not None:
                profile_data["name"] = changes.name

            if changes.version_id is not None:
                profile_data["lastVersionId"] = changes.version_id

            if changes.version_type is not None:
                profile_data["type"] = changes.version_type

            if changes.game_dir is not _UNSET:
                if changes.game_dir is None:
                    profile_data["gameDir"] = ""
                else:
                    profile_data["gameDir"] = Path(
                        changes.game_dir
                    ).as_posix()

            if changes.java_args is not _UNSET:
                profile_data["javaArgs"] = (
                    "" if changes.java_args is None
                    else changes.java_args
                )

            if changes.icon is not None:
                profile_data["icon"] = changes.icon

            if changes.resolution is not _UNSET:
                if changes.resolution is None:
                    profile_data.pop("resolution", None)
                else:
                    width, height = changes.resolution
                    profile_data["resolution"] = {
                        "width": width,
                        "height": height,
                    }

            update_profile_raw(
                updated_profiles,
                profile_id,
                profile_data,
            )

            if profile_data == list_profiles_raw(self.profiles)[profile_id]:
                return True

            # Update event and flag
            self.profiles = updated_profiles
            self._is_modified = True

            self.profile_updated.emit(profile_id)
            self.profiles_changed.emit()

            return True

        except (ProfileException, TypeError, ValueError) as e:
            self.widget_manager.signal.show_error_message.emit(
                f"Unable to update profile: {e}"
            )
            return False

    def remove_profile(self, profile_id: str) -> bool:
        if not self.contains(profile_id, show_error=True):
            return False

        try:
            updated_profiles = copy.deepcopy(self.profiles)
            is_current_changed = get_current_profile_id(self.profiles) == profile_id

            # !!!COPY BEFORE REMOVE!!!
            remove_profile(updated_profiles, profile_id)

            # Unset current profile id if is same as removed profile's id
            if is_current_changed:
                updated_profiles["lastPlayedProfileID"] = None

            self.profiles = updated_profiles

            # event
            self._is_modified = True
            self.profile_removed.emit(profile_id)

            if is_current_changed:
                self.current_profile_changed.emit(None)

            self.profiles_changed.emit()

            return True
        except ProfileException as e:
            self.widget_manager.signal.show_error_message.emit(
                f"Unable to remove profile: {e.user_message}"
            )
            return False
        except Exception as e:
            self.widget_manager.signal.show_error_message.emit(
                f"Unable to remove profile. An unknown error occurred: {e}"
            )
            return False

    def duplicate_profile(self, profile_id: str, new_name: str | None = None) -> str | None:
        if not self.contains(profile_id, show_error=True):
            return None

        try:
            updated_profiles = copy.deepcopy(self.profiles)
            new_profile_id = duplicate_profile(
                updated_profiles,
                profile_id,
                new_name,
            )

            # Update event and flag
            self.profiles = updated_profiles
            self._is_modified = True
            self.profile_created.emit(new_profile_id)
            self.profiles_changed.emit()

            return new_profile_id
        except ProfileException as e:
            self.widget_manager.signal.show_error_message.emit(
                f"Unable to duplicate profile: {e.user_message}"
            )
        except Exception as e:
            self.widget_manager.signal.show_error_message.emit(
                f"Unable to duplicate profile. An unknown error occurred: {e}"
            )

    # Profile switch
    def set_current_profile(self, profile_id: str | None) -> bool:
        """
        Set the current profile ID.
        :param profile_id: INFO: Set the current profile ID to None if you want to unset it
        :return:
        """
        if not self.is_loaded:
            success, _ = self.load()

            if not success:
                return False

        if profile_id is not None and not self.contains(profile_id, show_error=True):
            return False

        try:
            existing_id = get_current_profile_id(self.profiles)

            # ignore same id request
            if profile_id == existing_id:
                return True

            if existing_id is not None:
                self.app.logger.debug(
                    "Replacing current profile ID {} to {}".format(existing_id, profile_id)
                )

            set_current_profile_id(self.profiles, profile_id)

            # event and flag update
            self._is_modified = True
            self.current_profile_changed.emit(profile_id)
            self.profiles_changed.emit()

            return True
        except ProfileException as e:
            self.widget_manager.signal.show_error_message.emit(
                f"Unable to set current profile ID: {e.user_message}"
            )
            return False
        except Exception as e:
            self.widget_manager.signal.show_error_message.emit(
                "Unable to set current profile ID. An unknown error occurred {}".format(str(e))
            )
            return False

    def get_current_profile_id(self) -> str | None:
        if not self.is_loaded:
            success, _ = self.load()

            if not success:
                return None

        return get_current_profile_id(self.profiles)

    def get_launch_profile(self, profile_id: str) -> LaunchProfile | None:
        profile = self.get_profile(profile_id)

        if profile is None:
            return None

        # Use default game_dir if is unset
        game_dir = (
            profile.game_dir
            if profile.game_dir is not None
            else self.app.default_game_dir
        )

        return LaunchProfile(
            display_name=self._get_profile_display_name(
                profile.name,
                profile.version_id,
            ),
            name=profile.name,
            profile_id=profile.id,
            version_id=profile.version_id,
            version_type=profile.version_type,
            game_dir=game_dir,
            java_args=profile.java_args,
            resolution=profile.resolution,
        )

    # Validation
    @staticmethod
    def validate(request: CreateProfileRequest) -> ValidationResult:
        errors = {}

        if not isinstance(request.name, str) or not request.name.strip():
            errors["name"] = "Profile name cannot be empty."

        if not isinstance(request.version_id, str)  or not request.version_id.strip():
            errors["version_id"] = "Version ID cannot be empty."

        if not isinstance(request.version_type, str) or not request.version_type.strip():
            errors["version_type"] = "Version type cannot be empty."

        if not isinstance(request.java_args, str):
            errors["java_args"] = "Java arguments must be a string."

        if not isinstance(request.icon, str):
            errors["icon"] = "Icon must be a string."

        if request.game_dir is not None:
            try:
                Path(request.game_dir)
            except (TypeError, ValueError):
                errors["game_dir"] = "Game directory is invalid."

        if request.resolution is not None:
            if not isinstance(request.resolution, tuple) or len(request.resolution) != 2:
                errors["resolution"] = (
                    "Resolution must contain width and height."
                )
            else:
                width, height = request.resolution

                if (not isinstance(width, int) or isinstance(width, bool) or width <= 0 or
                    not isinstance(height, int) or isinstance(height, bool) or height <= 0):
                    errors["resolution"] = (
                        "Resolution width and height must be positive integers."
                    )

        return ValidationResult(errors=errors)

    @staticmethod
    def validate_update(request: UpdateProfileRequest) -> ValidationResult:
        errors = {}

        if not isinstance(request.name, str) or not request.name.strip():
            errors["name"] = "Profile name cannot be empty."

        if not isinstance(request.version_id, str) or not request.version_id.strip():
            errors["version_id"] = "Version ID cannot be empty."

        if not isinstance(request.version_type, str) or not request.version_type.strip():
            errors["version_type"] = "Version type cannot be empty."

        if not isinstance(request.java_args, str):
            errors["java_args"] = "Java arguments must be a string."

        if not isinstance(request.icon, str):
            errors["icon"] = "Icon must be a string."

        if request.game_dir is not _UNSET and request.game_dir is not None:
            try:
                Path(request.game_dir)
            except (TypeError, ValueError):
                errors["game_dir"] = "Game directory is invalid."

        if request.resolution is not _UNSET and request.resolution is not None:
            if not isinstance(request.resolution, tuple) or len(request.resolution) != 2:
                errors["resolution"] = (
                    "Resolution must contain width and height."
                )
            else:
                width, height = request.resolution

                if (not isinstance(width, int) or isinstance(width, bool) or width <= 0 or
                        not isinstance(height, int) or isinstance(height, bool) or height <= 0):
                    errors["resolution"] = (
                        "Resolution width and height must be positive integers."
                    )

        return ValidationResult(errors=errors)

    # Property
    @property
    def path(self) -> Path | None:
        if self._path is None:
            self._path = Path(self.app.work_dir, DEFAuLT_PROFILE_RELATIVE_PATH)

        return self._path

    @property
    def is_loaded(self) -> bool:
        return bool(self.profiles) and is_profiles_valid(self.profiles)

    @property
    def is_modified(self) -> bool:
        return self._is_modified


def sort_profile_summaries(
    profiles: list[ProfileSummary],
    sorting: str,
    current_profile_id: str | None = None,
) -> list[ProfileSummary]:
    """Order profiles according to launcher_profiles.json settings."""
    ordered = list(profiles)

    if sorting == "ByName":
        return sorted(
            ordered,
            key=lambda profile: (
                profile.display_name.casefold(),
                profile.version_id.casefold(),
                profile.id,
            ),
        )

    if sorting == "ByLastPlayed":
        def last_used_timestamp(profile: ProfileSummary) -> float:
            if profile.last_used is None:
                return float("-inf")
            try:
                return profile.last_used.timestamp()
            except (OSError, OverflowError, ValueError):
                return float("-inf")

        ordered.sort(key=last_used_timestamp, reverse=True)
        if current_profile_id is not None:
            ordered.sort(key=lambda profile: profile.id != current_profile_id)

    return ordered
