import binascii
import glob
import logging
import os
import re
import string
import struct
import zlib
from pathlib import Path

import vdf

__all__ = (
    "COMMON_STEAM_DIRS", "SteamApp", "find_steam_path",
    "find_steam_proton_app", "find_proton_app", "find_steam_runtime_path",
    "find_appid_proton_prefix", "get_steam_lib_paths", "get_steam_apps",
    "get_custom_proton_installations"
)

COMMON_STEAM_DIRS = [
    os.path.join(".steam", "steam"),
    os.path.join(".local", "share", "Steam")
]

logger = logging.getLogger("protontricks")


class SteamApp(object):
    """
    SteamApp represents an installed Steam app or whatever is close enough to
    one (eg. a custom Proton installation or a Windows shortcut with its own
    Proton prefix)
    """
    __slots__ = ("appid", "name", "prefix_path", "install_path")

    def __init__(self, name, install_path, prefix_path=None, appid=None):
        """
        :appid: App's appid
        :name: The app's human-readable name
        :prefix_path: Absolute path to where the app's Wine prefix *might*
                      exist.
        :app_path: Absolute path to app's installation directory
        """
        self.appid = int(appid) if appid else None
        self.name = name
        self.prefix_path = prefix_path
        self.install_path = install_path

    @property
    def prefix_path_exists(self):
        """
        Returns True if the app has a Wine prefix directory that has been
        launched at least once
        """
        if not self.prefix_path:
            return False

        # 'pfx' directory is incomplete until the game has been launched
        # once, so check for 'pfx.lock' as well
        return (
            os.path.exists(self.prefix_path)
            and os.path.exists(os.path.join(self.prefix_path, "..", "pfx.lock"))
        )

    def name_contains(self, s):
        """
        Returns True if the name contains the given substring.
        Both strings are normalized for easier searching before comparison.
        """
        def normalize_str(s):
            """
            Normalize the string to make it easier for human to
            perform a search by removing all symbols
            except ASCII digits and letters and turning it into lowercase
            """
            printable = set(string.printable) - set(string.punctuation)
            s = "".join([c for c in s if c in printable])
            s = s.lower()
            s = s.replace(" ", "")
            return s

        return normalize_str(s) in normalize_str(self.name)

    @property
    def is_proton(self):
        """
        Return True if this app is a Proton installation
        """
        # If the installation directory contains a file named "proton",
        # it's a Proton installation
        return os.path.exists(os.path.join(self.install_path, "proton"))

    @classmethod
    def from_appmanifest(cls, path, steam_lib_paths):
        """
        Parse appmanifest_X.acf file containing Steam app installation metadata
        and return a SteamApp object
        """
        with open(path, "r") as f:
            try:
                content = f.read()
            except UnicodeDecodeError:
                # This might occur if the appmanifest becomes corrupted
                # eg. due to running a Linux filesystem under Windows
                # In that case just skip it
                logger.warning(
                    "Skipping malformed appmanifest {}".format(path)
                )
                return None

        try:
            vdf_data = vdf.loads(content)
        except SyntaxError:
            logger.warning("Skipping malformed appmanifest {}".format(path))
            return None

        try:
            app_state = vdf_data["AppState"]
        except KeyError:
            # Some appmanifest files may be empty. Ignore those.
            logger.info("Skipping empty appmanifest {}".format(path))
            return None

        # The app ID field can be named 'appID' or 'appid'.
        # 'appid' is more common, but certain appmanifest
        # files (created by old Steam clients?) also use 'appID'.
        #
        # Use case-insensitive field names to deal with these.
        app_state = {k.lower(): v for k, v in app_state.items()}
        appid = int(app_state["appid"])
        name = app_state["name"]

        # Proton prefix may exist on a different library
        prefix_path = find_appid_proton_prefix(
            appid=appid, steam_lib_paths=steam_lib_paths
        )

        install_path = os.path.join(
            os.path.split(path)[0], "common", app_state["installdir"])

        return cls(
            appid=appid, name=name, prefix_path=prefix_path,
            install_path=install_path)


