# Copyright 2017 Damon Atkins
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
r"""
Collect information about software installed on Windows OS
================

:maintainer: Salt Stack <https://github.com/saltstack>
:codeauthor: Damon Atkins <https://github.com/damon-atkins>
:maturity: new
:depends: pywin32
:platform: windows

Known Issue: install_date may not match Control Panel\Programs\Programs and Features
"""

# Note although this code will work with Python 2.7, win32api does not
# support Unicode. i.e non ASCII characters may be returned with unexpected
# results e.g. a '?' instead of the correct character
# Python 3.6 or newer is recommended.

import collections
import datetime
import locale
import logging
import os.path
import platform
import re
import sys
import time
from functools import cmp_to_key

__version__ = "0.1"

try:
    import pywintypes
    import win32api
    import win32con
    import win32process
    import win32security
    import winerror

except ImportError:
    if __name__ == "__main__":
        raise ImportError("Please install pywin32/pypiwin32")
    else:
        raise


if __name__ == "__main__":
    LOG_CONSOLE = logging.StreamHandler()
    LOG_CONSOLE.setFormatter(logging.Formatter("[%(levelname)s]: %(message)s"))
    log = logging.getLogger(__name__)
    log.addHandler(LOG_CONSOLE)
    log.setLevel(logging.DEBUG)
else:
    log = logging.getLogger(__name__)


try:
    from salt.utils.odict import OrderedDict
except ImportError:
    from collections import OrderedDict

try:
    from salt.utils.versions import LooseVersion
except ImportError:
    from distutils.version import LooseVersion  # pylint: disable=blacklisted-module


# pylint: disable=too-many-instance-attributes


