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
"""
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

from salt.utils.versions import Version

# 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 next(self)

    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 Version(ver1) > Version(ver2) else -1

    @staticmethod
    def __latest_to_oldest_version(ver1, ver2):  # pylint: disable=unused-private-member
        """
        Used for sorting version numbers, latest to oldest
        """
        return 1 if Version(ver1) < Version(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 Version(version_text) <= Version(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()