def find_steam_path():
    """
    Try to discover default Steam dir using common locations and return the
    first one that matches

    Return (steam_path, steam_root), where steam_path points to
    "~/.steam/steam" (contains "appcache", "config" and "steamapps")
    and "~/.steam/root" (contains "ubuntu12_32" and "compatibilitytools.d")
    """
    def has_steamapps_dir(path):
        """
        Return True if the path either has a 'steamapps' or a 'SteamApps'
        subdirectory, False otherwise
        """
        return (
            # 'steamapps' is the usual name under Linux Steam installations
            os.path.isdir(os.path.join(path, "steamapps"))
            # 'SteamApps' name appears in installations imported from Windows
            or os.path.isdir(os.path.join(path, "SteamApps"))
        )

    def has_runtime_dir(path):
        return os.path.isdir(os.path.join(path, "ubuntu12_32"))

    # as far as @admalledd can tell,
    # this should always be correct for the tools root:
    steam_root = os.path.join(os.path.expanduser("~"), ".steam", "root")

    if not os.path.isdir(os.path.join(steam_root, "ubuntu12_32")):
        # Check that runtime dir exists, if not make root=path and hope
        steam_root = None

    if os.environ.get("STEAM_DIR"):
        steam_path = os.environ.get("STEAM_DIR")
        if has_steamapps_dir(steam_path) and has_runtime_dir(steam_path):
            logger.info(
                "Found a valid Steam installation at %s.", steam_path
            )

            return steam_path, steam_path

        logger.error(
            "$STEAM_DIR was provided but didn't point to a valid Steam "
            "installation."
        )

        return None, None

    for steam_path in COMMON_STEAM_DIRS:
        # The common Steam directories are found inside the home directory
        steam_path = str(Path.home() / steam_path)
        if has_steamapps_dir(steam_path):
            logger.info(
                "Found Steam directory at {}. You can also define Steam "
                "directory manually using $STEAM_DIR".format(steam_path)
            )
            if not steam_root:
                steam_root = steam_path
            return steam_path, steam_root

    return None, None


def find_steam_runtime_path(steam_root):
    """
    Find the Steam Runtime either using the STEAM_RUNTIME env or
    steam_root
    """
    env_steam_runtime = os.environ.get("STEAM_RUNTIME", "")

    if env_steam_runtime == "0":
        # User has disabled Steam Runtime
        logger.info("STEAM_RUNTIME is 0. Disabling Steam Runtime.")
        return None
    elif os.path.isdir(env_steam_runtime):
        # User has a custom Steam Runtime
        logger.info(
            "Using custom Steam Runtime at %s", env_steam_runtime)
        return env_steam_runtime
    elif env_steam_runtime in ["1", ""]:
        # User has enabled Steam Runtime or doesn't have STEAM_RUNTIME set;
        # default to enabled Steam Runtime in either case
        steam_runtime_path = os.path.join(
            steam_root, "ubuntu12_32", "steam-runtime")

        logger.info(
            "Using default Steam Runtime at %s", steam_runtime_path)
        return steam_runtime_path

    logger.error(
        "Path in STEAM_RUNTIME doesn't point to a valid Steam Runtime!")

    return None


APPINFO_STRUCT_HEADER = "<4sL"
APPINFO_STRUCT_SECTION = "<LLLLQ20sL"


def get_appinfo_sections(path):
    """
    Parse an appinfo.vdf file and return all the deserialized binary VDF
    objects inside it
    """
    # appinfo.vdf is not actually a (binary) VDF file, but a binary file
    # containing multiple binary VDF sections.
    # File structure based on comment from vdf developer:
    # https://github.com/ValvePython/vdf/issues/13#issuecomment-321700244
    with open(path, "rb") as f:
        data = f.read()
        i = 0

        # Parse the header
        header_size = struct.calcsize(APPINFO_STRUCT_HEADER)
        magic, universe = struct.unpack(
            APPINFO_STRUCT_HEADER, data[0:header_size]
        )

        i += header_size

        if magic != b"'DV\x07":
            raise SyntaxError("Invalid file magic number")

        sections = []

        section_size = struct.calcsize(APPINFO_STRUCT_SECTION)
        while True:
            # We don't need any of the fields besides 'entry_size',
            # which is used to determine the length of the variable-length VDF
            # field.
            # Still, here they are for posterity's sake.
            (appid, entry_size, infostate, last_updated, access_token,
             sha_hash, change_number) = struct.unpack(
                APPINFO_STRUCT_SECTION, data[i:i+section_size])
            vdf_section_size = entry_size - 40

            i += section_size
            try:
                vdf_d = vdf.binary_loads(data[i:i+vdf_section_size])
                sections.append(vdf_d)
            except UnicodeDecodeError:
                # vdf is unable to decode binary VDF objects containing
                # invalid UTF-8 strings.
                # Since we're only interested in the SteamPlay manifests,
                # we can skip those faulty sections.
                #
                # TODO: Remove this once the upstream bug at
                # https://github.com/ValvePython/vdf/issues/20
                # is fixed
                pass

            i += vdf_section_size

            if i == len(data) - 4:
                return sections