class RegSoftwareInfo:
    """
    Retrieve Registry data on a single installed software item or component.

    Attribute:
        None

    :codeauthor: Damon Atkins <https://github.com/damon-atkins>
    """

    # Variables shared by all instances
    __guid_pattern = re.compile(
        r"^\{(\w{8})-(\w{4})-(\w{4})-(\w\w)(\w\w)-(\w\w)(\w\w)(\w\w)(\w\w)(\w\w)(\w\w)\}$"
    )
    __squid_pattern = re.compile(
        r"^(\w{8})(\w{4})(\w{4})(\w\w)(\w\w)(\w\w)(\w\w)(\w\w)(\w\w)(\w\w)(\w\w)$"
    )
    __version_pattern = re.compile(r"\d+\.\d+\.\d+[\w.-]*|\d+\.\d+[\w.-]*")
    __upgrade_codes = {}
    __upgrade_code_have_scan = {}

    __reg_types = {
        "str": (win32con.REG_EXPAND_SZ, win32con.REG_SZ),
        "list": (win32con.REG_MULTI_SZ),
        "int": (win32con.REG_DWORD, win32con.REG_DWORD_BIG_ENDIAN, win32con.REG_QWORD),
        "bytes": (win32con.REG_BINARY),
    }

    # Search 64bit, on 64bit platform, on 32bit its ignored
    if platform.architecture()[0] == "32bit":
        # Handle Python 32bit on 64&32 bit platform and Python 64bit
        if win32process.IsWow64Process():  # pylint: disable=no-member
            # 32bit python on a 64bit platform
            __use_32bit_lookup = {True: 0, False: win32con.KEY_WOW64_64KEY}
        else:
            # 32bit python on a 32bit platform
            __use_32bit_lookup = {True: 0, False: None}
    else:
        __use_32bit_lookup = {True: win32con.KEY_WOW64_32KEY, False: 0}

    def __init__(self, key_guid, sid=None, use_32bit=False):
        """
        Initialise against a software item or component.

        All software has a unique "Identifer" within the registry. This can be free
        form text/numbers e.g. "MySoftware" or
        GUID e.g. "{0EAF0D8F-C9CF-4350-BD9A-07EC66929E04}"

        Args:
            key_guid (str): Identifer.
            sid (str): Security IDentifier of the User or None for Computer/Machine.
            use_32bit (bool):
                Regisrty location of the Identifer. ``True`` 32 bit registry only
                meaning fully on 64 bit OS.
        """
        self.__reg_key_guid = key_guid  # also called IdentifyingNumber(wmic)
        self.__squid = ""
        self.__reg_products_path = ""
        self.__reg_upgradecode_path = ""
        self.__patch_list = None

        # If a valid GUID create the SQUID also.
        guid_match = self.__guid_pattern.match(key_guid)
        if guid_match is not None:
            for index in range(1, 12):
                # __guid_pattern breaks up the GUID
                self.__squid += guid_match.group(index)[::-1]

        if sid:
            # User data seems to be more spreadout within the registry.
            self.__reg_hive = "HKEY_USERS"
            self.__reg_32bit = False  # Force to False
            self.__reg_32bit_access = (
                0  # HKEY_USERS does not have a 32bit and 64bit view
            )
            self.__reg_uninstall_path = "{}\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{}".format(
                sid, key_guid
            )
            if self.__squid:
                self.__reg_products_path = (
                    "{}\\Software\\Classes\\Installer\\Products\\{}".format(
                        sid, self.__squid
                    )
                )
                self.__reg_upgradecode_path = (
                    "{}\\Software\\Microsoft\\Installer\\UpgradeCodes".format(sid)
                )
                self.__reg_patches_path = (
                    "Software\\Microsoft\\Windows\\CurrentVersion\\Installer\\UserData\\"
                    "{}\\Products\\{}\\Patches".format(sid, self.__squid)
                )
        else:
            self.__reg_hive = "HKEY_LOCAL_MACHINE"
            self.__reg_32bit = use_32bit
            self.__reg_32bit_access = self.__use_32bit_lookup[use_32bit]
            self.__reg_uninstall_path = (
                "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{}".format(
                    key_guid
                )
            )
            if self.__squid:
                self.__reg_products_path = (
                    "Software\\Classes\\Installer\\Products\\{}".format(self.__squid)
                )
                self.__reg_upgradecode_path = (
                    "Software\\Classes\\Installer\\UpgradeCodes"
                )
                self.__reg_patches_path = (
                    "Software\\Microsoft\\Windows\\CurrentVersion\\Installer\\UserData\\"
                    "S-1-5-18\\Products\\{}\\Patches".format(self.__squid)
                )

        # OpenKey is expensive, open in advance and keep it open.
        # This must exist
        try:
            # pylint: disable=no-member
            self.__reg_uninstall_handle = win32api.RegOpenKeyEx(
                getattr(win32con, self.__reg_hive),
                self.__reg_uninstall_path,
                0,
                win32con.KEY_READ | self.__reg_32bit_access,
            )
        except pywintypes.error as exc:  # pylint: disable=no-member
            if exc.winerror == winerror.ERROR_FILE_NOT_FOUND:
                log.error(
                    "Software/Component Not Found  key_guid: '%s', "
                    "sid: '%s' , use_32bit: '%s'",
                    key_guid,
                    sid,
                    use_32bit,
                )
            raise  # This must exist or have no errors

        self.__reg_products_handle = None
        if self.__squid:
            try:
                # pylint: disable=no-member
                self.__reg_products_handle = win32api.RegOpenKeyEx(
                    getattr(win32con, self.__reg_hive),
                    self.__reg_products_path,
                    0,
                    win32con.KEY_READ | self.__reg_32bit_access,
                )
            except pywintypes.error as exc:  # pylint: disable=no-member
                if exc.winerror == winerror.ERROR_FILE_NOT_FOUND:
                    log.debug(
                        "Software/Component Not Found in Products section of registry "
                        "key_guid: '%s', sid: '%s', use_32bit: '%s'",
                        key_guid,
                        sid,
                        use_32bit,
                    )
                    self.__squid = None  # mark it as not a SQUID
                else:
                    raise

        self.__mod_time1970 = 0
        # pylint: disable=no-member
        mod_win_time = win32api.RegQueryInfoKeyW(self.__reg_uninstall_handle).get(
            "LastWriteTime", None
        )
        # pylint: enable=no-member
        if mod_win_time:
            # at some stage __int__() was removed from pywintypes.datetime to return secs since 1970
            if hasattr(mod_win_time, "utctimetuple"):
                self.__mod_time1970 = time.mktime(mod_win_time.utctimetuple())
            elif hasattr(mod_win_time, "__int__"):
                self.__mod_time1970 = int(mod_win_time)

    def __squid_to_guid(self, squid):
        """
        Squished GUID (SQUID) to GUID.

        A SQUID is a Squished/Compressed version of a GUID to use up less space
        in the registry.

        Args:
            squid (str): Squished GUID.

        Returns:
            str: the GUID if a valid SQUID provided.
        """
        if not squid:
            return ""
        squid_match = self.__squid_pattern.match(squid)
        guid = ""
        if squid_match is not None:
            guid = (
                "{"
                + squid_match.group(1)[::-1]
                + "-"
                + squid_match.group(2)[::-1]
                + "-"
                + squid_match.group(3)[::-1]
                + "-"
                + squid_match.group(4)[::-1]
                + squid_match.group(5)[::-1]
                + "-"
            )
            for index in range(6, 12):
                guid += squid_match.group(index)[::-1]
            guid += "}"
        return guid

    @staticmethod
    def __one_equals_true(value):
        """
        Test for ``1`` as a number or a string and return ``True`` if it is.

        Args:
            value: string or number or None.

        Returns:
            bool: ``True`` if 1 otherwise ``False``.
        """
        if isinstance(value, int) and value == 1:
            return True
        elif (
            isinstance(value, str)
            and re.match(r"\d+", value, flags=re.IGNORECASE + re.UNICODE) is not None
            and str(value) == "1"
        ):
            return True
        return False

    @staticmethod
    def __reg_query_value(handle, value_name):
        """
        Calls RegQueryValueEx

        If PY2 ensure unicode string and expand REG_EXPAND_SZ before returning
        Remember to catch not found exceptions when calling.

        Args:
            handle (object): open registry handle.
            value_name (str): Name of the value you wished returned

        Returns:
            tuple: type, value
        """
        # item_value, item_type = win32api.RegQueryValueEx(self.__reg_uninstall_handle, value_name)
        item_value, item_type = win32api.RegQueryValueEx(
            handle, value_name
        )  # pylint: disable=no-member
        if item_type == win32con.REG_EXPAND_SZ:
            # expects Unicode input
            win32api.ExpandEnvironmentStrings(item_value)  # pylint: disable=no-member
            item_type = win32con.REG_SZ
        return item_value, item_type

    @property
    def install_time(self):
        """
        Return the install time, or provide an estimate of install time.

        Installers or even self upgrading software must/should update the date
        held within InstallDate field when they change versions. Some installers
        do not set ``InstallDate`` at all so we use the last modified time on the
        registry key.

        Returns:
            int: Seconds since 1970 UTC.
        """
        time1970 = self.__mod_time1970  # time of last resort
        try:
            # pylint: disable=no-member
            date_string, item_type = win32api.RegQueryValueEx(
                self.__reg_uninstall_handle, "InstallDate"
            )
        except pywintypes.error as exc:  # pylint: disable=no-member
            if exc.winerror == winerror.ERROR_FILE_NOT_FOUND:
                return time1970  # i.e. use time of last resort
            else:
                raise

        if item_type == win32con.REG_SZ:
            try:
                date_object = datetime.datetime.strptime(date_string, "%Y%m%d")
                time1970 = time.mktime(date_object.timetuple())
            except ValueError:  # date format is not correct
                pass

        return time1970

    def get_install_value(self, value_name, wanted_type=None):
        """
        For the uninstall section of the registry return the name value.

        Args:
            value_name (str): Registry value name.
            wanted_type (str):
                The type of value wanted if the type does not match
                None is return. wanted_type support values are
                ``str`` ``int`` ``list`` ``bytes``.

        Returns:
            value: Value requested or None if not found.
        """
        try:
            item_value, item_type = self.__reg_query_value(
                self.__reg_uninstall_handle, value_name
            )
        except pywintypes.error as exc:  # pylint: disable=no-member
            if exc.winerror == winerror.ERROR_FILE_NOT_FOUND:
                # Not Found
                return None
            raise

        if wanted_type and item_type not in self.__reg_types[wanted_type]:
            item_value = None

        return item_value

    def is_install_true(self, key):
        """
        For the uninstall section check if name value is ``1``.

        Args:
            value_name (str): Registry value name.

        Returns:
            bool: ``True`` if ``1`` otherwise ``False``.
        """
        return self.__one_equals_true(self.get_install_value(key))

    def get_product_value(self, value_name, wanted_type=None):
        """
        For the product section of the registry return the name value.

        Args:
            value_name (str): Registry value name.
            wanted_type (str):
                The type of value wanted if the type does not match
                None is return. wanted_type support values are
                ``str`` ``int`` ``list`` ``bytes``.

        Returns:
            value: Value requested or ``None`` if not found.
        """
        if not self.__reg_products_handle:
            return None
        subkey, search_value_name = os.path.split(value_name)
        try:
            if subkey:

                handle = win32api.RegOpenKeyEx(  # pylint: disable=no-member
                    self.__reg_products_handle,
                    subkey,
                    0,
                    win32con.KEY_READ | self.__reg_32bit_access,
                )
                item_value, item_type = self.__reg_query_value(
                    handle, search_value_name
                )
                win32api.RegCloseKey(handle)  # pylint: disable=no-member
            else:
                item_value, item_type = win32api.RegQueryValueEx(
                    self.__reg_products_handle, value_name
                )  # pylint: disable=no-member
        except pywintypes.error as exc:  # pylint: disable=no-member
            if exc.winerror == winerror.ERROR_FILE_NOT_FOUND:
                # Not Found
                return None
            raise

        if wanted_type and item_type not in self.__reg_types[wanted_type]:
            item_value = None
        return item_value

    @property
    def upgrade_code(self):
        """
        For installers which follow the Microsoft Installer standard, returns
        the ``Upgrade code``.

        Returns:
            value (str): ``Upgrade code`` GUID for installed software.
        """
        if not self.__squid:
            # Must have a valid squid for an upgrade code to exist
            return ""

        # GUID/SQUID are unique, so it does not matter if they are 32bit or
        # 64bit or user install so all items are cached into a single dict
        have_scan_key = "{}\\{}\\{}".format(
            self.__reg_hive, self.__reg_upgradecode_path, self.__reg_32bit
        )
        if not self.__upgrade_codes or self.__reg_key_guid not in self.__upgrade_codes:
            # Read in the upgrade codes in this section of the registry.
            try:
                uc_handle = win32api.RegOpenKeyEx(
                    getattr(win32con, self.__reg_hive),  # pylint: disable=no-member
                    self.__reg_upgradecode_path,
                    0,
                    win32con.KEY_READ | self.__reg_32bit_access,
                )
            except pywintypes.error as exc:  # pylint: disable=no-member
                if exc.winerror == winerror.ERROR_FILE_NOT_FOUND:
                    # Not Found
                    log.warning(
                        "Not Found %s\\%s 32bit %s",
                        self.__reg_hive,
                        self.__reg_upgradecode_path,
                        self.__reg_32bit,
                    )
                    return ""
                raise
            squid_upgrade_code_all, _, _, suc_pytime = zip(
                *win32api.RegEnumKeyEx(uc_handle)
            )  # pylint: disable=no-member

            # Check if we have already scanned these upgrade codes before, and also
            # check if they have been updated in the registry since last time we scanned.
            if (
                have_scan_key in self.__upgrade_code_have_scan
                and self.__upgrade_code_have_scan[have_scan_key]
                == (
                    squid_upgrade_code_all,
                    suc_pytime,
                )
            ):
                log.debug(
                    "Scan skipped for upgrade codes, no changes (%s)", have_scan_key
                )
                return ""  # we have scanned this before and no new changes.

            # Go into each squid upgrade code and find all the related product codes.
            log.debug("Scan for upgrade codes (%s) for product codes", have_scan_key)
            for upgrade_code_squid in squid_upgrade_code_all:
                upgrade_code_guid = self.__squid_to_guid(upgrade_code_squid)
                pc_handle = win32api.RegOpenKeyEx(
                    uc_handle,  # pylint: disable=no-member
                    upgrade_code_squid,
                    0,
                    win32con.KEY_READ | self.__reg_32bit_access,
                )
                _, pc_val_count, _ = win32api.RegQueryInfoKey(
                    pc_handle
                )  # pylint: disable=no-member
                for item_index in range(pc_val_count):
                    product_code_guid = self.__squid_to_guid(
                        win32api.RegEnumValue(pc_handle, item_index)[0]
                    )  # pylint: disable=no-member
                    if product_code_guid:
                        self.__upgrade_codes[product_code_guid] = upgrade_code_guid
                win32api.RegCloseKey(pc_handle)  # pylint: disable=no-member

            win32api.RegCloseKey(uc_handle)  # pylint: disable=no-member
            self.__upgrade_code_have_scan[have_scan_key] = (
                squid_upgrade_code_all,
                suc_pytime,
            )

        return self.__upgrade_codes.get(self.__reg_key_guid, "")

    @property
    def list_patches(self):
        """
        For installers which follow the Microsoft Installer standard, returns
        a list of patches applied.

        Returns:
            value (list): Long name of the patch.
        """
        if not self.__squid:
            # Must have a valid squid for an upgrade code to exist
            return []

        if self.__patch_list is None:
            # Read in the upgrade codes in this section of the reg.
            try:
                pat_all_handle = win32api.RegOpenKeyEx(
                    getattr(win32con, self.__reg_hive),  # pylint: disable=no-member
                    self.__reg_patches_path,
                    0,
                    win32con.KEY_READ | self.__reg_32bit_access,
                )
            except pywintypes.error as exc:  # pylint: disable=no-member
                if exc.winerror == winerror.ERROR_FILE_NOT_FOUND:
                    # Not Found
                    log.warning(
                        "Not Found %s\\%s 32bit %s",
                        self.__reg_hive,
                        self.__reg_patches_path,
                        self.__reg_32bit,
                    )
                    return []
                raise

            pc_sub_key_cnt, _, _ = win32api.RegQueryInfoKey(
                pat_all_handle
            )  # pylint: disable=no-member
            if not pc_sub_key_cnt:
                return []
            squid_patch_all, _, _, _ = zip(
                *win32api.RegEnumKeyEx(pat_all_handle)
            )  # pylint: disable=no-member

            ret = []
            # Scan the patches for the DisplayName of active patches.
            for patch_squid in squid_patch_all:
                try:
                    patch_squid_handle = (
                        win32api.RegOpenKeyEx(  # pylint: disable=no-member
                            pat_all_handle,
                            patch_squid,
                            0,
                            win32con.KEY_READ | self.__reg_32bit_access,
                        )
                    )
                    (
                        patch_display_name,
                        patch_display_name_type,
                    ) = self.__reg_query_value(patch_squid_handle, "DisplayName")
                    patch_state, patch_state_type = self.__reg_query_value(
                        patch_squid_handle, "State"
                    )
                    if (
                        patch_state_type != win32con.REG_DWORD
                        or not isinstance(patch_state_type, int)
                        or patch_state != 1
                        or patch_display_name_type  # 1 is Active, 2 is Superseded/Obsolute
                        != win32con.REG_SZ
                    ):
                        continue
                    win32api.RegCloseKey(
                        patch_squid_handle
                    )  # pylint: disable=no-member
                    ret.append(patch_display_name)
                except pywintypes.error as exc:  # pylint: disable=no-member
                    if exc.winerror == winerror.ERROR_FILE_NOT_FOUND:
                        log.debug("skipped patch, not found %s", patch_squid)
                        continue
                    raise

        return ret

    @property
    def registry_path_text(self):
        """
        Returns the uninstall path this object is associated with.

        Returns:
            str: <hive>\\<uninstall registry entry>
        """
        return "{}\\{}".format(self.__reg_hive, self.__reg_uninstall_path)

    @property
    def registry_path(self):
        """
        Returns the uninstall path this object is associated with.

        Returns:
            tuple: hive, uninstall registry entry path.
        """
        return (self.__reg_hive, self.__reg_uninstall_path)

    @property
    def guid(self):
        """
        Return GUID or Key.

        Returns:
            str: GUID or Key
        """
        return self.__reg_key_guid

    @property
    def squid(self):
        """
        Return SQUID of the GUID if a valid GUID.

        Returns:
            str: GUID
        """
        return self.__squid

    @property
    def package_code(self):
        """
        Return package code of the software.

        Returns:
            str: GUID
        """
        return self.__squid_to_guid(self.get_product_value("PackageCode"))

    @property
    def version_binary(self):
        """
        Return version number which is stored in binary format.

        Returns:
            str: <major 0-255>.<minior 0-255>.<build 0-65535> or None if not found
        """
        # Under MSI 'Version' is a 'REG_DWORD' which then sets other registry
        # values like DisplayVersion to x.x.x to the same value.
        # However not everyone plays by the rules, so we need to check first.
        # version_binary_data will be None if the reg value does not exist.
        # Some installs set 'Version' to REG_SZ (string) which is not
        # the MSI standard
        try:
            item_value, item_type = self.__reg_query_value(
                self.__reg_uninstall_handle, "version"
            )
        except pywintypes.error as exc:  # pylint: disable=no-member
            if exc.winerror == winerror.ERROR_FILE_NOT_FOUND:
                # Not Found
                return "", ""

        version_binary_text = ""
        version_src = ""
        if item_value:
            if item_type == win32con.REG_DWORD:
                if isinstance(item_value, int):
                    version_binary_raw = item_value
                if version_binary_raw:
                    # Major.Minor.Build
                    version_binary_text = "{}.{}.{}".format(
                        version_binary_raw >> 24 & 0xFF,
                        version_binary_raw >> 16 & 0xFF,
                        version_binary_raw & 0xFFFF,
                    )
                    version_src = "binary-version"

            elif (
                item_type == win32con.REG_SZ
                and isinstance(item_value, str)
                and self.__version_pattern.match(item_value) is not None
            ):
                # Hey, version should be a int/REG_DWORD, an installer has set
                # it to a string
                version_binary_text = item_value.strip(" ")
                version_src = "binary-version (string)"

        return (version_binary_text, version_src)


