import distutils.command.bdist_msi
import distutils.errors
import distutils.util
import importlib
import msilib
import os
import re

__all__ = [ "bdist_msi" ]

# force the remove existing products action to happen first since Windows
# installer appears to be braindead and doesn't handle files shared between
# different "products" very well
sequence = msilib.sequence.InstallExecuteSequence
for index, info in enumerate(sequence):
    if info[0] == 'RemoveExistingProducts':
        sequence[index] = (info[0], info[1], 1450)


class bdist_msi(distutils.command.bdist_msi.bdist_msi):
    user_options = distutils.command.bdist_msi.bdist_msi.user_options + [
        ('add-to-path=', None, 'add target dir to PATH environment variable'),
        ('upgrade-code=', None, 'upgrade code to use'),
        ('initial-target-dir=', None, 'initial target directory'),
        ('target-name=', None, 'name of the file to create'),
        ('directories=', None, 'list of 3-tuples of directories to create'),
        ('environment-variables=', None, 'list of environment variables'),
        ('data=', None, 'dictionary of data indexed by table name, and each value is a tuple to include in table'),
        ('summary-data=', None, 'Dictionary of data to include in msi summary data stream.  Allowed keys are "author", "comments", "keywords".'),
        ('product-code=', None, 'product code to use'),
        ('install-icon=', None, 'icon path to add/remove programs '),
        ('all-users=', None, 'installation for all users (or just me)'),
    ]
    x = y = 50
    width = 370
    height = 300
    title = "[ProductName] Setup"
    modeless = 1
    modal = 3

    def add_config(self, fullname):
        if self.add_to_path:
            p = "Path"
            if self.all_users:
                p = "=-*" + p
            msilib.add_data(self.db, 'Environment',
                    [("E_PATH", p, r"[~];[TARGETDIR]", "TARGETDIR")])
        if self.directories:
            msilib.add_data(self.db, "Directory", self.directories)
        if self.environment_variables:
            msilib.add_data(self.db, "Environment", self.environment_variables)
        # This is needed in case the AlwaysInstallElevated policy is set.
        # Otherwise installation will not end up in TARGETDIR.
        msilib.add_data(self.db, "Property", [("SecureCustomProperties", "TARGETDIR")])
        msilib.add_data(self.db, 'CustomAction',
                [("A_SET_TARGET_DIR", 256 + 51, "TARGETDIR",
                        self.initial_target_dir)])
        msilib.add_data(self.db, 'InstallExecuteSequence',
                [("A_SET_TARGET_DIR", 'TARGETDIR=""', 401)])
        msilib.add_data(self.db, 'InstallUISequence',
                [("PrepareDlg", None, 140),
                 ("A_SET_TARGET_DIR", 'TARGETDIR=""', 401),
                 ("SelectDirectoryDlg", "not Installed", 1230),
                 ("MaintenanceTypeDlg",
                        "Installed and not Resume and not Preselected", 1250),
                 ("ProgressDlg", None, 1280)
                ])
        for index, executable in enumerate(self.distribution.executables):
            if executable.shortcutName is not None \
                    and executable.shortcutDir is not None:
                baseName = os.path.basename(executable.targetName)
                msilib.add_data(self.db, "Shortcut",
                        [("S_APP_%s" % index, executable.shortcutDir,
                                executable.shortcutName, "TARGETDIR",
                                "[TARGETDIR]%s" % baseName, None, None, None,
                                None, None, None, None)])
        for tableName, data in self.data.items():
            msilib.add_data(self.db, tableName, data)
        
        # If provided, add data to MSI's summary information stream
        if len(self.summary_data) > 0:
            for k in self.summary_data:
                if k not in ["author", "comments", "keywords"]:
                    raise Exception('Unknown key provided in summary-data: "{}"'.format(k))
            
            summaryInfo = self.db.GetSummaryInformation(5)
            if "author" in self.summary_data:
                summaryInfo.SetProperty(msilib.PID_AUTHOR, self.summary_data["author"])
            if "comments" in self.summary_data:
                summaryInfo.SetProperty(msilib.PID_COMMENTS, self.summary_data["comments"])
            if "keywords" in self.summary_data:
                summaryInfo.SetProperty(msilib.PID_KEYWORDS, self.summary_data["keywords"])
            summaryInfo.Persist()
        
        

    def add_cancel_dialog(self):
        dialog = msilib.Dialog(self.db, "CancelDlg", 50, 10, 260, 85, 3,
                self.title, "No", "No", "No")
        dialog.text("Text", 48, 15, 194, 30, 3,
                "Are you sure you want to cancel [ProductName] installation?")
        button = dialog.pushbutton("Yes", 72, 57, 56, 17, 3, "Yes", "No")
        button.event("EndDialog", "Exit")
        button = dialog.pushbutton("No", 132, 57, 56, 17, 3, "No", "Yes")
        button.event("EndDialog", "Return")

    def add_error_dialog(self):
        dialog = msilib.Dialog(self.db, "ErrorDlg", 50, 10, 330, 101, 65543,
                self.title, "ErrorText", None, None)
        dialog.text("ErrorText", 50, 9, 280, 48, 3, "")
        for text, x in [("No", 120), ("Yes", 240), ("Abort", 0),
                ("Cancel", 42), ("Ignore", 81), ("Ok", 159), ("Retry", 198)]:
            button = dialog.pushbutton(text[0], x, 72, 81, 21, 3, text, None)
            button.event("EndDialog", "Error%s" % text)

    def add_exit_dialog(self):
        dialog = distutils.command.bdist_msi.PyDialog(self.db, "ExitDialog",
                self.x, self.y, self.width, self.height, self.modal,
                self.title, "Finish", "Finish", "Finish")
        dialog.title("Completing the [ProductName] installer")
        dialog.back("< Back", "Finish", active = False)
        dialog.cancel("Cancel", "Back", active = False)
        dialog.text("Description", 15, 235, 320, 20, 0x30003,
                "Click the Finish button to exit the installer.")
        button = dialog.next("Finish", "Cancel", name = "Finish")
        button.event("EndDialog", "Return")

    def add_fatal_error_dialog(self):
        dialog = distutils.command.bdist_msi.PyDialog(self.db, "FatalError",
                self.x, self.y, self.width, self.height, self.modal,
                self.title, "Finish", "Finish", "Finish")
        dialog.title("[ProductName] installer ended prematurely")
        dialog.back("< Back", "Finish", active = False)
        dialog.cancel("Cancel", "Back", active = False)
        dialog.text("Description1", 15, 70, 320, 80, 0x30003,
                "[ProductName] setup ended prematurely because of an error. "
                "Your system has not been modified. To install this program "
                "at a later time, please run the installation again.")
        dialog.text("Description2", 15, 155, 320, 20, 0x30003,
                "Click the Finish button to exit the installer.")
        button = dialog.next("Finish", "Cancel", name = "Finish")
        button.event("EndDialog", "Exit")

    def add_files(self):
        db = self.db
        cab = msilib.CAB("distfiles")
        f = msilib.Feature(db, "default", "Default Feature", "Everything", 1,
                directory="TARGETDIR")
        f.set_current()
        rootdir = os.path.abspath(self.bdist_dir)
        root = msilib.Directory(db, cab, None, rootdir, "TARGETDIR",
                "SourceDir")
        db.Commit()
        todo = [root]
        while todo:
            dir = todo.pop()
            for file in os.listdir(dir.absolute):
                if os.path.isdir(os.path.join(dir.absolute, file)):
                    newDir = msilib.Directory(db, cab, dir, file, file,
                            "%s|%s" % (dir.make_short(file), file))
                    todo.append(newDir)
                else:
                    dir.add_file(file)
        cab.commit(db)

    def add_files_in_use_dialog(self):
        dialog = distutils.command.bdist_msi.PyDialog(self.db, "FilesInUse",
                self.x, self.y, self.width, self.height, 19, self.title,
                "Retry", "Retry", "Retry", bitmap = False)
        dialog.text("Title", 15, 6, 200, 15, 0x30003,
                r"{\DlgFontBold8}Files in Use")
        dialog.text("Description", 20, 23, 280, 20, 0x30003,
                "Some files that need to be updated are currently in use.")
        dialog.text("Text", 20, 55, 330, 50, 3,
                "The following applications are using files that need to be "
                "updated by this setup. Close these applications and then "
                "click Retry to continue the installation or Cancel to exit "
                "it.")
        dialog.control("List", "ListBox", 20, 107, 330, 130, 7,
                "FileInUseProcess", None, None, None)
        button = dialog.back("Exit", "Ignore", name = "Exit")
        button.event("EndDialog", "Exit")
        button = dialog.next("Ignore", "Retry", name = "Ignore")
        button.event("EndDialog", "Ignore")
        button = dialog.cancel("Retry", "Exit", name = "Retry")
        button.event("EndDialog", "Retry")

    def add_maintenance_type_dialog(self):
        dialog = distutils.command.bdist_msi.PyDialog(self.db,
                "MaintenanceTypeDlg", self.x, self.y, self.width, self.height,
                self.modal, self.title, "Next", "Next", "Cancel")
        dialog.title("Welcome to the [ProductName] Setup Wizard")
        dialog.text("BodyText", 15, 63, 330, 42, 3,
                "Select whether you want to repair or remove [ProductName].")
        group = dialog.radiogroup("RepairRadioGroup", 15, 108, 330, 60, 3,
                "MaintenanceForm_Action", "", "Next")
        group.add("Repair", 0, 18, 300, 17, "&Repair [ProductName]")
        group.add("Remove", 0, 36, 300, 17, "Re&move [ProductName]")
        dialog.back("< Back", None, active = False)
        button = dialog.next("Finish", "Cancel")
        button.event("[REINSTALL]", "ALL",
                'MaintenanceForm_Action="Repair"', 5)
        button.event("[Progress1]", "Repairing",
                'MaintenanceForm_Action="Repair"', 6)
        button.event("[Progress2]", "repairs",
                'MaintenanceForm_Action="Repair"', 7)
        button.event("Reinstall", "ALL",
                'MaintenanceForm_Action="Repair"', 8)
        button.event("[REMOVE]", "ALL",
                'MaintenanceForm_Action="Remove"', 11)
        button.event("[Progress1]", "Removing",
                'MaintenanceForm_Action="Remove"', 12)
        button.event("[Progress2]", "removes",
                'MaintenanceForm_Action="Remove"', 13)
        button.event("Remove", "ALL",
                'MaintenanceForm_Action="Remove"', 14)
        button.event("EndDialog", "Return",
                'MaintenanceForm_Action<>"Change"', 20)
        button = dialog.cancel("Cancel", "RepairRadioGroup")
        button.event("SpawnDialog", "CancelDlg")

    def add_prepare_dialog(self):
        dialog = distutils.command.bdist_msi.PyDialog(self.db, "PrepareDlg",
                self.x, self.y, self.width, self.height, self.modeless,
                self.title, "Cancel", "Cancel", "Cancel")
        dialog.text("Description", 15, 70, 320, 40, 0x30003,
                "Please wait while the installer prepares to guide you through "
                "the installation.")
        dialog.title("Welcome to the [ProductName] installer")
        text = dialog.text("ActionText", 15, 110, 320, 20, 0x30003,
                "Pondering...")
        text.mapping("ActionText", "Text")
        text = dialog.text("ActionData", 15, 135, 320, 30, 0x30003, None)
        text.mapping("ActionData", "Text")
        dialog.back("Back", None, active = False)
        dialog.next("Next", None, active = False)
        button = dialog.cancel("Cancel", None)
        button.event("SpawnDialog", "CancelDlg")

    def add_progress_dialog(self):
        dialog = distutils.command.bdist_msi.PyDialog(self.db, "ProgressDlg",
                self.x, self.y, self.width, self.height, self.modeless,
                self.title, "Cancel", "Cancel", "Cancel", bitmap = False)
        dialog.text("Title", 20, 15, 200, 15, 0x30003,
                r"{\DlgFontBold8}[Progress1] [ProductName]")
        dialog.text("Text", 35, 65, 300, 30, 3,
                "Please wait while the installer [Progress2] [ProductName].")
        dialog.text("StatusLabel", 35, 100 ,35, 20, 3, "Status:")
        text = dialog.text("ActionText", 70, 100, self.width - 70, 20, 3,
                "Pondering...")
        text.mapping("ActionText", "Text")
        control = dialog.control("ProgressBar", "ProgressBar", 35, 120, 300,
                10, 65537, None, "Progress done", None, None)
        control.mapping("SetProgress", "Progress")
        dialog.back("< Back", "Next", active = False)
        dialog.next("Next >", "Cancel", active = False)
        button = dialog.cancel("Cancel", "Back")
        button.event("SpawnDialog", "CancelDlg")

    def add_properties(self):
        metadata = self.distribution.metadata
        props = [
                ('DistVersion', metadata.get_version()),
                ('DefaultUIFont', 'DlgFont8'),
                ('ErrorDialog', 'ErrorDlg'),
                ('Progress1', 'Install'),
                ('Progress2', 'installs'),
                ('MaintenanceForm_Action', 'Repair'),
                ('ALLUSERS', '2')]

        if not self.all_users:
            props.append(('MSIINSTALLPERUSER', '1'))
        email = metadata.author_email or metadata.maintainer_email
        if email:
            props.append(("ARPCONTACT", email))
        if metadata.url:
            props.append(("ARPURLINFOABOUT", metadata.url))
        if self.upgrade_code is not None:
            if not is_valid_GUID(self.upgrade_code):
                raise ValueError("upgrade-code must be in valid GUID format")
            props.append(("UpgradeCode", self.upgrade_code.upper()))
        if self.install_icon:
            props.append(('ARPPRODUCTICON', 'InstallIcon'))
        msilib.add_data(self.db, 'Property', props)
        if self.install_icon:
            msilib.add_data(self.db, "Icon", [("InstallIcon", msilib.Binary(self.install_icon))])

    def add_select_directory_dialog(self):
        dialog = distutils.command.bdist_msi.PyDialog(self.db,
                "SelectDirectoryDlg", self.x, self.y, self.width, self.height,
                self.modal, self.title, "Next", "Next", "Cancel")
        dialog.title("Select destination directory")
        dialog.back("< Back", None, active = False)
        button = dialog.next("Next >", "Cancel")
        button.event("SetTargetPath", "TARGETDIR", ordering = 1)
        button.event("SpawnWaitDialog", "WaitForCostingDlg", ordering = 2)
        button.event("EndDialog", "Return", ordering = 3)
        button = dialog.cancel("Cancel", "DirectoryCombo")
        button.event("SpawnDialog", "CancelDlg")
        dialog.control("DirectoryCombo", "DirectoryCombo", 15, 70, 272, 80,
                393219, "TARGETDIR", None, "DirectoryList", None)
        dialog.control("DirectoryList", "DirectoryList", 15, 90, 308, 136, 3,
                "TARGETDIR", None, "PathEdit", None)
        dialog.control("PathEdit", "PathEdit", 15, 230, 306, 16, 3,
                "TARGETDIR", None, "Next", None)
        button = dialog.pushbutton("Up", 306, 70, 18, 18, 3, "Up", None)
        button.event("DirectoryListUp", "0")
        button = dialog.pushbutton("NewDir", 324, 70, 30, 18, 3, "New", None)
        button.event("DirectoryListNew", "0")

    def add_text_styles(self):
        msilib.add_data(self.db, 'TextStyle',
                [("DlgFont8", "Tahoma", 9, None, 0),
                 ("DlgFontBold8", "Tahoma", 8, None, 1),
                 ("VerdanaBold10", "Verdana", 10, None, 1),
                 ("VerdanaRed9", "Verdana", 9, 255, 0)
                ])

    def add_ui(self):
        self.add_text_styles()
        self.add_error_dialog()
        self.add_fatal_error_dialog()
        self.add_cancel_dialog()
        self.add_exit_dialog()
        self.add_user_exit_dialog()
        self.add_files_in_use_dialog()
        self.add_wait_for_costing_dialog()
        self.add_prepare_dialog()
        self.add_select_directory_dialog()
        self.add_progress_dialog()
        self.add_maintenance_type_dialog()

    def add_upgrade_config(self, sversion):
        if self.upgrade_code is not None:
            msilib.add_data(self.db, 'Upgrade',
                    [(self.upgrade_code, None, sversion, None, 513, None,
                            "REMOVEOLDVERSION"),
                     (self.upgrade_code, sversion, None, None, 257, None,
                            "REMOVENEWVERSION")
                    ])

    def add_user_exit_dialog(self):
        dialog = distutils.command.bdist_msi.PyDialog(self.db, "UserExit",
                self.x, self.y, self.width, self.height, self.modal,
                self.title, "Finish", "Finish", "Finish")
        dialog.title("[ProductName] installer was interrupted")
        dialog.back("< Back", "Finish", active = False)
        dialog.cancel("Cancel", "Back", active = False)
        dialog.text("Description1", 15, 70, 320, 80, 0x30003,
                "[ProductName] setup was interrupted. Your system has not "
                "been modified. To install this program at a later time, "
                "please run the installation again.")
        dialog.text("Description2", 15, 155, 320, 20, 0x30003,
                "Click the Finish button to exit the installer.")
        button = dialog.next("Finish", "Cancel", name = "Finish")
        button.event("EndDialog", "Exit")

    def add_wait_for_costing_dialog(self):
        dialog = msilib.Dialog(self.db, "WaitForCostingDlg", 50, 10, 260, 85,
                self.modal, self.title, "Return", "Return", "Return")
        dialog.text("Text", 48, 15, 194, 30, 3,
                "Please wait while the installer finishes determining your "
                "disk space requirements.")
        button = dialog.pushbutton("Return", 102, 57, 56, 17, 3, "Return",
                None)
        button.event("EndDialog", "Exit")

    def finalize_options(self):
        distutils.command.bdist_msi.bdist_msi.finalize_options(self)
        name = self.distribution.get_name()
        fullname = self.distribution.get_fullname()
        if self.initial_target_dir is None:
            if distutils.util.get_platform() == "win-amd64":
                programFilesFolder = "ProgramFiles64Folder"
            else:
                programFilesFolder = "ProgramFilesFolder"
            self.initial_target_dir = r"[%s]\%s" % (programFilesFolder, name)
        if self.add_to_path is None:
            self.add_to_path = False
        if self.target_name is None:
            self.target_name = fullname
        if not self.target_name.lower().endswith(".msi"):
            platform = distutils.util.get_platform().replace("win-", "")
            self.target_name = "%s-%s.msi" % (self.target_name, platform)
        if not os.path.isabs(self.target_name):
            self.target_name = os.path.join(self.dist_dir, self.target_name)
        if self.directories is None:
            self.directories = []
        if self.environment_variables is None:
            self.environment_variables = []
        if self.data is None:
            self.data = {}
        if not isinstance(self.summary_data, dict):
            self.summary_data = {}

    def initialize_options(self):
        distutils.command.bdist_msi.bdist_msi.initialize_options(self)
        self.upgrade_code = None
        self.product_code = None
        self.add_to_path = None
        self.initial_target_dir = None
        self.target_name = None
        self.directories = None
        self.environment_variables = None
        self.data = None
        self.summary_data = None
        self.install_icon = None
        self.all_users = False


    def run(self):
        if not self.skip_build:
            self.run_command('build')
        install = self.reinitialize_command('install', reinit_subcommands = 1)
        install.prefix = self.bdist_dir
        install.skip_build = self.skip_build
        install.warn_dir = 0
        distutils.log.info("installing to %s", self.bdist_dir)
        install.ensure_finalized()
        install.run()
        self.mkpath(self.dist_dir)
        fullname = self.distribution.get_fullname()
        if os.path.exists(self.target_name):
            os.unlink(self.target_name)
        metadata = self.distribution.metadata
        author = metadata.author or metadata.maintainer or "UNKNOWN"
        version = metadata.get_version()
        sversion = '.'.join([str(x) \
                for x in distutils.version.LooseVersion(version).version])

        # msilib is reloaded in order to reset the "_directories" global member
        # in that module.  That member is used by msilib to prevent any two
        # directories from having the same logical name.  _directories might
        # already have contents due to msilib having been previously used in
        # the current instance of the python interpreter -- if so, it could
        # prevent the root from getting the logical name TARGETDIR, breaking
        # the MSI.
        importlib.reload(msilib)

        if self.product_code is None:
            self.product_code = msilib.gen_uuid()
        self.db = msilib.init_database(self.target_name, msilib.schema,
                self.distribution.metadata.name, self.product_code, sversion,
                author)
        msilib.add_tables(self.db, msilib.sequence)
        self.add_properties()
        self.add_config(fullname)
        self.add_upgrade_config(sversion)
        self.add_ui()
        self.add_files()
        self.db.Commit()
        self.distribution.dist_files.append(('bdist_msi', sversion or 'any', self.target_name))
        
        if not self.keep_temp:
            distutils.dir_util.remove_tree(self.bdist_dir,
                    dry_run = self.dry_run)


def is_valid_GUID(code):
    pattern = re.compile(r"^\{[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}\}$",
                         re.IGNORECASE)
    return isinstance(code, str) and pattern.match(code) is not None