def get_proton_appid(compat_tool_name, appinfo_path):
    """
    Get the App ID for Proton installation by the compat tool name
    used in STEAM_DIR/config/config.vdf
    """
    # Parse all the individual VDF sections in appinfo.vdf to a list
    vdf_sections = get_appinfo_sections(appinfo_path)

    for section in vdf_sections:
        if not section.get("appinfo", {}).get("extended", {}).get(
                "compat_tools", None):
            continue

        compat_tools = section["appinfo"]["extended"]["compat_tools"]

        for default_name, entry in compat_tools.items():
            # A single compatibility tool may have multiple valid names
            # eg. "proton_316" and "proton_316_beta"
            aliases = [default_name]

            # Each compat tool entry can also contain an 'aliases' field
            # with a different compat tool name
            if "aliases" in entry:
                # All of the appinfo.vdf files encountered so far
                # only have a single string inside the "aliases" field,
                # but let's assume the field could be a list of strings
                # as well
                if isinstance(entry["aliases"], str):
                    aliases.append(entry["aliases"])
                elif isinstance(entry["aliases"], list):
                    aliases += entry["aliases"]
                else:
                    raise TypeError(
                        "Unexpected type {} for 'fields' in "
                        "appinfo.vdf".format(type(aliases))
                    )

            if compat_tool_name in aliases:
                return entry["appid"]

    logger.error("Could not find the Steam Play manifest in appinfo.vdf")

    return None


def find_steam_proton_app(steam_path, steam_apps, appid=None):
    """
    Get the current Proton installation used by Steam
    and return a SteamApp object

    If 'appid' is provided, try to find the app-specific Proton installation
    if one is configured
    """
    # 1. Find the name of Proton installation in use
    #    from STEAM_DIR/config/config.vdf
    # 2. If the Proton installation's name can be found directly
    #    in the list of apps we discovered earlier, return that
    # 3. ...or if the name can't be found that way, parse
    #    the file in STEAM_DIR/appcache/appinfo.vdf to find the Proton
    #    installation's App ID
    config_vdf_path = os.path.join(steam_path, "config", "config.vdf")

    with open(config_vdf_path, "r") as f:
        content = f.read()

    vdf_data = vdf.loads(content)
    # ToolMapping seems to be used in older Steam beta releases
    try:
        tool_mapping = (
            vdf_data["InstallConfigStore"]["Software"]["Valve"]["Steam"]
                    ["ToolMapping"]
        )
    except KeyError:
        tool_mapping = {}

    # CompatToolMapping seems to be the name used in newer Steam releases
    # We'll prioritize this if it exists
    try:
        compat_tool_mapping = (
            vdf_data["InstallConfigStore"]["Software"]["Valve"]["Steam"]
                    ["CompatToolMapping"]
        )
    except KeyError:
        compat_tool_mapping = {}

    compat_tool_name = None

    # The name of potential names in order of priority
    potential_names = [
        compat_tool_mapping.get(str(appid), {}).get("name", None),
        compat_tool_mapping.get("0", {}).get("name", None),
        tool_mapping.get(str(appid), {}).get("name", None),
        tool_mapping.get("0", {}).get("name", None)
    ]
    # Get the first name that was valid
    try:
        compat_tool_name = next(name for name in potential_names if name)
    except StopIteration:
        logger.error("No Proton installation found in config.vdf")
        return None

    # We've got the name from config.vdf,
    # now there are two possible ways to find the installation
    # 1. It's a custom Proton installation, and we simply need to find
    #    a SteamApp by its internal name
    # 2. It's a production Proton installation, in which case we need
    #    to parse a binary configuration file to find the App ID

    # Let's try option 1 first
    try:
        app = next(app for app in steam_apps if app.name == compat_tool_name)
        logger.info(
            "Found active custom Proton installation: {}".format(app.name)
        )
        return app
    except StopIteration:
        pass

    # Try option 2:
    # Find the corresponding App ID from <steam_path>/appcache/appinfo.vdf
    appinfo_path = os.path.join(steam_path, "appcache", "appinfo.vdf")
    proton_appid = get_proton_appid(compat_tool_name, appinfo_path)

    if not proton_appid:
        logger.error("Could not find Proton's App ID from appinfo.vdf")
        return None

    # We've now got the appid. Return the corresponding SteamApp
    try:
        app = next(app for app in steam_apps if app.appid == proton_appid)
        logger.info(
            "Found active Proton installation: {}".format(app.name)
        )
        return app
    except StopIteration:
        return None