class WinSoftware:
    """
    Point in time snapshot of the software and components installed on
    a system.

    Attributes:
        None

    :codeauthor: Damon Atkins <https://github.com/damon-atkins>
    """

    __sid_pattern = re.compile(r"^S-\d-\d-\d+$|^S-\d-\d-\d+-\d+-\d+-\d+-\d+$")
    __whitespace_pattern = re.compile(r"^\s*$", flags=re.UNICODE)
    # items we copy out of the uninstall section of the registry without further processing
    __uninstall_search_list = [
        ("url", "str", ["URLInfoAbout", "HelpLink", "MoreInfoUrl", "UrlUpdateInfo"]),
        ("size", "int", ["Size", "EstimatedSize"]),
        ("win_comments", "str", ["Comments"]),
        ("win_release_type", "str", ["ReleaseType"]),
        ("win_product_id", "str", ["ProductID"]),
        ("win_product_codes", "str", ["ProductCodes"]),
        ("win_package_refs", "str", ["PackageRefs"]),
        ("win_install_location", "str", ["InstallLocation"]),
        ("win_install_src_dir", "str", ["InstallSource"]),
        ("win_parent_pkg_uid", "str", ["ParentKeyName"]),
        ("win_parent_name", "str", ["ParentDisplayName"]),
    ]
    # items we copy out of the products section of the registry without further processing
    __products_search_list = [
        ("win_advertise_flags", "int", ["AdvertiseFlags"]),
        ("win_redeployment_flags", "int", ["DeploymentFlags"]),
        ("win_instance_type", "int", ["InstanceType"]),
        ("win_package_name", "str", ["SourceList\\PackageName"]),
    ]

    def __init__(self, version_only=False, user_pkgs=False, pkg_obj=None):
        """
        Point in time snapshot of the software and components installed on
        a system.

        Args:
            version_only (bool): Provide list of versions installed instead of detail.
            user_pkgs (bool): Include software/components installed with user space.
            pkg_obj (object):
                If None (default) return default package naming standard and use
                default version capture methods (``DisplayVersion`` then
                ``Version``, otherwise ``0.0.0.0``)
        """
        self.__pkg_obj = pkg_obj  # must be set before calling get_software_details
        self.__version_only = version_only
        self.__reg_software = {}
        self.__get_software_details(user_pkgs=user_pkgs)
        self.__pkg_cnt = len(self.__reg_software)
        self.__iter_list = None

    @property
    def data(self):
        """
        Returns the raw data

        Returns:
            dict: contents of the dict are dependent on the parameters passed
                when the class was initiated.
        """
        return self.__reg_software

    @property
    def version_only(self):
        """
        Returns True if class initiated with ``version_only=True``

        Returns:
            bool: The value of ``version_only``
        """
        return self.__version_only

    def __len__(self):
        """
        Returns total number of software/components installed.

        Returns:
            int: total number of software/components installed.
        """
        return self.__pkg_cnt

    def __getitem__(self, pkg_id):
        """
        Returns information on a package.

        Args:
            pkg_id (str): Package Id of the software/component

        Returns:
            dict or list: List if ``version_only`` is ``True`` otherwise dict
        """
        if pkg_id in self.__reg_software:
            return self.__reg_software[pkg_id]
        else:
            raise KeyError(pkg_id)

    def __iter__(self):
        """
        Standard interation class initialisation over package information.
        """
        if self.__iter_list is not None:
            raise RuntimeError("Can only perform one iter at a time")
        self.__iter_list = collections.deque(sorted(self.__reg_software.keys()))
        return self

    def __next__(self):
        """
        Returns next Package Id.

        Returns:
            str: Package Id
        """
        try:
            return self.__iter_list.popleft()
        except IndexError:
            self.__iter_list = None
            raise StopIteration

    def next(self):
        """
        Returns next Package Id.

        Returns:
            str: Package Id
        """
        return self.__next__()

    def get(self, pkg_id, default_value=None):
        """
        Returns information on a package.

        Args:
            pkg_id (str): Package Id of the software/component.
            default_value: Value to return when the Package Id is not found.

        Returns:
            dict or list: List if ``version_only`` is ``True`` otherwise dict
        """
        return self.__reg_software.get(pkg_id, default_value)

    @staticmethod
    def __oldest_to_latest_version(ver1, ver2):
        """
        Used for sorting version numbers oldest to latest
        """
        return 1 if LooseVersion(ver1) > LooseVersion(ver2) else -1

    @staticmethod
    def __latest_to_oldest_version(ver1, ver2):
        """
        Used for sorting version numbers, latest to oldest
        """
        return 1 if LooseVersion(ver1) < LooseVersion(ver2) else -1

    def pkg_version_list(self, pkg_id):
        """
        Returns information on a package.

        Args:
            pkg_id (str): Package Id of the software/component.

        Returns:
            list: List of version numbers installed.
        """
        pkg_data = self.__reg_software.get(pkg_id, None)
        if not pkg_data:
            return []

        if isinstance(pkg_data, list):
            # raw data is 'pkgid': [sorted version list]
            return pkg_data  # already sorted oldest to newest

        # Must be a dict or OrderDict, and contain full details
        installed_versions = list(pkg_data.get("version").keys())
        return sorted(
            installed_versions, key=cmp_to_key(self.__oldest_to_latest_version)
        )

    def pkg_version_latest(self, pkg_id):
        """
        Returns a package latest version installed out of all the versions
        currently installed.

        Args:
            pkg_id (str): Package Id of the software/component.

        Returns:
            str: Latest/Newest version number installed.
        """
        return self.pkg_version_list(pkg_id)[-1]

    def pkg_version_oldest(self, pkg_id):
        """
        Returns a package oldest version installed out of all the versions
        currently installed.

        Args:
            pkg_id (str): Package Id of the software/component.

        Returns:
            str: Oldest version number installed.
        """
        return self.pkg_version_list(pkg_id)[0]

    @staticmethod
    def __sid_to_username(sid):
        """
        Provided with a valid Windows Security Identifier (SID) and returns a Username

        Args:
            sid (str): Security Identifier (SID).

        Returns:
            str: Username in the format of username@realm or username@computer.
        """
        if sid is None or sid == "":
            return ""
        try:
            sid_bin = win32security.GetBinarySid(sid)  # pylint: disable=no-member
        except pywintypes.error as exc:  # pylint: disable=no-member
            raise ValueError(
                "pkg: Software owned by {} is not valid: [{}] {}".format(
                    sid, exc.winerror, exc.strerror
                )
            )
        try:
            name, domain, _account_type = win32security.LookupAccountSid(
                None, sid_bin
            )  # pylint: disable=no-member
            user_name = "{}\\{}".format(domain, name)
        except pywintypes.error as exc:  # pylint: disable=no-member
            # if user does not exist...
            # winerror.ERROR_NONE_MAPPED = No mapping between account names and
            # security IDs was carried out.
            if exc.winerror == winerror.ERROR_NONE_MAPPED:  # 1332
                # As the sid is from the registry it should be valid
                # even if it cannot be lookedup, so the sid is returned
                return sid
            else:
                raise ValueError(
                    "Failed looking up sid '{}' username: [{}] {}".format(
                        sid, exc.winerror, exc.strerror
                    )
                )
        try:
            user_principal = win32security.TranslateName(  # pylint: disable=no-member
                user_name,
                win32api.NameSamCompatible,  # pylint: disable=no-member
                win32api.NameUserPrincipal,
            )  # pylint: disable=no-member
        except pywintypes.error as exc:  # pylint: disable=no-member
            # winerror.ERROR_NO_SUCH_DOMAIN The specified domain either does not exist
            # or could not be contacted, computer may not be part of a domain also
            # winerror.ERROR_INVALID_DOMAINNAME The format of the specified domain name is
            # invalid. e.g. S-1-5-19 which is a local account
            # winerror.ERROR_NONE_MAPPED No mapping between account names and security IDs was done.
            if exc.winerror in (
                winerror.ERROR_NO_SUCH_DOMAIN,
                winerror.ERROR_INVALID_DOMAINNAME,
                winerror.ERROR_NONE_MAPPED,
            ):
                return "{}@{}".format(name.lower(), domain.lower())
            else:
                raise
        return user_principal

    def __software_to_pkg_id(self, publisher, name, is_component, is_32bit):
        """
        Determine the Package ID of a software/component using the
        software/component ``publisher``, ``name``, whether its a software or a
        component, and if its 32bit or 64bit archiecture.

        Args:
            publisher (str): Publisher of the software/component.
            name (str): Name of the software.
            is_component (bool): True if package is a component.
            is_32bit (bool): True if the software/component is 32bit architecture.

        Returns:
            str: Package Id
        """
        if publisher:
            # remove , and lowercase as , are used as list separators
            pub_lc = publisher.replace(",", "").lower()

        else:
            # remove , and lowercase
            pub_lc = "NoValue"  # Capitals/Special Value

        if name:
            name_lc = name.replace(",", "").lower()
            # remove ,   OR we do the URL Encode on chars we do not want e.g. \\ and ,
        else:
            name_lc = "NoValue"  # Capitals/Special Value

        if is_component:
            soft_type = "comp"
        else:
            soft_type = "soft"

        if is_32bit:
            soft_type += "32"  # Tag only the 32bit only

        default_pkg_id = pub_lc + "\\\\" + name_lc + "\\\\" + soft_type

        # Check to see if class was initialise with pkg_obj with a method called
        # to_pkg_id, and if so use it for the naming standard instead of the default
        if self.__pkg_obj and hasattr(self.__pkg_obj, "to_pkg_id"):
            pkg_id = self.__pkg_obj.to_pkg_id(publisher, name, is_component, is_32bit)
            if pkg_id:
                return pkg_id

        return default_pkg_id

    def __version_capture_slp(
        self, pkg_id, version_binary, version_display, display_name
    ):
        """
        This returns the version and where the version string came from, based on instructions
        under ``version_capture``, if ``version_capture`` is missing, it defaults to
        value of display-version.

        Args:
            pkg_id (str): Publisher of the software/component.
            version_binary (str): Name of the software.
            version_display (str): True if package is a component.
            display_name (str): True if the software/component is 32bit architecture.

        Returns:
            str: Package Id
        """
        if self.__pkg_obj and hasattr(self.__pkg_obj, "version_capture"):
            version_str, src, version_user_str = self.__pkg_obj.version_capture(
                pkg_id, version_binary, version_display, display_name
            )
            if src != "use-default" and version_str and src:
                return version_str, src, version_user_str
            elif src != "use-default":
                raise ValueError(
                    "version capture within object '{}' failed "
                    "for pkg id: '{}' it returned '{}' '{}' "
                    "'{}'".format(
                        str(self.__pkg_obj),
                        pkg_id,
                        version_str,
                        src,
                        version_user_str,
                    )
                )

        # If self.__pkg_obj.version_capture() not defined defaults to using
        # version_display and if not valid then use version_binary, and as a last
        # result provide the version 0.0.0.0.0 to indicate version string was not determined.
        if (
            version_display
            and re.match(r"\d+", version_display, flags=re.IGNORECASE + re.UNICODE)
            is not None
        ):
            version_str = version_display
            src = "display-version"
        elif (
            version_binary
            and re.match(r"\d+", version_binary, flags=re.IGNORECASE + re.UNICODE)
            is not None
        ):
            version_str = version_binary
            src = "version-binary"
        else:
            src = "none"
            version_str = "0.0.0.0.0"
        # return version str, src of the version, "user" interpretation of the version
        # which by default is version_str
        return version_str, src, version_str

    def __collect_software_info(self, sid, key_software, use_32bit):
        """
        Update data with the next software found
        """

        reg_soft_info = RegSoftwareInfo(key_software, sid, use_32bit)

        # Check if the registry entry is a valid.
        # a) Cannot manage software without at least a display name
        display_name = reg_soft_info.get_install_value("DisplayName", wanted_type="str")
        if display_name is None or self.__whitespace_pattern.match(display_name):
            return

        # b) make sure its not an 'Hotfix', 'Update Rollup', 'Security Update', 'ServicePack'
        # General this is software which pre dates Windows 10
        default_value = reg_soft_info.get_install_value("", wanted_type="str")
        release_type = reg_soft_info.get_install_value("ReleaseType", wanted_type="str")

        if (
            re.match(
                r"^{.*\}\.KB\d{6,}$", key_software, flags=re.IGNORECASE + re.UNICODE
            )
            is not None
            or (default_value and default_value.startswith(("KB", "kb", "Kb")))
            or (
                release_type
                and release_type
                in ("Hotfix", "Update Rollup", "Security Update", "ServicePack")
            )
        ):
            log.debug("skipping hotfix/update/service pack %s", key_software)
            return

        # if NoRemove exists we would expect their to be no UninstallString
        uninstall_no_remove = reg_soft_info.is_install_true("NoRemove")
        uninstall_string = reg_soft_info.get_install_value("UninstallString")
        uninstall_quiet_string = reg_soft_info.get_install_value("QuietUninstallString")
        uninstall_modify_path = reg_soft_info.get_install_value("ModifyPath")
        windows_installer = reg_soft_info.is_install_true("WindowsInstaller")
        system_component = reg_soft_info.is_install_true("SystemComponent")
        publisher = reg_soft_info.get_install_value("Publisher", wanted_type="str")

        # UninstallString is optional if the installer is "windows installer"/MSI
        # However for it to appear in Control-Panel -> Program and Features -> Uninstall or change a program
        # the UninstallString needs to be set or ModifyPath set
        if (
            uninstall_string is None
            and uninstall_quiet_string is None
            and uninstall_modify_path is None
            and (not windows_installer)
        ):
            return

        # Question: If uninstall string is not set and windows_installer should we set it
        # Question: if uninstall_quiet is not set .......

        if sid:
            username = self.__sid_to_username(sid)
        else:
            username = None

        # We now have a valid software install or a system component
        pkg_id = self.__software_to_pkg_id(
            publisher, display_name, system_component, use_32bit
        )
        version_binary, version_src = reg_soft_info.version_binary
        version_display = reg_soft_info.get_install_value(
            "DisplayVersion", wanted_type="str"
        )
        # version_capture is what the slp defines, the result overrides. Question: maybe it should error if it fails?
        (version_text, version_src, user_version) = self.__version_capture_slp(
            pkg_id, version_binary, version_display, display_name
        )
        if not user_version:
            user_version = version_text

        # log.trace('%s\\%s ver:%s src:%s', username or 'SYSTEM', pkg_id, version_text, version_src)

        if username:
            dict_key = "{};{}".format(
                username, pkg_id
            )  # Use ; as its not a valid hostnmae char
        else:
            dict_key = pkg_id

        # Guessing the architecture http://helpnet.flexerasoftware.com/isxhelp21/helplibrary/IHelp64BitSupport.htm
        # A 32 bit installed.exe can install a 64 bit app, but for it to write to 64bit reg it will
        # need to use WOW. So the following is a bit of a guess

        if self.__version_only:
            # package name and package version list, are the only info being return
            if dict_key in self.__reg_software:
                if version_text not in self.__reg_software[dict_key]:
                    # Not expecting the list to be big, simple search and insert
                    insert_point = 0
                    for ver_item in self.__reg_software[dict_key]:
                        if LooseVersion(version_text) <= LooseVersion(ver_item):
                            break
                        insert_point += 1
                    self.__reg_software[dict_key].insert(insert_point, version_text)
                else:
                    # This code is here as it can happen, especially if the
                    # package id provided by pkg_obj is simple.
                    log.debug(
                        "Found extra entries for '%s' with same version "
                        "'%s', skipping entry '%s'",
                        dict_key,
                        version_text,
                        key_software,
                    )
            else:
                self.__reg_software[dict_key] = [version_text]

            return

        if dict_key in self.__reg_software:
            data = self.__reg_software[dict_key]
        else:
            data = self.__reg_software[dict_key] = OrderedDict()

        if sid:
            # HKEY_USERS has no 32bit and 64bit view like HKEY_LOCAL_MACHINE
            data.update({"arch": "unknown"})
        else:
            arch_str = "x86" if use_32bit else "x64"
            if "arch" in data:
                if data["arch"] != arch_str:
                    data["arch"] = "many"
            else:
                data.update({"arch": arch_str})

        if publisher:
            if "vendor" in data:
                if data["vendor"].lower() != publisher.lower():
                    data["vendor"] = "many"
            else:
                data["vendor"] = publisher

        if "win_system_component" in data:
            if data["win_system_component"] != system_component:
                data["win_system_component"] = None
        else:
            data["win_system_component"] = system_component

        data.update({"win_version_src": version_src})

        data.setdefault("version", {})
        if version_text in data["version"]:
            if "win_install_count" in data["version"][version_text]:
                data["version"][version_text]["win_install_count"] += 1
            else:
                # This is only defined when we have the same item already
                data["version"][version_text]["win_install_count"] = 2
        else:
            data["version"][version_text] = OrderedDict()

        version_data = data["version"][version_text]
        version_data.update({"win_display_name": display_name})
        if uninstall_string:
            version_data.update({"win_uninstall_cmd": uninstall_string})
        if uninstall_quiet_string:
            version_data.update({"win_uninstall_quiet_cmd": uninstall_quiet_string})
        if uninstall_no_remove:
            version_data.update({"win_uninstall_no_remove": uninstall_no_remove})

        version_data.update({"win_product_code": key_software})
        if version_display:
            version_data.update({"win_version_display": version_display})
        if version_binary:
            version_data.update({"win_version_binary": version_binary})
        if user_version:
            version_data.update({"win_version_user": user_version})

        # Determine Installer Product
        #   'NSIS:Language'
        #   'Inno Setup: Setup Version'
        if windows_installer or (
            uninstall_string
            and re.search(
                r"MsiExec.exe\s|MsiExec\s",
                uninstall_string,
                flags=re.IGNORECASE + re.UNICODE,
            )
        ):
            version_data.update({"win_installer_type": "winmsi"})
        elif re.match(r"InstallShield_", key_software, re.IGNORECASE) is not None or (
            uninstall_string
            and (
                re.search(
                    r"InstallShield", uninstall_string, flags=re.IGNORECASE + re.UNICODE
                )
                is not None
                or re.search(
                    r"isuninst\.exe.*\.isu",
                    uninstall_string,
                    flags=re.IGNORECASE + re.UNICODE,
                )
                is not None
            )
        ):
            version_data.update({"win_installer_type": "installshield"})
        elif key_software.endswith("_is1") and reg_soft_info.get_install_value(
            "Inno Setup: Setup Version", wanted_type="str"
        ):
            version_data.update({"win_installer_type": "inno"})
        elif uninstall_string and re.search(
            r".*\\uninstall.exe|.*\\uninst.exe",
            uninstall_string,
            flags=re.IGNORECASE + re.UNICODE,
        ):
            version_data.update({"win_installer_type": "nsis"})
        else:
            version_data.update({"win_installer_type": "unknown"})

        # Update dict with information retrieved so far for detail results to be return
        # Do not add fields which are blank.
        language_number = reg_soft_info.get_install_value("Language")
        if (
            isinstance(language_number, int)
            and language_number in locale.windows_locale
        ):
            version_data.update(
                {"win_language": locale.windows_locale[language_number]}
            )

        package_code = reg_soft_info.package_code
        if package_code:
            version_data.update({"win_package_code": package_code})

        upgrade_code = reg_soft_info.upgrade_code
        if upgrade_code:
            version_data.update({"win_upgrade_code": upgrade_code})

        is_minor_upgrade = reg_soft_info.is_install_true("IsMinorUpgrade")
        if is_minor_upgrade:
            version_data.update({"win_is_minor_upgrade": is_minor_upgrade})

        install_time = reg_soft_info.install_time
        if install_time:
            version_data.update(
                {
                    "install_date": datetime.datetime.fromtimestamp(
                        install_time
                    ).isoformat()
                }
            )
            version_data.update({"install_date_time_t": int(install_time)})

        for infokey, infotype, regfield_list in self.__uninstall_search_list:
            for regfield in regfield_list:
                strvalue = reg_soft_info.get_install_value(
                    regfield, wanted_type=infotype
                )
                if strvalue:
                    version_data.update({infokey: strvalue})
                    break

        for infokey, infotype, regfield_list in self.__products_search_list:
            for regfield in regfield_list:
                data = reg_soft_info.get_product_value(regfield, wanted_type=infotype)
                if data is not None:
                    version_data.update({infokey: data})
                    break
        patch_list = reg_soft_info.list_patches
        if patch_list:
            version_data.update({"win_patches": patch_list})

    def __get_software_details(self, user_pkgs):
        """
        This searches the uninstall keys in the registry to find
        a match in the sub keys, it will return a dict with the
        display name as the key and the version as the value
        .. sectionauthor:: Damon Atkins <https://github.com/damon-atkins>
        .. versionadded:: 2016.11.0
        """

        # FUNCTION MAIN CODE #
        # Search 64bit, on 64bit platform, on 32bit its ignored.
        if platform.architecture()[0] == "32bit":
            # Handle Python 32bit on 64&32 bit platform and Python 64bit
            if win32process.IsWow64Process():  # pylint: disable=no-member
                # 32bit python on a 64bit platform
                use_32bit_lookup = {True: 0, False: win32con.KEY_WOW64_64KEY}
                arch_list = [True, False]
            else:
                # 32bit python on a 32bit platform
                use_32bit_lookup = {True: 0, False: None}
                arch_list = [True]

        else:
            # Python is 64bit therefore most be on 64bit System.
            use_32bit_lookup = {True: win32con.KEY_WOW64_32KEY, False: 0}
            arch_list = [True, False]

        # Process software installed for the machine i.e. all users.
        for arch_flag in arch_list:
            key_search = "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
            log.debug("SYSTEM processing 32bit:%s", arch_flag)
            handle = win32api.RegOpenKeyEx(  # pylint: disable=no-member
                win32con.HKEY_LOCAL_MACHINE,
                key_search,
                0,
                win32con.KEY_READ | use_32bit_lookup[arch_flag],
            )
            reg_key_all, _, _, _ = zip(
                *win32api.RegEnumKeyEx(handle)
            )  # pylint: disable=no-member
            win32api.RegCloseKey(handle)  # pylint: disable=no-member
            for reg_key in reg_key_all:
                self.__collect_software_info(None, reg_key, arch_flag)

        if not user_pkgs:
            return

        # Process software installed under all USERs, this adds significate processing time.
        # There is not 32/64 bit registry redirection under user tree.
        log.debug("Processing user software... please wait")
        handle_sid = win32api.RegOpenKeyEx(  # pylint: disable=no-member
            win32con.HKEY_USERS, "", 0, win32con.KEY_READ
        )
        sid_all = []
        for index in range(
            win32api.RegQueryInfoKey(handle_sid)[0]
        ):  # pylint: disable=no-member
            sid_all.append(
                win32api.RegEnumKey(handle_sid, index)
            )  # pylint: disable=no-member

        for sid in sid_all:
            if (
                self.__sid_pattern.match(sid) is not None
            ):  # S-1-5-18 needs to be ignored?
                user_uninstall_path = "{}\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall".format(
                    sid
                )
                try:
                    handle = win32api.RegOpenKeyEx(  # pylint: disable=no-member
                        handle_sid, user_uninstall_path, 0, win32con.KEY_READ
                    )
                except pywintypes.error as exc:  # pylint: disable=no-member
                    if exc.winerror == winerror.ERROR_FILE_NOT_FOUND:
                        # Not Found Uninstall under SID
                        log.debug("Not Found %s", user_uninstall_path)
                        continue
                    else:
                        raise
                try:
                    reg_key_all, _, _, _ = zip(
                        *win32api.RegEnumKeyEx(handle)
                    )  # pylint: disable=no-member
                except ValueError:
                    log.debug("No Entries Found %s", user_uninstall_path)
                    reg_key_all = []
                win32api.RegCloseKey(handle)  # pylint: disable=no-member
                for reg_key in reg_key_all:
                    self.__collect_software_info(sid, reg_key, False)
        win32api.RegCloseKey(handle_sid)  # pylint: disable=no-member
        return


def __main():
    """This module can also be run directly for testing
    Args:
        detail|list : Provide ``detail`` or version ``list``.
        system|system+user: System installed and System and User installs.
    """
    if len(sys.argv) < 3:
        sys.stderr.write(
            "usage: {} <detail|list> <system|system+user>\n".format(sys.argv[0])
        )
        sys.exit(64)
    user_pkgs = False
    version_only = False
    if str(sys.argv[1]) == "list":
        version_only = True
    if str(sys.argv[2]) == "system+user":
        user_pkgs = True
    import timeit

    import salt.utils.json

    def run():
        """
        Main run code, when this module is run directly
        """
        pkg_list = WinSoftware(user_pkgs=user_pkgs, version_only=version_only)
        print(
            salt.utils.json.dumps(pkg_list.data, sort_keys=True, indent=4)
        )  # pylint: disable=superfluous-parens
        print("Total: {}".format(len(pkg_list)))  # pylint: disable=superfluous-parens

    print(
        "Time Taken: {}".format(timeit.timeit(run, number=1))
    )  # pylint: disable=superfluous-parens


if __name__ == "__main__":
    __main()