def find_appid_proton_prefix(appid, steam_lib_paths):
    """
    Find the Proton prefix for the app by its App ID

    Proton prefix and the game installation itself can exist on different
    Steam libraries, making a search necessary
    """
    def get_prefix_modify_time(prefix_path):
        """
        Get the prefix modification time for sorting purposes.
        The newest modification time corresponds to the most recently
        used Proton prefix
        """
        try:
            # 'pfx.lock' is modified on game launch
            return os.stat(
                os.path.join(prefix_path, "..", "pfx.lock")
            ).st_mtime
        except FileNotFoundError:
            return 0

    candidates = []

    for path in steam_lib_paths:
        # 'steamapps' portion of the path can also be 'SteamApps'
        for steamapps_part in ("steamapps", "SteamApps"):
            prefix_path = os.path.join(
                path, steamapps_part, "compatdata", str(appid), "pfx"
            )
            if os.path.isdir(prefix_path):
                candidates.append(prefix_path)

    if len(candidates) > 1:
        # If we have more than one possible prefix path, use the one
        # with the most recent modification date
        logger.info(
            "Multiple compatdata directories found for app %s", appid
        )
        candidates.sort(key=get_prefix_modify_time)
        candidates.reverse()

    if candidates:
        return candidates[0]

    return None


def find_proton_app(steam_path, steam_apps, appid=None):
    """
    Find the Proton app, using either $PROTON_VERSION or the one
    currently configured in Steam

    If 'appid' is provided, use it to find the app-specific Proton installation
    if one is configured
    """
    if os.environ.get("PROTON_VERSION"):
        proton_version = os.environ.get("PROTON_VERSION")
        try:
            proton_app = next(
                app for app in steam_apps if app.name == proton_version)
            logger.info(
                 "Found requested Proton version: {}".format(proton_app.name)
            )
            return proton_app
        except StopIteration:
            logger.error(
                "$PROTON_VERSION was set but matching Proton installation "
                "could not be found."
            )
            return None

    proton_app = find_steam_proton_app(
        steam_path=steam_path, steam_apps=steam_apps, appid=appid)

    if not proton_app:
        logger.error(
            "Active Proton installation could not be found automatically."
        )

    return proton_app


def get_steam_lib_paths(steam_path):
    """
    Return a list of any Steam directories including any user-added
    Steam library folders
    """
    def parse_library_folders(data):
        """
        Parse the Steam library folders in the VDF file using the given data
        """
        vdf_data = vdf.loads(data)
        # Library folders have integer field names in ascending order
        library_folders = [
            value for key, value in vdf_data["LibraryFolders"].items()
            if key.isdigit()
        ]

        logger.info(
            "Found {} Steam library folders".format(len(library_folders))
        )
        return library_folders

    # Try finding Steam library folders using libraryfolders.vdf in Steam root
    if os.path.isdir(os.path.join(steam_path, "steamapps")):
        folders_vdf_path = os.path.join(
            steam_path, "steamapps", "libraryfolders.vdf")
    elif os.path.isdir(os.path.join(steam_path, "SteamApps")):
        folders_vdf_path = os.path.join(
            steam_path, "SteamApps", "libraryfolders.vdf")

    try:
        with open(folders_vdf_path, "r") as f:
            library_folders = parse_library_folders(f.read())
    except OSError:
        # libraryfolders.vdf doesn't exist; maybe no Steam library folders
        # are set?
        library_folders = []

    return [steam_path] + library_folders


def get_compat_tool_dirs(steam_root):
    """
    Return a list of compatibility tool directories in order from
    directories with lowest precedence
    """
    # The path list is ordered by priority, starting from Proton apps
    # with the lowest precedence ('/usr/share/steam/compatibilitytools.d')
    paths = [
        "/usr/share/steam/compatibilitytools.d",
        "/usr/local/share/steam/compatibilitytools.d",
    ]
    extra_ct_paths_env = os.getenv("STEAM_EXTRA_COMPAT_TOOLS_PATHS")
    if extra_ct_paths_env:
        paths += extra_ct_paths_env.split(os.pathsep)
    paths += [os.path.join(steam_root, "compatibilitytools.d")]

    return paths


def get_proton_installations(compat_tool_dir):
    """
    Return a list of custom Proton installations as a list of SteamApp objects
    """

    if not os.path.isdir(compat_tool_dir):
        return []

    comptool_files = glob.glob(
        os.path.join(
            glob.escape(compat_tool_dir), "*", "compatibilitytool.vdf"
        )
    )
    comptool_files += glob.glob(
        os.path.join(glob.escape(compat_tool_dir), "compatibilitytool.vdf")
    )

    custom_proton_apps = []

    for vdf_path in comptool_files:
        with open(vdf_path, "r") as f:
            content = f.read()

        vdf_data = vdf.loads(content)
        internal_name = list(
            vdf_data["compatibilitytools"]["compat_tools"].keys())[0]
        tool_info = vdf_data["compatibilitytools"]["compat_tools"][
            internal_name]

        install_path = tool_info["install_path"]
        from_oslist = tool_info["from_oslist"]
        to_oslist = tool_info["to_oslist"]

        if from_oslist != "windows" or to_oslist != "linux":
            continue

        # Installation path can be relative if the VDF was in
        # 'compatibilitytools.d/'
        # or '.' if the VDF was in 'compatibilitytools.d/TOOL_NAME'
        if install_path == ".":
            install_path = os.path.dirname(vdf_path)
        else:
            install_path = os.path.join(compat_tool_dir, install_path)

        custom_proton_apps.append(
            SteamApp(name=internal_name, install_path=install_path)
        )

    return custom_proton_apps


def get_custom_proton_installations(steam_root):
    custom_proton_apps = {}
    for d in get_compat_tool_dirs(steam_root=steam_root):
        for proton_app in get_proton_installations(d):
            # If another Proton app exists with the same name, it will
            # be replaced with an installation that has higher precedence
            # here
            custom_proton_apps[proton_app.name] = proton_app

    # Return the list of Proton apps as a list
    custom_proton_apps = list(custom_proton_apps.values())

    return custom_proton_apps


def find_current_steamid3(steam_path):
    """
    Find the SteamID3 of the currently logged in Steam user
    """
    def to_steamid3(steamid64):
        """Convert a SteamID64 into the SteamID3 format"""
        return int(steamid64) & 0xffffffff

    loginusers_path = os.path.join(steam_path, "config", "loginusers.vdf")
    try:
        with open(loginusers_path, "r") as f:
            content = f.read()
            vdf_data = vdf.loads(content)
    except IOError:
        return None

    users = [
        {
            "steamid3": to_steamid3(user_id),
            "account_name": user_data["AccountName"],
            "timestamp": user_data.get("Timestamp", 0)
        }
        for user_id, user_data in vdf_data["users"].items()
    ]

    # Return the user with the highest timestamp, as that's likely to be the
    # currently logged-in user
    if users:
        user = max(users, key=lambda u: u["timestamp"])
        logger.info(
            "Currently logged-in Steam user: %s", user["account_name"]
        )
        return user["steamid3"]

    return None


def get_appid_from_shortcut(target, name):
    """
    Get the identifier used for the Proton prefix from a shortcut's
    target and name
    """
    # First, calculate the screenshot ID Steam uses for shortcuts
    data = b"".join([
        target.encode("utf-8"),
        name.encode("utf-8")
    ])
    result = zlib.crc32(data) & 0xffffffff
    result = result | 0x80000000
    result = (result << 32) | 0x02000000

    # Derive the prefix ID from the screenshot ID
    return result >> 32


def get_custom_windows_shortcuts(steam_path):
    """
    Get a list of custom shortcuts for Windows applications as a list
    of SteamApp objects
    """
    # Get the Steam ID3 for the currently logged-in user
    steamid3 = find_current_steamid3(steam_path)

    shortcuts_path = os.path.join(
        steam_path, "userdata", str(steamid3), "config", "shortcuts.vdf"
    )

    try:
        with open(shortcuts_path, "rb") as f:
            content = f.read()
            vdf_data = vdf.binary_loads(content)
    except IOError:
        logger.info(
            "Couldn't find custom shortcuts. Maybe none have been created yet?"
        )
        return []

    steam_apps = []

    for shortcut_id, shortcut_data in vdf_data["shortcuts"].items():
        # The "exe" field can also be "Exe". Account for this by making
        # all field names lowercase
        shortcut_data = {k.lower(): v for k, v in shortcut_data.items()}
        shortcut_id = int(shortcut_id)

        appid = get_appid_from_shortcut(
            target=shortcut_data["exe"], name=shortcut_data["appname"]
        )

        prefix_path = os.path.join(
            steam_path, "steamapps", "compatdata", str(appid), "pfx"
        )
        install_path = shortcut_data["startdir"].strip('"')

        if not os.path.isdir(prefix_path):
            continue

        steam_apps.append(
            SteamApp(
                appid=appid,
                name="Non-Steam shortcut: {}".format(shortcut_data["appname"]),
                prefix_path=prefix_path, install_path=install_path
            )
        )

    logger.info(
        "Found %d Steam shortcuts running under Proton", len(steam_apps)
    )

    return steam_apps


def get_steam_apps(steam_root, steam_path, steam_lib_paths):
    """
    Find all the installed Steam apps and return them as a list of SteamApp
    objects
    """
    steam_apps = []

    for path in steam_lib_paths:
        appmanifest_paths = []
        is_lowercase = os.path.isdir(os.path.join(path, "steamapps"))
        is_mixedcase = os.path.isdir(os.path.join(path, "SteamApps"))

        if is_lowercase:
            appmanifest_paths = glob.glob(
                os.path.join(
                    glob.escape(path), "steamapps", "appmanifest_*.acf"
                )
            )
        elif is_mixedcase:
            appmanifest_paths = glob.glob(
                os.path.join(
                    glob.escape(path), "SteamApps", "appmanifest_*.acf"
                )
            )

        if is_lowercase and is_mixedcase:
            # Log a warning if both 'steamapps' and 'SteamApps' directories
            # exist, as both Protontricks and Steam client have problems
            # dealing with it (see issue #51)
            logger.warning(
                "Both 'steamapps' and 'SteamApps' directories were found at "
                "%s. 'SteamApps' directory should be removed to prevent "
                "issues with app and Proton discovery.",
                path
            )

        for manifest_path in appmanifest_paths:
            steam_app = SteamApp.from_appmanifest(
                manifest_path, steam_lib_paths=steam_lib_paths
            )
            if steam_app:
                steam_apps.append(steam_app)

    # Get the custom Proton installations and non-Steam shortcuts as well
    steam_apps += get_custom_proton_installations(steam_root=steam_root)
    steam_apps += get_custom_windows_shortcuts(steam_path=steam_path)

    # Exclude games that haven't been launched yet
    steam_apps = [
        app for app in steam_apps if app.prefix_path_exists or app.is_proton
    ]

    # Sort the apps by their names
    steam_apps.sort(key=lambda app: app.name)

    return steam_apps