import argparse
import datetime
import errno
import json
import logging
import os
import random
import re
import shutil
import subprocess
import sys
import time
import traceback
import zipfile
import zlib
from functools import wraps
from types import SimpleNamespace

import psutil
import py7zr
from artifactory import ArtifactoryPath
from artifactory import md5sum
from artifactory_du import artifactory_du
from dohq_artifactory import ArtifactoryException
from influxdb import InfluxDBClient
from office365.runtime.auth.authentication_context import AuthenticationContext
from office365.runtime.client_request_exception import ClientRequestException
from office365.sharepoint.client_context import ClientContext
from plyer import notification
from requests.exceptions import RequestException
from urllib3.exceptions import HTTPError

import iss_templates

__author__ = "<NAME>"
__email__ = "<EMAIL>"
__version__ = "3.0.2"

STATISTICS_SERVER = "OTTBLD02"
STATISTICS_PORT = 8086

TIMEOUT = 90

ARTIFACTORY_DICT = {
    "Azure": "http://azwec7artsrv01.ansys.com:8080/artifactory",
    "Austin": "http://ausatsrv01.ansys.com:8080/artifactory",
    "Boulder": "http://bouartifact.ansys.com:8080/artifactory",
    "Canonsburg": "http://canartifactory.ansys.com:8080/artifactory",
    "Concord": "http://convmartifact.win.ansys.com:8080/artifactory",
    "Darmstadt": "http://darvmartifact.win.ansys.com:8080/artifactory",
    "Evanston": "http://evavmartifact:8080/artifactory",
    "Hannover": "http://hanartifact1.ansys.com:8080/artifactory",
    "Horsham": "http://horvmartifact1.ansys.com:8080/artifactory",
    "Lebanon": "http://lebartifactory.win.ansys.com:8080/artifactory",
    "Lyon": "http://lyovmartifact.win.ansys.com:8080/artifactory",
    "Otterfing": "http://ottvmartifact.win.ansys.com:8080/artifactory",
    "Pune": "http://punvmartifact.win.ansys.com:8080/artifactory",
    "Sheffield": "http://shfvmartifact.win.ansys.com:8080/artifactory",
    "SanJose": "http://sjoartsrv01.ansys.com:8080/artifactory",
    "Waterloo": "https://watartifactory.win.ansys.com:8443/artifactory",
}

SHAREPOINT_SITE_URL = r"https://ansys.sharepoint.com/sites/BetaDownloader"


class DownloaderError(Exception):
    pass


def retry(exceptions, tries=4, delay=3, backoff=1, logger=None, proc_lock=False):
    """
        Retry calling the decorated function using an exponential backoff.

    Args:
        exceptions (Exception or tuple): the exception to check. may be a tuple of exceptions to check
        tries (int): number of times to try (not retry) before giving up
        delay (int): initial delay between retries in seconds
        backoff (int): backoff multiplier e.g. value of 2 will double the delay each retry
        logger (logging): logger to use. If None, print
        proc_lock (bool): if retry is applied to proc lock function

    Returns: decorator

    """

    def deco_retry(func):
        @wraps(func)
        def f_retry(self, *args, **kwargs):
            mtries, mdelay = tries, delay
            while mtries > 0:
                try:
                    return func(self, *args, **kwargs)
                except exceptions as e:
                    msg = f"{e}. Error occurred, attempt: {tries - mtries + 1}/{tries}"

                    if proc_lock:
                        # only applied for process lock
                        err = "Stop all processes running from installation folder. "
                        err += f"Attempt: {tries - mtries + 1}/{tries}"
                        if mtries > 1:
                            err += " Autoretry in 60sec."
                            Downloader.toaster_notification("Failed", err)
                        else:
                            raise DownloaderError(msg)

                    if logger:
                        logger.warning(msg)
                    else:
                        print(msg)
                    time.sleep(mdelay)
                    mtries -= 1
                    mdelay *= backoff
            else:
                error = (
                    "Please verify that your connection is stable, avoid switching state of VPN during download. "
                    "For artifactory you have to be on VPN. "
                    f"Number of attempts: {tries}/{tries}"
                )

                if logger:
                    raise DownloaderError(error)
                else:
                    print(error)

        return f_retry  # true decorator

    return deco_retry


class Downloader:
    """
    Main class that operates the download and installation process:
    1. enables logs
    2. parses arguments to get settings file
    3. loads JSON to named tuple
    4. gets URL for selected version based on server
    5. downloads zip archive with BETA build
    6. unpacks it to download folder
    7. depending on the choice proceeds to installation of EDT or WB
    8. uninstalls previous build if one exists
    9. updates registry of EDT
    10. sends statistics to the server

    Performs checks:
    1. if the same build date is already installed, then do not proceed to download
    2. if some process is running from installation folder it will abort download

    Notes:
    Software attempts to download 4 times, if connection is still bad will abort
    """

    def __init__(self, version, settings_folder="", settings_path=""):
        """
        :parameter: version: version of the file, used if invoke file with argument -V to get version

        self.build_artifactory_path (ArtifactoryPath): URL to the latest build that will be used to download archive
        self.zip_file (str): path to the zip file on the PC
        self.target_unpack_dir (str): path where to unpack .zip
        self.installed_product_info (str): path to the product.info of the installed build
        self.product_root_path (str): root path of Ansys Electronics Desktop/ Workbench installation
        self.product_version (str): version to use in env variables eg 202
        self.setup_exe (str): path to the setup.exe from downloaded and unpacked zip
        self.remote_build_date (str): build date that receive from SharePoint
        self.hash (str): hash code used for this run of the program
        self.pid: pid (process ID of the current Python run, required to allow kill in UI)
        self.ctx: context object to authorize in SharePoint using office365 module
        self.settings_folder (str): default folder where all configurations would be saved
        self.history_file (str): file where installation progress would be written (this file is tracked by UI)
        self.history (dict): dict with history of installation processes
        self.logging_file (str): file where detailed log for all runs is saved

        """
        self.build_artifactory_path = ArtifactoryPath()
        self.zip_file = ""
        self.target_unpack_dir = ""
        self.installed_product_info = ""
        self.product_root_path = ""
        self.product_version = ""
        self.setup_exe = ""
        self.remote_build_date = ""

        self.pid = str(os.getpid())
        self.ctx = None
        self.warnings_list = []

        self.hash = generate_hash_str()
        if not settings_folder:
            self.settings_folder = os.path.join(os.environ["APPDATA"], "build_downloader")
        else:
            self.settings_folder = settings_folder

        self.check_and_make_directories(self.settings_folder)

        self.history = {}
        self.history_file = os.path.join(self.settings_folder, "installation_history.json")
        self.get_installation_history()

        self.logging_file = os.path.join(self.settings_folder, "downloader.log")

        self.settings_path = settings_path if settings_path else self.parse_args(version)
        with open(self.settings_path, "r") as file:
            self.settings = json.load(file, object_hook=lambda d: SimpleNamespace(**d))

        # this part of the code creates attributes that were added. Required for compatibility
        if not hasattr(self.settings, "replace_shortcut"):
            # v2.0.0
            self.settings.replace_shortcut = True

        if not hasattr(self.settings, "custom_flags"):
            # v2.2.0
            self.settings.custom_flags = ""

        if not hasattr(self.settings, "license_file"):
            # v3.0.0
            self.settings.license_file = ""

        if not hasattr(self.settings, "wb_assoc"):
            # v3.0.0
            self.settings.wb_assoc = ""

        if "ElectronicsDesktop" in self.settings.version:
            self.product_version = self.settings.version[1:4]
            if float(self.product_version) >= 221:
                self.product_root_path = os.path.join(
                    self.settings.install_path, "AnsysEM", f"v{self.product_version}", "Win64"
                )
            else:
                self.product_root_path = os.path.join(
                    self.settings.install_path,
                    "AnsysEM",
                    "AnsysEM" + self.product_version[:2] + "." + self.product_version[2:],
                    "Win64",
                )

            self.installed_product_info = os.path.join(self.product_root_path, "product.info")
        elif "Workbench" in self.settings.version:
            self.product_root_path = os.path.join(self.settings.install_path, "ANSYS Inc", self.settings.version[:4])
        elif "LicenseManager" in self.settings.version:
            self.product_root_path = os.path.join(
                self.settings.install_path, "ANSYS Inc", "Shared Files", "Licensing", "tools", "lmcenter"
            )

    def authorize_sharepoint(self):
        """
        Function that uses PnP to authorize user in SharePoint using Windows account and to get actual client_id and
        client_secret
        Returns: ctx: authorization context for Office365 library

        """
        self.update_installation_history(status="In-Progress", details="Authorizing in SharePoint")

        command = "powershell.exe "
        command += "Connect-PnPOnline -Url https://ansys.sharepoint.com/sites/BetaDownloader -UseWebLogin;"
        command += '(Get-PnPListItem -List secret_list -Fields "Title","client_id","client_secret").FieldValues'
        out_str = self.subprocess_call(command, shell=True, popen=True)

        secret_list = []
        try:
            for line in out_str.splitlines():
                if "Title" in line:
                    secret_dict = {"Title": line.split()[1]}
                elif "client_id" in line:
                    secret_dict["client_id"] = line.split()[1]
                elif "client_secret" in line:
                    secret_dict["client_secret"] = line.split()[1]
                    secret_list.append(secret_dict)
        except NameError:
            raise DownloaderError("Cannot retrieve authentication tokens for SharePoint")

        secret_list.sort(key=lambda elem: elem["Title"], reverse=True)

        context_auth = AuthenticationContext(url=SHAREPOINT_SITE_URL)
        context_auth.acquire_token_for_app(
            client_id=secret_list[0]["client_id"], client_secret=secret_list[0]["client_secret"]
        )

        ctx = ClientContext(SHAREPOINT_SITE_URL, context_auth)
        return ctx

    def run(self):
        """
        Function that executes the download-installation process
        :return: None
        """
        try:
            set_logger(self.logging_file)
            logging.info(f"Settings path is set to {self.settings_path}")

            if self.settings.artifactory == "SharePoint":
                self.ctx = self.authorize_sharepoint()

            self.update_installation_history(status="In-Progress", details="Verifying configuration")
            self.check_and_make_directories(self.settings.install_path, self.settings.download_path)
            if "ElectronicsDesktop" in self.settings.version or "Workbench" in self.settings.version:
                space_required = 15

                # License Manager can be updated even if running
                self.check_process_lock()
            else:
                if not self.settings.license_file:
                    raise DownloaderError("No license file defined. Please select it in Advanced Settings")

                if not os.path.isfile(self.settings.license_file):
                    raise DownloaderError(f"No license file was detected under {self.settings.license_file}")

                space_required = 1

            self.check_free_space(self.settings.download_path, space_required)
            self.check_free_space(self.settings.install_path, space_required)

            self.get_build_link()
            if self.settings.force_install or self.newer_version_exists:
                self.download_file()

                if "ElectronicsDesktop" in self.settings.version or "Workbench" in self.settings.version:
                    self.check_process_lock()  # download can take time, better to recheck again
                self.install()
                try:
                    self.send_statistics()
                except Exception:
                    self.warnings_list.append("Connection to product improvement server failed")
                self.update_installation_history(status="Success", details="Normal completion")
            else:
                raise DownloaderError("Versions are up to date. If issue occurred please use force install flag")
            return
        except DownloaderError as e:
            # all caught errors are here
            logging.error(e)
            self.update_installation_history(status="Failed", details=str(e))
        except Exception:
            logging.error(traceback.format_exc())
            self.update_installation_history(status="Failed", details="Unexpected error, see logs")
            self.send_statistics(error=traceback.format_exc())
        self.clean_temp()

    @staticmethod
    def check_and_make_directories(*paths):
        """
        Verify that installation and download path exists.
        If not tries to create a requested path
        :parameter: paths: list of paths that we need to check and create
        """
        for path in paths:
            if not os.path.isdir(path):
                try:
                    os.makedirs(path)
                except PermissionError:
                    raise DownloaderError(f"{path} could not be created due to insufficient permissions")
                except OSError as err:
                    if "BitLocker" in str(err):
                        raise DownloaderError("Your drive is locked by BitLocker. Please unlock!")
                    else:
                        raise DownloaderError(err)

    @staticmethod
    def check_free_space(path, required):
        """
        Verifies that enough disk space is available. Raises error if not enough space
        :param path: path where to check
        :param required: value in GB that should be available on drive to pass the check
        :return:
        """
        free_space = shutil.disk_usage(path).free // (2**30)
        if free_space < required:
            err = f"Disk space in {path} is less than {required}GB. This would not be enough to proceed"
            raise DownloaderError(err)

    @retry((DownloaderError,), tries=3, delay=60, logger=logging, proc_lock=True)
    def check_process_lock(self):
        """
        Verify if some executable is running from installation folder
        Abort installation if any process is running from installation folder
        :return: None
        """
        process_list = []
        for process in psutil.process_iter():
            try:
                if self.product_root_path in process.exe():
                    process_list.append(process.name())
            except psutil.AccessDenied:
                pass

        if process_list:
            process_list.sort(key=len)  # to fit into UI
            raise DownloaderError(
                "Following processes are running from installation directory: "
                + f"{', '.join(set(process_list))}. Please stop all processes."
            )

    @property
    def newer_version_exists(self):
        """
        verify if version on the server is newer compared to installed
        Returns:
            (bool) True if remote is newer or no version is installed, False if remote is the same or older
        """
        if "Workbench" in self.settings.version:
            product_installed = os.path.join(self.product_root_path, "package.id")
        elif "LicenseManager" in self.settings.version:
            # always update LM
            return True
        else:
            product_installed = self.installed_product_info

        if os.path.isfile(product_installed):
            if "Workbench" in self.settings.version:
                with open(product_installed) as file:
                    installed_product_version = next(file).rstrip().split()[-1]  # get first line
                    try:
                        installed_product_version = int(installed_product_version.split("P")[0])
                    except ValueError:
                        installed_product_version = 0
            else:
                installed_product_version = self.get_edt_build_date(product_installed)

            logging.info(f"Installed version of {self.settings.version} is {installed_product_version}")
            new_product_version = self.get_new_build_date()

            if not all([new_product_version, installed_product_version]):
                # some of the versions could not be parsed, need installation
                return True

            if new_product_version <= installed_product_version:
                return False
        return True

    def get_new_build_date(self, distribution="winx64"):
        """
        Create URL for new build extraction or extract version from self.remote_build_date for SharePoint download
        Returns:
            new_product_version (int) version of the product on the server
        """
        if self.settings.artifactory != "SharePoint":
            if "Workbench" in self.settings.version:
                url = self.build_artifactory_path.joinpath("package.id")
            elif "ElectronicsDesktop" in self.settings.version:
                system = "windows" if distribution == "winx64" else "linux"
                url = self.build_artifactory_path.parent.joinpath(f"product_{system}.info")
            else:
                # todo add license manager handling
                return 0

            logging.info(f"Request info about new package: {url}")
            new_product_version = self.get_build_info_file_from_artifactory(url)
        else:
            try:
                new_product_version = int(self.remote_build_date)
            except ValueError:
                return 0

        logging.info(f"Version on artifactory/SP is {new_product_version}")
        return new_product_version

    @retry((HTTPError, RequestException, ConnectionError, ConnectionResetError), 4, logger=logging)
    def get_build_link(self, distribution="winx64"):
        """
        Function that sends HTTP request to JFrog and get the list of folders with builds for Electronics Desktop and
        checks user password
        If use SharePoint then readdress to SP list
        :modify: (str) self.build_artifactory_path: URL link to the latest build that will be used to download archive
        """
        self.update_installation_history(status="In-Progress", details="Search latest build URL")
        if self.settings.artifactory == "SharePoint":
            self.get_sharepoint_build_info()
            return

        if not hasattr(self.settings.password, self.settings.artifactory):
            raise DownloaderError(f"Please provide password for {self.settings.artifactory}")

        password = getattr(self.settings.password, self.settings.artifactory)

        if not self.settings.username or not password:
            raise DownloaderError("Please provide username and artifactory password")

        server = ARTIFACTORY_DICT[self.settings.artifactory]

        art_path = ArtifactoryPath(server, auth=(self.settings.username, password), timeout=TIMEOUT)
        try:
            repos_list = art_path.get_repositories(lazy=True)
        except ArtifactoryException as err:
            raise DownloaderError(f"Cannot retrieve repositories. Error: {err}")

        # fill the dictionary with Electronics Desktop and Workbench keys since builds could be different
        # still parse the list because of different names on servers
        artifacts_dict = {}
        for repo in repos_list:
            repo_name = repo.name
            if "EBU_Certified" in repo_name:
                version = repo_name.split("_")[0] + "_ElectronicsDesktop"
            elif "Certified" in repo_name and "Licensing" not in repo_name:
                version = repo_name.split("_")[0] + "_Workbench"
            elif "Certified" in repo_name and "Licensing" in repo_name:
                version = repo_name.split("_")[0] + "_LicenseManager"
            else:
                continue

            if version not in artifacts_dict:
                # extract real repo name in case it is cached
                if art_path.joinpath(repo_name).exists():
                    # repo might be syncing (happens on new release addition)
                    artifacts_dict[version] = art_path.joinpath(repo_name).stat().repo

        try:
            repo = artifacts_dict[self.settings.version]
        except KeyError:
            raise DownloaderError(
                f"Version {self.settings.version} that you have specified "
                + f"does not exist on {self.settings.artifactory}"
            )

        path = ""
        art_path = art_path.joinpath(str(repo))
        if "ElectronicsDesktop" in self.settings.version:
            archive = "winx64.zip" if distribution == "winx64" else "linx64.tgz"
            builds_dates = []
            for relative_p in art_path:
                try:
                    archive_exists = list(relative_p.glob(f"Electronics*{archive}"))
                    if archive_exists:
                        builds_dates.append(int(relative_p.name))
                except ValueError:
                    pass

            if not builds_dates:
                raise DownloaderError("Artifact does not exist")

            latest_build = sorted(builds_dates)[-1]

            art_path = art_path.joinpath(str(latest_build))
            for path in art_path:
                if archive in path.name:
                    break
            else:
                raise DownloaderError(f"Cannot find {distribution} archive file")

        elif "Workbench" in self.settings.version or "LicenseManager" in self.settings.version:
            path = art_path.joinpath(distribution)

        if not path:
            raise DownloaderError("Cannot receive URL")

        self.build_artifactory_path = path

    def get_sharepoint_build_info(self):
        """
        Gets link to the latest build from SharePoint and builddate itself
        Returns: None

        """
        product_list = self.ctx.web.lists.get_by_title("product_list")
        items = product_list.items
        self.ctx.load(items).execute_query()

        build_list = []
        for item in items:
            title = item.properties["Title"]
            if title != self.settings.version:
                continue

            build_dict = {
                "Title": title,
                "build_date": item.properties["build_date"],
                "relative_url": item.properties["relative_url"],
            }
            build_list.append(build_dict)

        build_list.sort(key=lambda elem: elem["build_date"], reverse=True)

        build_dict = build_list[0] if build_list else {}
        if not build_dict:
            raise DownloaderError(f"No version of {self.settings.version} is available on SharePoint")

        self.build_artifactory_path = build_dict["relative_url"]
        self.remote_build_date = build_dict["build_date"]

    def download_file(self):
        """
        Downloads file in chunks and saves to the temp.zip file
        Uses url to the zip archive or special JFrog API to download Workbench folder
        :modify: (str) zip_file: link to the zip file
        """
        if self.settings.artifactory == "SharePoint" or "win" in self.build_artifactory_path.name:
            archive_type = "zip"
        else:
            archive_type = "tgz"

        self.zip_file = os.path.join(self.settings.download_path, f"{self.settings.version}.{archive_type}")
        chunk_size = 50 * 1024 * 1024
        if self.settings.artifactory == "SharePoint":
            self.download_from_sharepoint(chunk_size=chunk_size)
        else:
            self.download_from_artifactory(archive_type, chunk_size=chunk_size)

        logging.info(f"File is downloaded to {self.zip_file}")

    @retry((HTTPError, RequestException, ConnectionError, ConnectionResetError), 4, logger=logging)
    def download_from_artifactory(self, archive_type, chunk_size):
        """
        Download file from Artifactory
        Args:
            archive_type: type of the archive, zip or tgz
            chunk_size: (int) chunk size in bytes when download

        Returns: None
        """
        if not self.build_artifactory_path.replication_status["status"] in ["ok", "never_run"]:
            raise DownloaderError("Currently Artifactory repository is replicating, please try later")

        logging.info(f"Start download file from {self.build_artifactory_path} to {self.zip_file}")
        self.update_installation_history(
            status="In-Progress", details=f"Downloading file from {self.settings.artifactory}"
        )
        if "ElectronicsDesktop" in self.settings.version:
            file_stats = self.build_artifactory_path.stat()
            arti_file_md5 = file_stats.md5
            logging.info(f"Artifactory hash: {arti_file_md5}")

            try:
                self.build_artifactory_path.writeto(
                    out=self.zip_file, chunk_size=chunk_size, progress_func=self.print_download_progress
                )
            except RuntimeError as err:
                raise DownloaderError(f"Cannot download file. Server returned status code: {err}")

            local_md5_hash = md5sum(self.zip_file)
            logging.info(f"Local file hash: {local_md5_hash}")
            if local_md5_hash != arti_file_md5:
                raise DownloaderError("Downloaded file MD5 hash is different")

        elif "Workbench" in self.settings.version or "LicenseManager" in self.settings.version:
            try:
                file_size = self.get_artifactory_folder_size()
                logging.info(f"Workbench/License Manager real file size is {file_size}")
            except (TypeError, ValueError):
                file_size = 14e9

            archive_url = self.build_artifactory_path.archive(archive_type=archive_type)
            archive_url.writeto(
                out=self.zip_file,
                chunk_size=chunk_size,
                progress_func=lambda x, y: self.print_download_progress(x, file_size),
            )

    def get_artifactory_folder_size(self):
        aql_query_dict, max_depth_print = artifactory_du.prepare_aql(
            file=f"/{self.build_artifactory_path.name}",
            max_depth=0,
            repository=self.build_artifactory_path.repo,
            without_downloads="",
            older_than="",
        )
        artifacts = artifactory_du.artifactory_aql(
            artifactory_url=str(self.build_artifactory_path.drive),
            aql_query_dict=aql_query_dict,
            username=self.build_artifactory_path.auth[0],
            password=self.build_artifactory_path.auth[1],
            kerberos=False,
            verify=False,
        )
        file_size = artifactory_du.out_as_du(artifacts, max_depth_print, human_readable=False)
        file_size = int(file_size.strip("/"))
        return file_size

    @retry(
        (HTTPError, RequestException, ConnectionError, ConnectionResetError, ClientRequestException),
        tries=4,
        logger=logging,
    )
    def download_from_sharepoint(self, chunk_size):
        """
        Downloads file from Sharepoint
        Args:
            chunk_size: (int) chunk size in bytes when download
        Returns: None
        """
        self.update_installation_history(status="In-Progress", details="Downloading file from SharePoint")
        remote_file = self.ctx.web.get_file_by_server_relative_url(
            f"/sites/BetaDownloader/{self.build_artifactory_path}"
        )
        remote_file.get()
        try:
            self.ctx.execute_query()
        except ClientRequestException as err:
            logging.error(str(err))
            raise DownloaderError(
                "URL on SharePoint is broken. Report an issue to <EMAIL>. "
                "In meantime please switch to any other repository."
            )

        file_size = remote_file.length
        self.check_free_space(self.settings.download_path, file_size / 1024 / 1024 / 1024)

        try:
            with open(self.zip_file, "wb") as zip_file:
                try:
                    remote_file.download_session(
                        zip_file,
                        lambda offset: self.print_download_progress(offset, total_size=file_size),
                        chunk_size=chunk_size,
                    )
                    self.ctx.execute_query()
                except OSError as err:
                    if err.errno == errno.ENOSPC:
                        raise DownloaderError("No disk space available in download folder!")
                    raise
        except PermissionError as err:
            msg = str(err).replace("[Errno 13]", "").strip()
            raise DownloaderError(msg)

        if not self.zip_file:
            raise DownloaderError("ZIP download failed")

        if abs(os.path.getsize(self.zip_file) - file_size) > 0.05 * file_size:
            raise DownloaderError("File size difference is more than 5%")

    def print_download_progress(self, offset, total_size):
        msg = "Downloaded {}/{}MB...[{}%]".format(
            int(offset / 1024 / 1024), int(total_size / 1024 / 1024), min(round(offset / total_size * 100, 2), 100)
        )
        logging.info(msg)
        self.update_installation_history(status="In-Progress", details=msg)

    def install(self, local_lang=False):
        """
        Unpack downloaded zip and proceed to installation. Different executions for Electronics Desktop and Workbench
        :param local_lang: if not specified then use English as default installation language
        :return: None
        """
        self.unpack_archive()

        if "ElectronicsDesktop" in self.settings.version:
            self.install_edt()
        elif "Workbench" in self.settings.version:
            self.install_wb(local_lang)
        else:
            self.install_license_manager()

        self.update_installation_history(status="In-Progress", details="Clean temp directory")
        self.clean_temp()

    def unpack_archive(self):
        self.update_installation_history(status="In-Progress", details="Start unpacking")
        self.target_unpack_dir = self.zip_file.replace(".zip", "")
        try:
            with zipfile.ZipFile(self.zip_file, "r") as zip_ref:
                zip_ref.extractall(self.target_unpack_dir)
        except OSError as err:
            if err.errno == errno.ENOSPC:
                raise DownloaderError("No disk space available in download folder!")
            else:
                raise DownloaderError(f"Cannot unpack due to {err}")
        except (zipfile.BadZipFile, zlib.error):
            raise DownloaderError("Zip file is broken. Please try again later or use another repository.")
        logging.info(f"File is unpacked to {self.target_unpack_dir}")

    def install_edt(self):
        """
        Install Electronics Desktop. Make verification that the same version is not yet installed and makes
        silent installation
        Get Workbench installation path from environment variable and enables integration if exists.
        :return: None
        """
        setup_exe, product_id, installshield_version = self.parse_iss_template(self.target_unpack_dir)
        self.uninstall_edt(setup_exe, product_id, installshield_version)

        install_iss_file, install_log_file = self.create_install_iss_file(installshield_version, product_id)

        command = [f'"{setup_exe}"', "-s", rf'-f1"{install_iss_file}"', rf'-f2"{install_log_file}"']
        command = " ".join(command)
        self.update_installation_history(status="In-Progress", details="Start installation")
        logging.info("Execute installation")
        self.subprocess_call(command)

        self.check_result_code(install_log_file)
        self.update_edt_registry()
        self.remove_aedt_shortcuts()

    def create_install_iss_file(self, installshield_version, product_id):
        install_iss_file = os.path.join(self.target_unpack_dir, "install.iss")
        install_log_file = os.path.join(self.target_unpack_dir, "install.log")
        integrate_wb = 0
        awp_env_var = "AWP_ROOT" + self.product_version
        if awp_env_var in os.environ:
            run_wb = os.path.join(os.environ[awp_env_var], "Framework", "bin", "Win64", "RunWB2.exe")
            if os.path.isfile(run_wb):
                integrate_wb = 1
                logging.info("Integration with Workbench turned ON")
        # the "shared files" is created at the same level as the "AnsysEMxx.x" so if installing to unique folders,
        # the Shared Files folder will be unique as well. Thus we can check install folder for license
        if os.path.isfile(
            os.path.join(self.settings.install_path, "AnsysEM", "Shared Files", "Licensing", "ansyslmd.ini")
        ):
            install_iss = iss_templates.install_iss + iss_templates.existing_server
            logging.info("Install using existing license configuration")
        else:
            install_iss = iss_templates.install_iss + iss_templates.new_server
            logging.info("Install using 127.0.0.1, Otterfing and HQ license servers")
        with open(install_iss_file, "w") as file:
            file.write(
                install_iss.format(
                    product_id,
                    os.path.join(self.settings.install_path, "AnsysEM"),
                    os.environ["TEMP"],
                    integrate_wb,
                    installshield_version,
                )
            )
        return install_iss_file, install_log_file

    def uninstall_edt(self, setup_exe, product_id, installshield_version):
        """
        Silently uninstall build of the same version
        :return: None
        """
        if os.path.isfile(self.installed_product_info):
            uninstall_iss_file = os.path.join(self.target_unpack_dir, "uninstall.iss")
            uninstall_log_file = os.path.join(self.target_unpack_dir, "uninstall.log")
            with open(uninstall_iss_file, "w") as file:
                file.write(iss_templates.uninstall_iss.format(product_id, installshield_version))

            command = [
                f'"{setup_exe}"',
                "-uninst",
                "-s",
                rf'-f1"{uninstall_iss_file}"',
                rf'-f2"{uninstall_log_file}"',
            ]
            command = " ".join(command)
            logging.info("Execute uninstallation")
            self.update_installation_history(status="In-Progress", details="Uninstall previous build")
            self.subprocess_call(command)

            self.check_result_code(uninstall_log_file, False)
            em_main_dir = os.path.dirname(self.product_root_path)
            self.remove_path(em_main_dir)

            if os.path.isdir(em_main_dir):
                raise DownloaderError(f"Failed to remove {em_main_dir}. Probably directory is locked.")
        else:
            logging.info("Version is not installed, skip uninstallation")

    def check_result_code(self, log_file, installation=True):
        """
        Verify result code of the InstallShield log file
        :param log_file: installation log file
        :param installation: True if verify log after installation elif after uninstallation False
        :return: None
        """
        success = "New build was successfully installed" if installation else "Previous build was uninstalled"
        fail = "Installation went wrong" if installation else "Uninstallation went wrong"
        if not os.path.isfile(log_file):
            raise DownloaderError(f"{fail}. Check that UAC disabled or confirm UAC question manually")

        msg = fail
        regex = "ResultCode=(.*)"
        with open(log_file) as file:
            for line in file:
                code = re.findall(regex, line)
                if code and code[0] == "0":
                    logging.info(success)
                    break
            else:
                if not installation:
                    msg = "Official uninstaller failed, make hard remove"
                    logging.error(msg)
                    self.warnings_list.append(msg)
                else:
                    raise DownloaderError(msg)

    @staticmethod
    def get_edt_build_date(product_info_file="", file_content=None):
        """
        extract information about Electronics Desktop build date and version
        Args:
            product_info_file: path to the product.info
            file_content (list): accepts list with file content as well instead of file

        Returns: (int) build_date

        """
        if os.path.isfile(product_info_file):
            with open(product_info_file) as file:
                file_content = file.readlines()

        for line in file_content:
            if "AnsProductBuildDate" in line:
                full_build_date = line.split("=")[1].replace('"', "").replace("-", "")
                build_date = full_build_date.split()[0]
                break
        else:
            # cannot find line with build date
            return 0

        try:
            return int(build_date)
        except ValueError:
            return 0

    @staticmethod
    def parse_iss_template(unpacked_dir):
        """
        Open directory with unpacked build of Electronics Desktop and search for SilentInstallationTemplate.iss to
        extract product ID which is GUID hash
        Args:
            unpacked_dir: directory where AEDT package was unpacked
        Returns:
            product_id: product GUID extracted from iss template
            setup_exe: set path to setup.exe if exists
            installshield_version: set version from file
        """
        default_iss_file = ""
        setup_exe = ""
        product_id_match = []

        for dir_path, dir_names, file_names in os.walk(unpacked_dir):
            for filename in file_names:
                if "AnsysEM" in dir_path and filename.endswith(".iss"):
                    default_iss_file = os.path.join(dir_path, filename)
                    setup_exe = os.path.join(dir_path, "setup.exe")
                    break

        if not default_iss_file:
            raise DownloaderError("SilentInstallationTemplate.iss does not exist")

        if not os.path.isfile(setup_exe):
            raise DownloaderError("setup.exe does not exist")

        with open(default_iss_file, "r") as iss_file:
            for line in iss_file:
                if "DlgOrder" in line:
                    guid_regex = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
                    product_id_match = re.findall(guid_regex, line)
                if "InstallShield Silent" in line:
                    installshield_version = next(iss_file).split("=")[1]

        if product_id_match:
            product_id = product_id_match[0]
            logging.info(f"Product ID is {product_id}")
        else:
            raise DownloaderError("Unable to extract product ID")

        return setup_exe, product_id, installshield_version

    def install_license_manager(self):
        """
        Install license manager and feed it with license file
        """
        self.setup_exe = os.path.join(self.target_unpack_dir, "setup.exe")

        if os.path.isfile(self.setup_exe):
            install_path = os.path.join(self.settings.install_path, "ANSYS Inc")
            if not os.path.isfile(self.settings.license_file):
                raise DownloaderError(f"No license file was detected under {self.settings.license_file}")

            command = [
                self.setup_exe,
                "-silent",
                "-LM",
                "-install_dir",
                install_path,
                "-lang",
                "en",
                "-licfilepath",
                self.settings.license_file,
            ]
            self.update_installation_history(status="In-Progress", details="Start installation")
            logging.info("Execute installation")
            self.subprocess_call(command)

            package_build = self.parse_lm_installer_builddate()
            installed_build = self.get_license_manager_build_date()
            if all([package_build, installed_build]) and package_build == installed_build:
                self.update_installation_history(status="Success", details="Normal completion")
            else:
                raise DownloaderError("License Manager was not installed")
        else:
            raise DownloaderError("No LicenseManager setup.exe file detected")

    def parse_lm_installer_builddate(self):
        """
        Check build date of installation package of License Manager
        """

        build_file = os.path.join(self.target_unpack_dir, "builddate.txt")
        lm_center_archive = os.path.join(self.target_unpack_dir, "lmcenter", "WINX64.7z")

        if not os.path.isfile(build_file) and os.path.isfile(lm_center_archive):
            with py7zr.SevenZipFile(lm_center_archive, "r") as archive:
                archive.extractall(path=os.path.join(self.target_unpack_dir, "lmcenter"))
            build_file = os.path.join(
                self.target_unpack_dir,
                "lmcenter",
                "Shared Files",
                "licensing",
                "tools",
                "lmcenter",
                "lmcenter_blddate.txt",
            )

        if not os.path.isfile(build_file):
            # check again if file was unpacked
            logging.warning("builddate.txt was not found in installation package")
            return

        with open(build_file) as file:
            for line in file:
                if "license management center" in line.lower():
                    lm_build_date = line.split()[-1]
                    try:
                        logging.info(f"Build date of License Manager in installation package {lm_build_date}")
                        lm_build_date = int(lm_build_date)
                        return lm_build_date
                    except TypeError:
                        raise DownloaderError("Cannot extract build date of installation package")

    def get_license_manager_build_date(self):
        """
        Check build date of installed License Manager
        """
        build_date_file = os.path.join(self.product_root_path, "lmcenter_blddate.txt")
        if not os.path.isfile(build_date_file):
            raise DownloaderError("lmcenter_blddate.txt is not available")

        with open(build_date_file) as file:
            lm_build_date = next(file).split()[-1]
            try:
                logging.info(f"Newly installed build date of License Manager: {lm_build_date}")
                lm_build_date = int(lm_build_date)
                return lm_build_date
            except (TypeError, ValueError):
                raise DownloaderError("Cannot extract build date of installed License Manager")

    def install_wb(self, local_lang=False):
        """
        Install Workbench to the target installation directory
        :param local_lang: if not specified then use English as default installation language
        """
        self.setup_exe = os.path.join(self.target_unpack_dir, "setup.exe")

        if os.path.isfile(self.setup_exe):
            uninstall_exe = self.uninstall_wb()

            install_path = os.path.join(self.settings.install_path, "ANSYS Inc")
            command = [self.setup_exe, "-silent", "-install_dir", install_path]
            if not local_lang:
                command += ["-lang", "en"]
            command += self.settings.wb_flags.split()

            # the "shared files" is created at the same level as the "ANSYS Inc" so if installing to unique folders,
            # the Shared Files folder will be unique as well. Thus we can check install folder for license
            if (
                os.path.isfile(os.path.join(install_path, "Shared Files", "Licensing", "ansyslmd.ini"))
                or "ANSYSLMD_LICENSE_FILE" in os.environ
            ):
                logging.info("Install using existing license configuration")
            else:
                command += ["-licserverinfo", "2325:1055:127.0.0.1,OTTLICENSE5,PITRH6LICSRV1"]
                logging.info("Install using 127.0.0.1, Otterfing and HQ license servers")

            # convert command to string to easy append custom flags
            command = subprocess.list2cmdline(command)
            command += " " + self.settings.custom_flags

            self.update_installation_history(status="In-Progress", details="Start installation")
            logging.info("Execute installation")
            self.subprocess_call(command)

            if os.path.isfile(uninstall_exe):
                logging.info("New build was installed")
            else:
                raise DownloaderError(
                    "Workbench installation failed. "
                    + f"If you see this error message by mistake please report to {__email__}"
                )

            if self.settings.wb_assoc:
                wb_assoc_exe = os.path.join(self.settings.wb_assoc, "commonfiles", "tools", "winx64", "fileassoc.exe")
                if not os.path.isfile(wb_assoc_exe):
                    self.warnings_list.append(f"Cannot find {wb_assoc_exe}")
                else:
                    logging.info("Run WB file association")
                    self.subprocess_call(wb_assoc_exe)
        else:
            raise DownloaderError("No Workbench setup.exe file detected")

    def uninstall_wb(self):
        """
        Uninstall Workbench if such exists in the target installation directory
        :return: uninstall_exe: name of the executable of uninstaller"""

        uninstall_exe = os.path.join(self.product_root_path, "Uninstall.exe")
        if os.path.isfile(uninstall_exe):
            command = [uninstall_exe, "-silent"]
            self.update_installation_history(status="In-Progress", details="Uninstall previous build")
            logging.info("Execute uninstallation")
            self.subprocess_call(command)
            logging.info("Previous build was uninstalled using uninstaller")
        else:
            logging.info("No Workbench Uninstall.exe file detected")

        self.remove_path(self.product_root_path)

        return uninstall_exe

    def remove_path(self, path):
        """
        Function to safely remove path if rmtree fails
        :param path:
        :return:
        """

        def hard_remove():
            try:
                # try this dirty method to force remove all files in directory
                all_files = os.path.join(path, "*.*")
                command = ["DEL", "/F", "/Q", "/S", all_files, ">", "NUL"]
                self.subprocess_call(command, shell=True)

                command = ["rmdir", "/Q", "/S", path]
                self.subprocess_call(command, shell=True)
            except Exception as err:
                logging.error(str(err))
                logging.error("Failed to remove directory via hard_remove")
                self.warnings_list.append("Failed to remove directory")

        logging.info(f"Removing {path}")
        if os.path.isdir(path):
            try:
                shutil.rmtree(path)
            except PermissionError:
                logging.warning("Permission error. Switch to CMD force mode")
                hard_remove()
                self.warnings_list.append("Clean remove failed due to Permissions Error")
            except (FileNotFoundError, OSError, Exception):
                logging.warning("FileNotFoundError or other error. Switch to CMD force mode")
                hard_remove()
                self.warnings_list.append("Clean remove failed due to Not Found or OS Error")

        elif os.path.isfile(path):
            os.remove(path)

    def get_build_info_file_from_artifactory(self, build_info):
        """
        Downloads product info file from artifactory

        :param (ArtifactoryPath) build_info: arti path to the package_id file
        :return: (int): package_id if extracted
        """
        product_info = 0
        package_info = build_info.read_text()
        try:
            if "Workbench" in self.settings.version:
                first_line = package_info.split("\n")[0]
                product_info = first_line.rstrip().split()[-1]
                try:
                    product_info = int(product_info.split("P")[0])
                except ValueError:
                    pass
            else:
                product_info = self.get_edt_build_date(file_content=package_info.split("\n"))
        except IndexError:
            pass

        return product_info

    def clean_temp(self):
        """
        Cleans downloaded zip and unpacked folder with content
        :return: None
        """
        try:
            if os.path.isfile(self.zip_file) and self.settings.delete_zip:
                self.remove_path(self.zip_file)
                logging.info("ZIP deleted")

            if os.path.isdir(self.target_unpack_dir):
                self.remove_path(self.target_unpack_dir)
                logging.info("Unpacked directory removed")
        except PermissionError:
            logging.error("Temp files could not be removed due to permission error")

    def remove_aedt_shortcuts(self):
        """
        Function to remove newly created AEDT shortcuts and replace them with new one
        """
        if not self.settings.replace_shortcut:
            return

        # include Public, user folder and user folder when OneDrive sync is enabled
        for user in ["Public", os.getenv("username"), os.path.join(os.getenv("username"), "OneDrive - ANSYS, Inc")]:
            desktop = os.path.join("C:\\", "Users", user, "Desktop")

            for shortcut in [
                "ANSYS Savant",
                "ANSYS EMIT",
                "ANSYS SIwave",
                "ANSYS Twin Builder",
                "Ansys Nuhertz FilterSolutions",
            ]:
                self.remove_path(os.path.join(desktop, shortcut + ".lnk"))

            new_name = os.path.join(
                desktop, f"20{self.product_version[:2]}R{self.product_version[2:]} Electronics Desktop.lnk"
            )
            aedt_shortcut = os.path.join(desktop, "ANSYS Electronics Desktop.lnk")
            if not os.path.isfile(new_name):
                try:
                    os.rename(aedt_shortcut, new_name)
                except FileNotFoundError:
                    pass
            else:
                self.remove_path(aedt_shortcut)

    def update_edt_registry(self):
        """
        Update Electronics Desktop registry based on the files in the HPC_Options folder that are added from UI
        :return: None
        """
        hpc_folder = os.path.join(self.settings_folder, "HPC_Options")

        update_registry_exe = os.path.join(self.product_root_path, "UpdateRegistry.exe")
        productlist_file = os.path.join(self.product_root_path, "config", "ProductList.txt")

        if not os.path.isfile(productlist_file):
            raise DownloaderError("Cannot update registry. Probably Electronics Desktop installation failed")

        with open(productlist_file) as file:
            product_version = next(file).rstrip()  # get first line

        self.update_installation_history(status="In-Progress", details="Update registry")
        if os.path.isdir(hpc_folder):
            for file in os.listdir(hpc_folder):
                if ".acf" in file:
                    options_file = os.path.join(hpc_folder, file)
                    command = [update_registry_exe, "-ProductName", product_version, "-FromFile", options_file]
                    logging.info("Update registry")
                    self.subprocess_call(command)

    def update_installation_history(self, status, details):
        """
        Update ordered dictionary with new data and write it to the file
        :param status: Failed | Success | In-Progress (important, used in JS)
        :param details: Message for details field
        :return:
        """
        if status == "Failed" or status == "Success":
            try:
                self.toaster_notification(status, details)
            except Exception:
                msg = "Toaster notification did not work"
                logging.error(msg)
                self.warnings_list.append(msg)

        self.get_installation_history()  # in case if file was deleted during run of installation
        time_now = datetime.datetime.now().strftime("%d-%m-%Y %H:%M")
        shorten_path = self.settings_path.replace(os.getenv("APPDATA", "@@@"), "%APPDATA%")

        if status == "Failed" or status == "Success":
            if self.warnings_list:
                details += "\nSome warnings occurred during process:\n" + "\n".join(self.warnings_list)

        self.history[self.hash] = [status, self.settings.version, time_now, shorten_path, details, self.pid]
        with open(self.history_file, "w") as file:
            json.dump(self.history, file, indent=4)

    @staticmethod
    def toaster_notification(status, details):
        """
        Send toaster notification
        :param status: Failed | Success | In-Progress
        :param details: Message for details field
        :return:
        """
        icon = "fail.ico" if status == "Failed" else "success.ico"

        root_path = os.path.dirname(sys.argv[0])
        icon_path = os.path.join(root_path, "notifications", icon)

        if not os.path.isfile(icon_path):
            root_path = os.path.dirname(os.path.realpath(__file__))
            icon_path = os.path.join(root_path, "notifications", icon)  # dev path

        notification.notify(title=status, message=details, app_icon=icon_path, timeout=15)

    def get_installation_history(self):
        """
        Read a file with installation history
        create a file if does not exist
        :return: dict with history or empty in case if file was deleted during run of installation
        """
        if os.path.isfile(self.history_file):
            try:
                with open(self.history_file) as file:
                    self.history = json.load(file)
            except json.decoder.JSONDecodeError:
                return
        else:
            self.history = {}

    def send_statistics(self, error=None):
        """
        Send usage statistics to the database.
        Collect username, time, version and software installed
        in case of crash send also crash data
        :parameter: error: error message of what went wrong
        :return: None
        """

        version, tool = self.settings.version.split("_")
        time_now = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
        self.settings.username = os.getenv("username", self.settings.username)

        if self.settings.artifactory == "SharePoint":
            self.send_statistics_to_sharepoint(tool, version, time_now, error)
        else:
            self.send_statistics_to_influx(tool, version, time_now, error)

    def send_statistics_to_influx(self, tool, version, time_now, error, downloader_ver=__version__):
        """
        Send statistics to InfluxDB
        Args:
            tool: product that is installed
            version: version
            time_now: time
            error: error if some crash occurred
            downloader_ver: version of the backend
        Returns:
            None
        """
        client = InfluxDBClient(host=STATISTICS_SERVER, port=STATISTICS_PORT)
        db_name = "downloads" if not error else "crashes"
        client.switch_database(db_name)
        json_body = [
            {
                "measurement": db_name,
                "tags": {
                    "username": self.settings.username,
                    "version": version,
                    "tool": tool,
                    "artifactory": self.settings.artifactory,
                    "downloader_ver": downloader_ver,
                },
                "time": time_now,
                "fields": {"count": 1},
            }
        ]

        if error:
            json_body[0]["tags"]["log"] = error

        client.write_points(json_body)

    def send_statistics_to_sharepoint(self, tool, version, time_now, error):
        """
        Send statistics to SharePoint list
        Args:
            time_now: time
            tool: product that is installed
            version: version
            error: error if some crash occurred
        Returns:
            None
        """
        list_name = "statistics" if not error else "crashes"
        target_list = self.ctx.web.lists.get_by_title(list_name)

        item = {
            "Title": self.settings.username,
            "Date": str(time_now),
            "tool": tool,
            "version": version,
            "in_influx": False,
            "downloader_ver": __version__,
        }

        if error:
            error = error.replace("\n", "#N").replace("\r", "")
            item["error"] = error

        target_list.add_item(item)
        self.ctx.execute_query()

    @staticmethod
    def subprocess_call(command, shell=False, popen=False):
        """
        Wrapper for subprocess call to handle non admin run or UAC issue

        Args:
            command: (str/list) command to run
            shell: call with shell mode or not
            popen: in case if you need output we need to use Popen. Pyinstaller compiles in -noconsole, need
              to explicitly define stdout, in, err

        Returns:
            output (str), output of the command run
        """

        output = ""
        try:
            if isinstance(command, list):
                command_str = subprocess.list2cmdline(command)
            else:
                command_str = command
            logging.info(command_str)

            if popen:
                p = subprocess.Popen(
                    command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=shell
                )

                byte_output = p.stdout.read()
                output = byte_output.decode("utf-8").rstrip()
                p.communicate()
            else:
                subprocess.call(command, shell=shell)

            return output
        except OSError:
            raise DownloaderError("Please run as administrator and disable Windows UAC")

    @staticmethod
    def parse_args(version):
        """
        Function to parse arguments provided to the script. Search for -p key to get settings path
        :return: settings_path: path to the configuration file
        """
        parser = argparse.ArgumentParser()
        # Add long and short argument
        parser.add_argument("--path", "-p", help="set path to the settings file generated by UI")
        parser.add_argument("--version", "-V", action="version", version=f"%(prog)s version: {version}")
        args = parser.parse_args()

        if args.path:
            settings_path = args.path
            if not os.path.isfile(settings_path):
                raise DownloaderError("Settings file does not exist")

            return settings_path
        else:
            raise DownloaderError("Please provide --path argument")


def generate_hash_str():
    """
    generate random hash. Letter A at the end is important to preserver Order in JS
    :return: hash code (str)
    """
    return f"{random.getrandbits(32):x}A".strip()


def set_logger(logging_file):
    """
    Function to setup logging output to stream and log file. Will be used by UI and backend
    :param: logging_file (str): path to the log file
    :return: None
    """

    work_dir = os.path.dirname(logging_file)
    if not os.path.isdir(work_dir):
        os.makedirs(work_dir)

    # add logging to console and log file
    # If you set the log level to INFO, it will include INFO, WARNING, ERROR, and CRITICAL messages
    logging.basicConfig(
        filename=logging_file,
        format="%(asctime)s (%(levelname)s) %(message)s",
        level=logging.INFO,
        datefmt="%d.%m.%Y %H:%M:%S",
    )
    logging.getLogger().addHandler(logging.StreamHandler())


if __name__ == "__main__":
    app = Downloader(__version__)
    app.run()

import argparse
import datetime
import errno
import json
import logging
import os
import random
import re
import shutil
import subprocess
import sys
import time
import traceback
import zipfile
import zlib
from functools import wraps
from types import SimpleNamespace

import psutil
import py7zr
from artifactory import ArtifactoryPath
from artifactory import md5sum
from artifactory_du import artifactory_du
from dohq_artifactory import ArtifactoryException
from influxdb import InfluxDBClient
from office365.runtime.auth.authentication_context import AuthenticationContext
from office365.runtime.client_request_exception import ClientRequestException
from office365.sharepoint.client_context import ClientContext
from plyer import notification
from requests.exceptions import RequestException
from urllib3.exceptions import HTTPError

import iss_templates

__author__ = "<NAME>"
__email__ = "<EMAIL>"
__version__ = "3.0.2"

STATISTICS_SERVER = "OTTBLD02"
STATISTICS_PORT = 8086

TIMEOUT = 90

ARTIFACTORY_DICT = {
    "Azure": "http://azwec7artsrv01.ansys.com:8080/artifactory",
    "Austin": "http://ausatsrv01.ansys.com:8080/artifactory",
    "Boulder": "http://bouartifact.ansys.com:8080/artifactory",
    "Canonsburg": "http://canartifactory.ansys.com:8080/artifactory",
    "Concord": "http://convmartifact.win.ansys.com:8080/artifactory",
    "Darmstadt": "http://darvmartifact.win.ansys.com:8080/artifactory",
    "Evanston": "http://evavmartifact:8080/artifactory",
    "Hannover": "http://hanartifact1.ansys.com:8080/artifactory",
    "Horsham": "http://horvmartifact1.ansys.com:8080/artifactory",
    "Lebanon": "http://lebartifactory.win.ansys.com:8080/artifactory",
    "Lyon": "http://lyovmartifact.win.ansys.com:8080/artifactory",
    "Otterfing": "http://ottvmartifact.win.ansys.com:8080/artifactory",
    "Pune": "http://punvmartifact.win.ansys.com:8080/artifactory",
    "Sheffield": "http://shfvmartifact.win.ansys.com:8080/artifactory",
    "SanJose": "http://sjoartsrv01.ansys.com:8080/artifactory",
    "Waterloo": "https://watartifactory.win.ansys.com:8443/artifactory",
}

SHAREPOINT_SITE_URL = r"https://ansys.sharepoint.com/sites/BetaDownloader"


class DownloaderError(Exception):
    pass


def retry(exceptions, tries=4, delay=3, backoff=1, logger=None, proc_lock=False):
    """
        Retry calling the decorated function using an exponential backoff.

    Args:
        exceptions (Exception or tuple): the exception to check. may be a tuple of exceptions to check
        tries (int): number of times to try (not retry) before giving up
        delay (int): initial delay between retries in seconds
        backoff (int): backoff multiplier e.g. value of 2 will double the delay each retry
        logger (logging): logger to use. If None, print
        proc_lock (bool): if retry is applied to proc lock function

    Returns: decorator

    """

    def deco_retry(func):
        @wraps(func)
        def f_retry(self, *args, **kwargs):
            mtries, mdelay = tries, delay
            while mtries > 0:
                try:
                    return func(self, *args, **kwargs)
                except exceptions as e:
                    msg = f"{e}. Error occurred, attempt: {tries - mtries + 1}/{tries}"

                    if proc_lock:
                        # only applied for process lock
                        err = "Stop all processes running from installation folder. "
                        err += f"Attempt: {tries - mtries + 1}/{tries}"
                        if mtries > 1:
                            err += " Autoretry in 60sec."
                            Downloader.toaster_notification("Failed", err)
                        else:
                            raise DownloaderError(msg)

                    if logger:
                        logger.warning(msg)
                    else:
                        print(msg)
                    time.sleep(mdelay)
                    mtries -= 1
                    mdelay *= backoff
            else:
                error = (
                    "Please verify that your connection is stable, avoid switching state of VPN during download. "
                    "For artifactory you have to be on VPN. "
                    f"Number of attempts: {tries}/{tries}"
                )

                if logger:
                    raise DownloaderError(error)
                else:
                    print(error)

        return f_retry  # true decorator

    return deco_retry


class Downloader:
    """
    Main class that operates the download and installation process:
    1. enables logs
    2. parses arguments to get settings file
    3. loads JSON to named tuple
    4. gets URL for selected version based on server
    5. downloads zip archive with BETA build
    6. unpacks it to download folder
    7. depending on the choice proceeds to installation of EDT or WB
    8. uninstalls previous build if one exists
    9. updates registry of EDT
    10. sends statistics to the server

    Performs checks:
    1. if the same build date is already installed, then do not proceed to download
    2. if some process is running from installation folder it will abort download

    Notes:
    Software attempts to download 4 times, if connection is still bad will abort
    """

    def __init__(self, version, settings_folder="", settings_path=""):
        """
        :parameter: version: version of the file, used if invoke file with argument -V to get version

        self.build_artifactory_path (ArtifactoryPath): URL to the latest build that will be used to download archive
        self.zip_file (str): path to the zip file on the PC
        self.target_unpack_dir (str): path where to unpack .zip
        self.installed_product_info (str): path to the product.info of the installed build
        self.product_root_path (str): root path of Ansys Electronics Desktop/ Workbench installation
        self.product_version (str): version to use in env variables eg 202
        self.setup_exe (str): path to the setup.exe from downloaded and unpacked zip
        self.remote_build_date (str): build date that receive from SharePoint
        self.hash (str): hash code used for this run of the program
        self.pid: pid (process ID of the current Python run, required to allow kill in UI)
        self.ctx: context object to authorize in SharePoint using office365 module
        self.settings_folder (str): default folder where all configurations would be saved
        self.history_file (str): file where installation progress would be written (this file is tracked by UI)
        self.history (dict): dict with history of installation processes
        self.logging_file (str): file where detailed log for all runs is saved

        """
        self.build_artifactory_path = ArtifactoryPath()
        self.zip_file = ""
        self.target_unpack_dir = ""
        self.installed_product_info = ""
        self.product_root_path = ""
        self.product_version = ""
        self.setup_exe = ""
        self.remote_build_date = ""

        self.pid = str(os.getpid())
        self.ctx = None
        self.warnings_list = []

        self.hash = generate_hash_str()
        if not settings_folder:
            self.settings_folder = os.path.join(os.environ["APPDATA"], "build_downloader")
        else:
            self.settings_folder = settings_folder

        self.check_and_make_directories(self.settings_folder)

        self.history = {}
        self.history_file = os.path.join(self.settings_folder, "installation_history.json")
        self.get_installation_history()

        self.logging_file = os.path.join(self.settings_folder, "downloader.log")

        self.settings_path = settings_path if settings_path else self.parse_args(version)
        with open(self.settings_path, "r") as file:
            self.settings = json.load(file, object_hook=lambda d: SimpleNamespace(**d))

        # this part of the code creates attributes that were added. Required for compatibility
        if not hasattr(self.settings, "replace_shortcut"):
            # v2.0.0
            self.settings.replace_shortcut = True

        if not hasattr(self.settings, "custom_flags"):
            # v2.2.0
            self.settings.custom_flags = ""

        if not hasattr(self.settings, "license_file"):
            # v3.0.0
            self.settings.license_file = ""

        if not hasattr(self.settings, "wb_assoc"):
            # v3.0.0
            self.settings.wb_assoc = ""

        if "ElectronicsDesktop" in self.settings.version:
            self.product_version = self.settings.version[1:4]
            if float(self.product_version) >= 221:
                self.product_root_path = os.path.join(
                    self.settings.install_path, "AnsysEM", f"v{self.product_version}", "Win64"
                )
            else:
                self.product_root_path = os.path.join(
                    self.settings.install_path,
                    "AnsysEM",
                    "AnsysEM" + self.product_version[:2] + "." + self.product_version[2:],
                    "Win64",
                )

            self.installed_product_info = os.path.join(self.product_root_path, "product.info")
        elif "Workbench" in self.settings.version:
            self.product_root_path = os.path.join(self.settings.install_path, "ANSYS Inc", self.settings.version[:4])
        elif "LicenseManager" in self.settings.version:
            self.product_root_path = os.path.join(
                self.settings.install_path, "ANSYS Inc", "Shared Files", "Licensing", "tools", "lmcenter"
            )

    def authorize_sharepoint(self):
        """
        Function that uses PnP to authorize user in SharePoint using Windows account and to get actual client_id and
        client_secret
        Returns: ctx: authorization context for Office365 library

        """
        self.update_installation_history(status="In-Progress", details="Authorizing in SharePoint")

        command = "powershell.exe "
        command += "Connect-PnPOnline -Url https://ansys.sharepoint.com/sites/BetaDownloader -UseWebLogin;"
        command += '(Get-PnPListItem -List secret_list -Fields "Title","client_id","client_secret").FieldValues'
        out_str = self.subprocess_call(command, shell=True, popen=True)

        secret_list = []
        try:
            for line in out_str.splitlines():
                if "Title" in line:
                    secret_dict = {"Title": line.split()[1]}
                elif "client_id" in line:
                    secret_dict["client_id"] = line.split()[1]
                elif "client_secret" in line:
                    secret_dict["client_secret"] = line.split()[1]
                    secret_list.append(secret_dict)
        except NameError:
            raise DownloaderError("Cannot retrieve authentication tokens for SharePoint")

        secret_list.sort(key=lambda elem: elem["Title"], reverse=True)

        context_auth = AuthenticationContext(url=SHAREPOINT_SITE_URL)
        context_auth.acquire_token_for_app(
            client_id=secret_list[0]["client_id"], client_secret=secret_list[0]["client_secret"]
        )

        ctx = ClientContext(SHAREPOINT_SITE_URL, context_auth)
        return ctx

    def run(self):
        """
        Function that executes the download-installation process
        :return: None
        """
        try:
            set_logger(self.logging_file)
            logging.info(f"Settings path is set to {self.settings_path}")

            if self.settings.artifactory == "SharePoint":
                self.ctx = self.authorize_sharepoint()

            self.update_installation_history(status="In-Progress", details="Verifying configuration")
            self.check_and_make_directories(self.settings.install_path, self.settings.download_path)
            if "ElectronicsDesktop" in self.settings.version or "Workbench" in self.settings.version:
                space_required = 15

                # License Manager can be updated even if running
                self.check_process_lock()
            else:
                if not self.settings.license_file:
                    raise DownloaderError("No license file defined. Please select it in Advanced Settings")

                if not os.path.isfile(self.settings.license_file):
                    raise DownloaderError(f"No license file was detected under {self.settings.license_file}")

                space_required = 1

            self.check_free_space(self.settings.download_path, space_required)
            self.check_free_space(self.settings.install_path, space_required)

            self.get_build_link()
            if self.settings.force_install or self.newer_version_exists:
                self.download_file()

                if "ElectronicsDesktop" in self.settings.version or "Workbench" in self.settings.version:
                    self.check_process_lock()  # download can take time, better to recheck again
                self.install()
                try:
                    self.send_statistics()
                except Exception:
                    self.warnings_list.append("Connection to product improvement server failed")
                self.update_installation_history(status="Success", details="Normal completion")
            else:
                raise DownloaderError("Versions are up to date. If issue occurred please use force install flag")
            return
        except DownloaderError as e:
            # all caught errors are here
            logging.error(e)
            self.update_installation_history(status="Failed", details=str(e))
        except Exception:
            logging.error(traceback.format_exc())
            self.update_installation_history(status="Failed", details="Unexpected error, see logs")
            self.send_statistics(error=traceback.format_exc())
        self.clean_temp()

    @staticmethod
    def check_and_make_directories(*paths):
        """
        Verify that installation and download path exists.
        If not tries to create a requested path
        :parameter: paths: list of paths that we need to check and create
        """
        for path in paths:
            if not os.path.isdir(path):
                try:
                    os.makedirs(path)
                except PermissionError:
                    raise DownloaderError(f"{path} could not be created due to insufficient permissions")
                except OSError as err:
                    if "BitLocker" in str(err):
                        raise DownloaderError("Your drive is locked by BitLocker. Please unlock!")
                    else:
                        raise DownloaderError(err)

    @staticmethod
    def check_free_space(path, required):
        """
        Verifies that enough disk space is available. Raises error if not enough space
        :param path: path where to check
        :param required: value in GB that should be available on drive to pass the check
        :return:
        """
        free_space = shutil.disk_usage(path).free // (2**30)
        if free_space < required:
            err = f"Disk space in {path} is less than {required}GB. This would not be enough to proceed"
            raise DownloaderError(err)

    @retry((DownloaderError,), tries=3, delay=60, logger=logging, proc_lock=True)
    def check_process_lock(self):
        """
        Verify if some executable is running from installation folder
        Abort installation if any process is running from installation folder
        :return: None
        """
        process_list = []
        for process in psutil.process_iter():
            try:
                if self.product_root_path in process.exe():
                    process_list.append(process.name())
            except psutil.AccessDenied:
                pass

        if process_list:
            process_list.sort(key=len)  # to fit into UI
            raise DownloaderError(
                "Following processes are running from installation directory: "
                + f"{', '.join(set(process_list))}. Please stop all processes."
            )

    @property
    def newer_version_exists(self):
        """
        verify if version on the server is newer compared to installed
        Returns:
            (bool) True if remote is newer or no version is installed, False if remote is the same or older
        """
        if "Workbench" in self.settings.version:
            product_installed = os.path.join(self.product_root_path, "package.id")
        elif "LicenseManager" in self.settings.version:
            # always update LM
            return True
        else:
            product_installed = self.installed_product_info

        if os.path.isfile(product_installed):
            if "Workbench" in self.settings.version:
                with open(product_installed) as file:
                    installed_product_version = next(file).rstrip().split()[-1]  # get first line
                    try:
                        installed_product_version = int(installed_product_version.split("P")[0])
                    except ValueError:
                        installed_product_version = 0
            else:
                installed_product_version = self.get_edt_build_date(product_installed)

            logging.info(f"Installed version of {self.settings.version} is {installed_product_version}")
            new_product_version = self.get_new_build_date()

            if not all([new_product_version, installed_product_version]):
                # some of the versions could not be parsed, need installation
                return True

            if new_product_version <= installed_product_version:
                return False
        return True

    def get_new_build_date(self, distribution="winx64"):
        """
        Create URL for new build extraction or extract version from self.remote_build_date for SharePoint download
        Returns:
            new_product_version (int) version of the product on the server
        """
        if self.settings.artifactory != "SharePoint":
            if "Workbench" in self.settings.version:
                url = self.build_artifactory_path.joinpath("package.id")
            elif "ElectronicsDesktop" in self.settings.version:
                system = "windows" if distribution == "winx64" else "linux"
                url = self.build_artifactory_path.parent.joinpath(f"product_{system}.info")
            else:
                # todo add license manager handling
                return 0

            logging.info(f"Request info about new package: {url}")
            new_product_version = self.get_build_info_file_from_artifactory(url)
        else:
            try:
                new_product_version = int(self.remote_build_date)
            except ValueError:
                return 0

        logging.info(f"Version on artifactory/SP is {new_product_version}")
        return new_product_version

    @retry((HTTPError, RequestException, ConnectionError, ConnectionResetError), 4, logger=logging)
    def get_build_link(self, distribution="winx64"):
        """
        Function that sends HTTP request to JFrog and get the list of folders with builds for Electronics Desktop and
        checks user password
        If use SharePoint then readdress to SP list
        :modify: (str) self.build_artifactory_path: URL link to the latest build that will be used to download archive
        """
        self.update_installation_history(status="In-Progress", details="Search latest build URL")
        if self.settings.artifactory == "SharePoint":
            self.get_sharepoint_build_info()
            return

        if not hasattr(self.settings.password, self.settings.artifactory):
            raise DownloaderError(f"Please provide password for {self.settings.artifactory}")

        password = getattr(self.settings.password, self.settings.artifactory)

        if not self.settings.username or not password:
            raise DownloaderError("Please provide username and artifactory password")

        server = ARTIFACTORY_DICT[self.settings.artifactory]

        art_path = ArtifactoryPath(server, auth=(self.settings.username, password), timeout=TIMEOUT)
        try:
            repos_list = art_path.get_repositories(lazy=True)
        except ArtifactoryException as err:
            raise DownloaderError(f"Cannot retrieve repositories. Error: {err}")

        # fill the dictionary with Electronics Desktop and Workbench keys since builds could be different
        # still parse the list because of different names on servers
        artifacts_dict = {}
        for repo in repos_list:
            repo_name = repo.name
            if "EBU_Certified" in repo_name:
                version = repo_name.split("_")[0] + "_ElectronicsDesktop"
            elif "Certified" in repo_name and "Licensing" not in repo_name:
                version = repo_name.split("_")[0] + "_Workbench"
            elif "Certified" in repo_name and "Licensing" in repo_name:
                version = repo_name.split("_")[0] + "_LicenseManager"
            else:
                continue

            if version not in artifacts_dict:
                # extract real repo name in case it is cached
                if art_path.joinpath(repo_name).exists():
                    # repo might be syncing (happens on new release addition)
                    artifacts_dict[version] = art_path.joinpath(repo_name).stat().repo

        try:
            repo = artifacts_dict[self.settings.version]
        except KeyError:
            raise DownloaderError(
                f"Version {self.settings.version} that you have specified "
                + f"does not exist on {self.settings.artifactory}"
            )

        path = ""
        art_path = art_path.joinpath(str(repo))
        if "ElectronicsDesktop" in self.settings.version:
            archive = "winx64.zip" if distribution == "winx64" else "linx64.tgz"
            builds_dates = []
            for relative_p in art_path:
                try:
                    archive_exists = list(relative_p.glob(f"Electronics*{archive}"))
                    if archive_exists:
                        builds_dates.append(int(relative_p.name))
                except ValueError:
                    pass

            if not builds_dates:
                raise DownloaderError("Artifact does not exist")

            latest_build = sorted(builds_dates)[-1]

            art_path = art_path.joinpath(str(latest_build))
            for path in art_path:
                if archive in path.name:
                    break
            else:
                raise DownloaderError(f"Cannot find {distribution} archive file")

        elif "Workbench" in self.settings.version or "LicenseManager" in self.settings.version:
            path = art_path.joinpath(distribution)

        if not path:
            raise DownloaderError("Cannot receive URL")

        self.build_artifactory_path = path

    def get_sharepoint_build_info(self):
        """
        Gets link to the latest build from SharePoint and builddate itself
        Returns: None

        """
        product_list = self.ctx.web.lists.get_by_title("product_list")
        items = product_list.items
        self.ctx.load(items).execute_query()

        build_list = []
        for item in items:
            title = item.properties["Title"]
            if title != self.settings.version:
                continue

            build_dict = {
                "Title": title,
                "build_date": item.properties["build_date"],
                "relative_url": item.properties["relative_url"],
            }
            build_list.append(build_dict)

        build_list.sort(key=lambda elem: elem["build_date"], reverse=True)

        build_dict = build_list[0] if build_list else {}
        if not build_dict:
            raise DownloaderError(f"No version of {self.settings.version} is available on SharePoint")

        self.build_artifactory_path = build_dict["relative_url"]
        self.remote_build_date = build_dict["build_date"]

    def download_file(self):
        """
        Downloads file in chunks and saves to the temp.zip file
        Uses url to the zip archive or special JFrog API to download Workbench folder
        :modify: (str) zip_file: link to the zip file
        """
        if self.settings.artifactory == "SharePoint" or "win" in self.build_artifactory_path.name:
            archive_type = "zip"
        else:
            archive_type = "tgz"

        self.zip_file = os.path.join(self.settings.download_path, f"{self.settings.version}.{archive_type}")
        chunk_size = 50 * 1024 * 1024
        if self.settings.artifactory == "SharePoint":
            self.download_from_sharepoint(chunk_size=chunk_size)
        else:
            self.download_from_artifactory(archive_type, chunk_size=chunk_size)

        logging.info(f"File is downloaded to {self.zip_file}")

    @retry((HTTPError, RequestException, ConnectionError, ConnectionResetError), 4, logger=logging)
    def download_from_artifactory(self, archive_type, chunk_size):
        """
        Download file from Artifactory
        Args:
            archive_type: type of the archive, zip or tgz
            chunk_size: (int) chunk size in bytes when download

        Returns: None
        """
        if not self.build_artifactory_path.replication_status["status"] in ["ok", "never_run"]:
            raise DownloaderError("Currently Artifactory repository is replicating, please try later")

        logging.info(f"Start download file from {self.build_artifactory_path} to {self.zip_file}")
        self.update_installation_history(
            status="In-Progress", details=f"Downloading file from {self.settings.artifactory}"
        )
        if "ElectronicsDesktop" in self.settings.version:
            file_stats = self.build_artifactory_path.stat()
            arti_file_md5 = file_stats.md5
            logging.info(f"Artifactory hash: {arti_file_md5}")

            try:
                self.build_artifactory_path.writeto(
                    out=self.zip_file, chunk_size=chunk_size, progress_func=self.print_download_progress
                )
            except RuntimeError as err:
                raise DownloaderError(f"Cannot download file. Server returned status code: {err}")

            local_md5_hash = md5sum(self.zip_file)
            logging.info(f"Local file hash: {local_md5_hash}")
            if local_md5_hash != arti_file_md5:
                raise DownloaderError("Downloaded file MD5 hash is different")

        elif "Workbench" in self.settings.version or "LicenseManager" in self.settings.version:
            try:
                file_size = self.get_artifactory_folder_size()
                logging.info(f"Workbench/License Manager real file size is {file_size}")
            except (TypeError, ValueError):
                file_size = 14e9

            archive_url = self.build_artifactory_path.archive(archive_type=archive_type)
            archive_url.writeto(
                out=self.zip_file,
                chunk_size=chunk_size,
                progress_func=lambda x, y: self.print_download_progress(x, file_size),
            )

    def get_artifactory_folder_size(self):
        aql_query_dict, max_depth_print = artifactory_du.prepare_aql(
            file=f"/{self.build_artifactory_path.name}",
            max_depth=0,
            repository=self.build_artifactory_path.repo,
            without_downloads="",
            older_than="",
        )
        artifacts = artifactory_du.artifactory_aql(
            artifactory_url=str(self.build_artifactory_path.drive),
            aql_query_dict=aql_query_dict,
            username=self.build_artifactory_path.auth[0],
            password=self.build_artifactory_path.auth[1],
            kerberos=False,
            verify=False,
        )
        file_size = artifactory_du.out_as_du(artifacts, max_depth_print, human_readable=False)
        file_size = int(file_size.strip("/"))
        return file_size

    @retry(
        (HTTPError, RequestException, ConnectionError, ConnectionResetError, ClientRequestException),
        tries=4,
        logger=logging,
    )
    def download_from_sharepoint(self, chunk_size):
        """
        Downloads file from Sharepoint
        Args:
            chunk_size: (int) chunk size in bytes when download
        Returns: None
        """
        self.update_installation_history(status="In-Progress", details="Downloading file from SharePoint")
        remote_file = self.ctx.web.get_file_by_server_relative_url(
            f"/sites/BetaDownloader/{self.build_artifactory_path}"
        )
        remote_file.get()
        try:
            self.ctx.execute_query()
        except ClientRequestException as err:
            logging.error(str(err))
            raise DownloaderError(
                "URL on SharePoint is broken. Report an issue to <EMAIL>. "
                "In meantime please switch to any other repository."
            )

        file_size = remote_file.length
        self.check_free_space(self.settings.download_path, file_size / 1024 / 1024 / 1024)

        try:
            with open(self.zip_file, "wb") as zip_file:
                try:
                    remote_file.download_session(
                        zip_file,
                        lambda offset: self.print_download_progress(offset, total_size=file_size),
                        chunk_size=chunk_size,
                    )
                    self.ctx.execute_query()
                except OSError as err:
                    if err.errno == errno.ENOSPC:
                        raise DownloaderError("No disk space available in download folder!")
                    raise
        except PermissionError as err:
            msg = str(err).replace("[Errno 13]", "").strip()
            raise DownloaderError(msg)

        if not self.zip_file:
            raise DownloaderError("ZIP download failed")

        if abs(os.path.getsize(self.zip_file) - file_size) > 0.05 * file_size:
            raise DownloaderError("File size difference is more than 5%")

    def print_download_progress(self, offset, total_size):
        msg = "Downloaded {}/{}MB...[{}%]".format(
            int(offset / 1024 / 1024), int(total_size / 1024 / 1024), min(round(offset / total_size * 100, 2), 100)
        )
        logging.info(msg)
        self.update_installation_history(status="In-Progress", details=msg)

    def install(self, local_lang=False):
        """
        Unpack downloaded zip and proceed to installation. Different executions for Electronics Desktop and Workbench
        :param local_lang: if not specified then use English as default installation language
        :return: None
        """
        self.unpack_archive()

        if "ElectronicsDesktop" in self.settings.version:
            self.install_edt()
        elif "Workbench" in self.settings.version:
            self.install_wb(local_lang)
        else:
            self.install_license_manager()

        self.update_installation_history(status="In-Progress", details="Clean temp directory")
        self.clean_temp()

    def unpack_archive(self):
        self.update_installation_history(status="In-Progress", details="Start unpacking")
        self.target_unpack_dir = self.zip_file.replace(".zip", "")
        try:
            with zipfile.ZipFile(self.zip_file, "r") as zip_ref:
                zip_ref.extractall(self.target_unpack_dir)
        except OSError as err:
            if err.errno == errno.ENOSPC:
                raise DownloaderError("No disk space available in download folder!")
            else:
                raise DownloaderError(f"Cannot unpack due to {err}")
        except (zipfile.BadZipFile, zlib.error):
            raise DownloaderError("Zip file is broken. Please try again later or use another repository.")
        logging.info(f"File is unpacked to {self.target_unpack_dir}")

    def install_edt(self):
        """
        Install Electronics Desktop. Make verification that the same version is not yet installed and makes
        silent installation
        Get Workbench installation path from environment variable and enables integration if exists.
        :return: None
        """
        setup_exe, product_id, installshield_version = self.parse_iss_template(self.target_unpack_dir)
        self.uninstall_edt(setup_exe, product_id, installshield_version)

        install_iss_file, install_log_file = self.create_install_iss_file(installshield_version, product_id)

        command = [f'"{setup_exe}"', "-s", rf'-f1"{install_iss_file}"', rf'-f2"{install_log_file}"']
        command = " ".join(command)
        self.update_installation_history(status="In-Progress", details="Start installation")
        logging.info("Execute installation")
        self.subprocess_call(command)

        self.check_result_code(install_log_file)
        self.update_edt_registry()
        self.remove_aedt_shortcuts()

    def create_install_iss_file(self, installshield_version, product_id):
        install_iss_file = os.path.join(self.target_unpack_dir, "install.iss")
        install_log_file = os.path.join(self.target_unpack_dir, "install.log")
        integrate_wb = 0
        awp_env_var = "AWP_ROOT" + self.product_version
        if awp_env_var in os.environ:
            run_wb = os.path.join(os.environ[awp_env_var], "Framework", "bin", "Win64", "RunWB2.exe")
            if os.path.isfile(run_wb):
                integrate_wb = 1
                logging.info("Integration with Workbench turned ON")
        # the "shared files" is created at the same level as the "AnsysEMxx.x" so if installing to unique folders,
        # the Shared Files folder will be unique as well. Thus we can check install folder for license
        if os.path.isfile(
            os.path.join(self.settings.install_path, "AnsysEM", "Shared Files", "Licensing", "ansyslmd.ini")
        ):
            install_iss = iss_templates.install_iss + iss_templates.existing_server
            logging.info("Install using existing license configuration")
        else:
            install_iss = iss_templates.install_iss + iss_templates.new_server
            logging.info("Install using 127.0.0.1, Otterfing and HQ license servers")
        with open(install_iss_file, "w") as file:
            file.write(
                install_iss.format(
                    product_id,
                    os.path.join(self.settings.install_path, "AnsysEM"),
                    os.environ["TEMP"],
                    integrate_wb,
                    installshield_version,
                )
            )
        return install_iss_file, install_log_file

    def uninstall_edt(self, setup_exe, product_id, installshield_version):
        """
        Silently uninstall build of the same version
        :return: None
        """
        if os.path.isfile(self.installed_product_info):
            uninstall_iss_file = os.path.join(self.target_unpack_dir, "uninstall.iss")
            uninstall_log_file = os.path.join(self.target_unpack_dir, "uninstall.log")
            with open(uninstall_iss_file, "w") as file:
                file.write(iss_templates.uninstall_iss.format(product_id, installshield_version))

            command = [
                f'"{setup_exe}"',
                "-uninst",
                "-s",
                rf'-f1"{uninstall_iss_file}"',
                rf'-f2"{uninstall_log_file}"',
            ]
            command = " ".join(command)
            logging.info("Execute uninstallation")
            self.update_installation_history(status="In-Progress", details="Uninstall previous build")
            self.subprocess_call(command)

            self.check_result_code(uninstall_log_file, False)
            em_main_dir = os.path.dirname(self.product_root_path)
            self.remove_path(em_main_dir)

            if os.path.isdir(em_main_dir):
                raise DownloaderError(f"Failed to remove {em_main_dir}. Probably directory is locked.")
        else:
            logging.info("Version is not installed, skip uninstallation")

    def check_result_code(self, log_file, installation=True):
        """
        Verify result code of the InstallShield log file
        :param log_file: installation log file
        :param installation: True if verify log after installation elif after uninstallation False
        :return: None
        """
        success = "New build was successfully installed" if installation else "Previous build was uninstalled"
        fail = "Installation went wrong" if installation else "Uninstallation went wrong"
        if not os.path.isfile(log_file):
            raise DownloaderError(f"{fail}. Check that UAC disabled or confirm UAC question manually")

        msg = fail
        regex = "ResultCode=(.*)"
        with open(log_file) as file:
            for line in file:
                code = re.findall(regex, line)
                if code and code[0] == "0":
                    logging.info(success)
                    break
            else:
                if not installation:
                    msg = "Official uninstaller failed, make hard remove"
                    logging.error(msg)
                    self.warnings_list.append(msg)
                else:
                    raise DownloaderError(msg)

    @staticmethod
    def get_edt_build_date(product_info_file="", file_content=None):
        """
        extract information about Electronics Desktop build date and version
        Args:
            product_info_file: path to the product.info
            file_content (list): accepts list with file content as well instead of file

        Returns: (int) build_date

        """
        if os.path.isfile(product_info_file):
            with open(product_info_file) as file:
                file_content = file.readlines()

        for line in file_content:
            if "AnsProductBuildDate" in line:
                full_build_date = line.split("=")[1].replace('"', "").replace("-", "")
                build_date = full_build_date.split()[0]
                break
        else:
            # cannot find line with build date
            return 0

        try:
            return int(build_date)
        except ValueError:
            return 0

    @staticmethod
    def parse_iss_template(unpacked_dir):
        """
        Open directory with unpacked build of Electronics Desktop and search for SilentInstallationTemplate.iss to
        extract product ID which is GUID hash
        Args:
            unpacked_dir: directory where AEDT package was unpacked
        Returns:
            product_id: product GUID extracted from iss template
            setup_exe: set path to setup.exe if exists
            installshield_version: set version from file
        """
        default_iss_file = ""
        setup_exe = ""
        product_id_match = []

        for dir_path, dir_names, file_names in os.walk(unpacked_dir):
            for filename in file_names:
                if "AnsysEM" in dir_path and filename.endswith(".iss"):
                    default_iss_file = os.path.join(dir_path, filename)
                    setup_exe = os.path.join(dir_path, "setup.exe")
                    break

        if not default_iss_file:
            raise DownloaderError("SilentInstallationTemplate.iss does not exist")

        if not os.path.isfile(setup_exe):
            raise DownloaderError("setup.exe does not exist")

        with open(default_iss_file, "r") as iss_file:
            for line in iss_file:
                if "DlgOrder" in line:
                    guid_regex = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
                    product_id_match = re.findall(guid_regex, line)
                if "InstallShield Silent" in line:
                    installshield_version = next(iss_file).split("=")[1]

        if product_id_match:
            product_id = product_id_match[0]
            logging.info(f"Product ID is {product_id}")
        else:
            raise DownloaderError("Unable to extract product ID")

        return setup_exe, product_id, installshield_version

    def install_license_manager(self):
        """
        Install license manager and feed it with license file
        """
        self.setup_exe = os.path.join(self.target_unpack_dir, "setup.exe")

        if os.path.isfile(self.setup_exe):
            install_path = os.path.join(self.settings.install_path, "ANSYS Inc")
            if not os.path.isfile(self.settings.license_file):
                raise DownloaderError(f"No license file was detected under {self.settings.license_file}")

            command = [
                self.setup_exe,
                "-silent",
                "-LM",
                "-install_dir",
                install_path,
                "-lang",
                "en",
                "-licfilepath",
                self.settings.license_file,
            ]
            self.update_installation_history(status="In-Progress", details="Start installation")
            logging.info("Execute installation")
            self.subprocess_call(command)

            package_build = self.parse_lm_installer_builddate()
            installed_build = self.get_license_manager_build_date()
            if all([package_build, installed_build]) and package_build == installed_build:
                self.update_installation_history(status="Success", details="Normal completion")
            else:
                raise DownloaderError("License Manager was not installed")
        else:
            raise DownloaderError("No LicenseManager setup.exe file detected")

    def parse_lm_installer_builddate(self):
        """
        Check build date of installation package of License Manager
        """

        build_file = os.path.join(self.target_unpack_dir, "builddate.txt")
        lm_center_archive = os.path.join(self.target_unpack_dir, "lmcenter", "WINX64.7z")

        if not os.path.isfile(build_file) and os.path.isfile(lm_center_archive):
            with py7zr.SevenZipFile(lm_center_archive, "r") as archive:
                archive.extractall(path=os.path.join(self.target_unpack_dir, "lmcenter"))
            build_file = os.path.join(
                self.target_unpack_dir,
                "lmcenter",
                "Shared Files",
                "licensing",
                "tools",
                "lmcenter",
                "lmcenter_blddate.txt",
            )

        if not os.path.isfile(build_file):
            # check again if file was unpacked
            logging.warning("builddate.txt was not found in installation package")
            return

        with open(build_file) as file:
            for line in file:
                if "license management center" in line.lower():
                    lm_build_date = line.split()[-1]
                    try:
                        logging.info(f"Build date of License Manager in installation package {lm_build_date}")
                        lm_build_date = int(lm_build_date)
                        return lm_build_date
                    except TypeError:
                        raise DownloaderError("Cannot extract build date of installation package")

    def get_license_manager_build_date(self):
        """
        Check build date of installed License Manager
        """
        build_date_file = os.path.join(self.product_root_path, "lmcenter_blddate.txt")
        if not os.path.isfile(build_date_file):
            raise DownloaderError("lmcenter_blddate.txt is not available")

        with open(build_date_file) as file:
            lm_build_date = next(file).split()[-1]
            try:
                logging.info(f"Newly installed build date of License Manager: {lm_build_date}")
                lm_build_date = int(lm_build_date)
                return lm_build_date
            except (TypeError, ValueError):
                raise DownloaderError("Cannot extract build date of installed License Manager")

    def install_wb(self, local_lang=False):
        """
        Install Workbench to the target installation directory
        :param local_lang: if not specified then use English as default installation language
        """
        self.setup_exe = os.path.join(self.target_unpack_dir, "setup.exe")

        if os.path.isfile(self.setup_exe):
            uninstall_exe = self.uninstall_wb()

            install_path = os.path.join(self.settings.install_path, "ANSYS Inc")
            command = [self.setup_exe, "-silent", "-install_dir", install_path]
            if not local_lang:
                command += ["-lang", "en"]
            command += self.settings.wb_flags.split()

            # the "shared files" is created at the same level as the "ANSYS Inc" so if installing to unique folders,
            # the Shared Files folder will be unique as well. Thus we can check install folder for license
            if (
                os.path.isfile(os.path.join(install_path, "Shared Files", "Licensing", "ansyslmd.ini"))
                or "ANSYSLMD_LICENSE_FILE" in os.environ
            ):
                logging.info("Install using existing license configuration")
            else:
                command += ["-licserverinfo", "2325:1055:127.0.0.1,OTTLICENSE5,PITRH6LICSRV1"]
                logging.info("Install using 127.0.0.1, Otterfing and HQ license servers")

            # convert command to string to easy append custom flags
            command = subprocess.list2cmdline(command)
            command += " " + self.settings.custom_flags

            self.update_installation_history(status="In-Progress", details="Start installation")
            logging.info("Execute installation")
            self.subprocess_call(command)

            if os.path.isfile(uninstall_exe):
                logging.info("New build was installed")
            else:
                raise DownloaderError(
                    "Workbench installation failed. "
                    + f"If you see this error message by mistake please report to {__email__}"
                )

            if self.settings.wb_assoc:
                wb_assoc_exe = os.path.join(self.settings.wb_assoc, "commonfiles", "tools", "winx64", "fileassoc.exe")
                if not os.path.isfile(wb_assoc_exe):
                    self.warnings_list.append(f"Cannot find {wb_assoc_exe}")
                else:
                    logging.info("Run WB file association")
                    self.subprocess_call(wb_assoc_exe)
        else:
            raise DownloaderError("No Workbench setup.exe file detected")

    def uninstall_wb(self):
        """
        Uninstall Workbench if such exists in the target installation directory
        :return: uninstall_exe: name of the executable of uninstaller"""

        uninstall_exe = os.path.join(self.product_root_path, "Uninstall.exe")
        if os.path.isfile(uninstall_exe):
            command = [uninstall_exe, "-silent"]
            self.update_installation_history(status="In-Progress", details="Uninstall previous build")
            logging.info("Execute uninstallation")
            self.subprocess_call(command)
            logging.info("Previous build was uninstalled using uninstaller")
        else:
            logging.info("No Workbench Uninstall.exe file detected")

        self.remove_path(self.product_root_path)

        return uninstall_exe

    def remove_path(self, path):
        """
        Function to safely remove path if rmtree fails
        :param path:
        :return:
        """

        def hard_remove():
            try:
                # try this dirty method to force remove all files in directory
                all_files = os.path.join(path, "*.*")
                command = ["DEL", "/F", "/Q", "/S", all_files, ">", "NUL"]
                self.subprocess_call(command, shell=True)

                command = ["rmdir", "/Q", "/S", path]
                self.subprocess_call(command, shell=True)
            except Exception as err:
                logging.error(str(err))
                logging.error("Failed to remove directory via hard_remove")
                self.warnings_list.append("Failed to remove directory")

        logging.info(f"Removing {path}")
        if os.path.isdir(path):
            try:
                shutil.rmtree(path)
            except PermissionError:
                logging.warning("Permission error. Switch to CMD force mode")
                hard_remove()
                self.warnings_list.append("Clean remove failed due to Permissions Error")
            except (FileNotFoundError, OSError, Exception):
                logging.warning("FileNotFoundError or other error. Switch to CMD force mode")
                hard_remove()
                self.warnings_list.append("Clean remove failed due to Not Found or OS Error")

        elif os.path.isfile(path):
            os.remove(path)

    def get_build_info_file_from_artifactory(self, build_info):
        """
        Downloads product info file from artifactory

        :param (ArtifactoryPath) build_info: arti path to the package_id file
        :return: (int): package_id if extracted
        """
        product_info = 0
        package_info = build_info.read_text()
        try:
            if "Workbench" in self.settings.version:
                first_line = package_info.split("\n")[0]
                product_info = first_line.rstrip().split()[-1]
                try:
                    product_info = int(product_info.split("P")[0])
                except ValueError:
                    pass
            else:
                product_info = self.get_edt_build_date(file_content=package_info.split("\n"))
        except IndexError:
            pass

        return product_info

    def clean_temp(self):
        """
        Cleans downloaded zip and unpacked folder with content
        :return: None
        """
        try:
            if os.path.isfile(self.zip_file) and self.settings.delete_zip:
                self.remove_path(self.zip_file)
                logging.info("ZIP deleted")

            if os.path.isdir(self.target_unpack_dir):
                self.remove_path(self.target_unpack_dir)
                logging.info("Unpacked directory removed")
        except PermissionError:
            logging.error("Temp files could not be removed due to permission error")

    def remove_aedt_shortcuts(self):
        """
        Function to remove newly created AEDT shortcuts and replace them with new one
        """
        if not self.settings.replace_shortcut:
            return

        # include Public, user folder and user folder when OneDrive sync is enabled
        for user in ["Public", os.getenv("username"), os.path.join(os.getenv("username"), "OneDrive - ANSYS, Inc")]:
            desktop = os.path.join("C:\\", "Users", user, "Desktop")

            for shortcut in [
                "ANSYS Savant",
                "ANSYS EMIT",
                "ANSYS SIwave",
                "ANSYS Twin Builder",
                "Ansys Nuhertz FilterSolutions",
            ]:
                self.remove_path(os.path.join(desktop, shortcut + ".lnk"))

            new_name = os.path.join(
                desktop, f"20{self.product_version[:2]}R{self.product_version[2:]} Electronics Desktop.lnk"
            )
            aedt_shortcut = os.path.join(desktop, "ANSYS Electronics Desktop.lnk")
            if not os.path.isfile(new_name):
                try:
                    os.rename(aedt_shortcut, new_name)
                except FileNotFoundError:
                    pass
            else:
                self.remove_path(aedt_shortcut)

    def update_edt_registry(self):
        """
        Update Electronics Desktop registry based on the files in the HPC_Options folder that are added from UI
        :return: None
        """
        hpc_folder = os.path.join(self.settings_folder, "HPC_Options")

        update_registry_exe = os.path.join(self.product_root_path, "UpdateRegistry.exe")
        productlist_file = os.path.join(self.product_root_path, "config", "ProductList.txt")

        if not os.path.isfile(productlist_file):
            raise DownloaderError("Cannot update registry. Probably Electronics Desktop installation failed")

        with open(productlist_file) as file:
            product_version = next(file).rstrip()  # get first line

        self.update_installation_history(status="In-Progress", details="Update registry")
        if os.path.isdir(hpc_folder):
            for file in os.listdir(hpc_folder):
                if ".acf" in file:
                    options_file = os.path.join(hpc_folder, file)
                    command = [update_registry_exe, "-ProductName", product_version, "-FromFile", options_file]
                    logging.info("Update registry")
                    self.subprocess_call(command)

    def update_installation_history(self, status, details):
        """
        Update ordered dictionary with new data and write it to the file
        :param status: Failed | Success | In-Progress (important, used in JS)
        :param details: Message for details field
        :return:
        """
        if status == "Failed" or status == "Success":
            try:
                self.toaster_notification(status, details)
            except Exception:
                msg = "Toaster notification did not work"
                logging.error(msg)
                self.warnings_list.append(msg)

        self.get_installation_history()  # in case if file was deleted during run of installation
        time_now = datetime.datetime.now().strftime("%d-%m-%Y %H:%M")
        shorten_path = self.settings_path.replace(os.getenv("APPDATA", "@@@"), "%APPDATA%")

        if status == "Failed" or status == "Success":
            if self.warnings_list:
                details += "\nSome warnings occurred during process:\n" + "\n".join(self.warnings_list)

        self.history[self.hash] = [status, self.settings.version, time_now, shorten_path, details, self.pid]
        with open(self.history_file, "w") as file:
            json.dump(self.history, file, indent=4)

    @staticmethod
    def toaster_notification(status, details):
        """
        Send toaster notification
        :param status: Failed | Success | In-Progress
        :param details: Message for details field
        :return:
        """
        icon = "fail.ico" if status == "Failed" else "success.ico"

        root_path = os.path.dirname(sys.argv[0])
        icon_path = os.path.join(root_path, "notifications", icon)

        if not os.path.isfile(icon_path):
            root_path = os.path.dirname(os.path.realpath(__file__))
            icon_path = os.path.join(root_path, "notifications", icon)  # dev path

        notification.notify(title=status, message=details, app_icon=icon_path, timeout=15)

    def get_installation_history(self):
        """
        Read a file with installation history
        create a file if does not exist
        :return: dict with history or empty in case if file was deleted during run of installation
        """
        if os.path.isfile(self.history_file):
            try:
                with open(self.history_file) as file:
                    self.history = json.load(file)
            except json.decoder.JSONDecodeError:
                return
        else:
            self.history = {}

    def send_statistics(self, error=None):
        """
        Send usage statistics to the database.
        Collect username, time, version and software installed
        in case of crash send also crash data
        :parameter: error: error message of what went wrong
        :return: None
        """

        version, tool = self.settings.version.split("_")
        time_now = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
        self.settings.username = os.getenv("username", self.settings.username)

        if self.settings.artifactory == "SharePoint":
            self.send_statistics_to_sharepoint(tool, version, time_now, error)
        else:
            self.send_statistics_to_influx(tool, version, time_now, error)

    def send_statistics_to_influx(self, tool, version, time_now, error, downloader_ver=__version__):
        """
        Send statistics to InfluxDB
        Args:
            tool: product that is installed
            version: version
            time_now: time
            error: error if some crash occurred
            downloader_ver: version of the backend
        Returns:
            None
        """
        client = InfluxDBClient(host=STATISTICS_SERVER, port=STATISTICS_PORT)
        db_name = "downloads" if not error else "crashes"
        client.switch_database(db_name)
        json_body = [
            {
                "measurement": db_name,
                "tags": {
                    "username": self.settings.username,
                    "version": version,
                    "tool": tool,
                    "artifactory": self.settings.artifactory,
                    "downloader_ver": downloader_ver,
                },
                "time": time_now,
                "fields": {"count": 1},
            }
        ]

        if error:
            json_body[0]["tags"]["log"] = error

        client.write_points(json_body)

    def send_statistics_to_sharepoint(self, tool, version, time_now, error):
        """
        Send statistics to SharePoint list
        Args:
            time_now: time
            tool: product that is installed
            version: version
            error: error if some crash occurred
        Returns:
            None
        """
        list_name = "statistics" if not error else "crashes"
        target_list = self.ctx.web.lists.get_by_title(list_name)

        item = {
            "Title": self.settings.username,
            "Date": str(time_now),
            "tool": tool,
            "version": version,
            "in_influx": False,
            "downloader_ver": __version__,
        }

        if error:
            error = error.replace("\n", "#N").replace("\r", "")
            item["error"] = error

        target_list.add_item(item)
        self.ctx.execute_query()

    @staticmethod
    def subprocess_call(command, shell=False, popen=False):
        """
        Wrapper for subprocess call to handle non admin run or UAC issue

        Args:
            command: (str/list) command to run
            shell: call with shell mode or not
            popen: in case if you need output we need to use Popen. Pyinstaller compiles in -noconsole, need
              to explicitly define stdout, in, err

        Returns:
            output (str), output of the command run
        """

        output = ""
        try:
            if isinstance(command, list):
                command_str = subprocess.list2cmdline(command)
            else:
                command_str = command
            logging.info(command_str)

            if popen:
                p = subprocess.Popen(
                    command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=shell
                )

                byte_output = p.stdout.read()
                output = byte_output.decode("utf-8").rstrip()
                p.communicate()
            else:
                subprocess.call(command, shell=shell)

            return output
        except OSError:
            raise DownloaderError("Please run as administrator and disable Windows UAC")

    @staticmethod
    def parse_args(version):
        """
        Function to parse arguments provided to the script. Search for -p key to get settings path
        :return: settings_path: path to the configuration file
        """
        parser = argparse.ArgumentParser()
        # Add long and short argument
        parser.add_argument("--path", "-p", help="set path to the settings file generated by UI")
        parser.add_argument("--version", "-V", action="version", version=f"%(prog)s version: {version}")
        args = parser.parse_args()

        if args.path:
            settings_path = args.path
            if not os.path.isfile(settings_path):
                raise DownloaderError("Settings file does not exist")

            return settings_path
        else:
            raise DownloaderError("Please provide --path argument")


def generate_hash_str():
    """
    generate random hash. Letter A at the end is important to preserver Order in JS
    :return: hash code (str)
    """
    return f"{random.getrandbits(32):x}A".strip()


def set_logger(logging_file):
    """
    Function to setup logging output to stream and log file. Will be used by UI and backend
    :param: logging_file (str): path to the log file
    :return: None
    """

    work_dir = os.path.dirname(logging_file)
    if not os.path.isdir(work_dir):
        os.makedirs(work_dir)

    # add logging to console and log file
    # If you set the log level to INFO, it will include INFO, WARNING, ERROR, and CRITICAL messages
    logging.basicConfig(
        filename=logging_file,
        format="%(asctime)s (%(levelname)s) %(message)s",
        level=logging.INFO,
        datefmt="%d.%m.%Y %H:%M:%S",
    )
    logging.getLogger().addHandler(logging.StreamHandler())


if __name__ == "__main__":
    app = Downloader(__version__)
    app.run()

Retry calling the decorated function using an exponential backoff.      Args:         exceptions (Exception or tuple): the exception to check. may be a tuple of exceptions to check         tries (int): number of times to try (not retry) before giving up         delay (int): initial delay between retries in seconds         backoff (int): backoff multiplier e.g. value of 2 will double the delay each retry         logger (logging): logger to use. If None, print         proc_lock (bool): if retry is applied to proc lock function      Returns: decorator # only applied for process lock # true decorator Main class that operates the download and installation process:     1. enables logs     2. parses arguments to get settings file     3. loads JSON to named tuple     4. gets URL for selected version based on server     5. downloads zip archive with BETA build     6. unpacks it to download folder     7. depending on the choice proceeds to installation of EDT or WB     8. uninstalls previous build if one exists     9. updates registry of EDT     10. sends statistics to the server      Performs checks:     1. if the same build date is already installed, then do not proceed to download     2. if some process is running from installation folder it will abort download      Notes:     Software attempts to download 4 times, if connection is still bad will abort :parameter: version: version of the file, used if invoke file with argument -V to get version          self.build_artifactory_path (ArtifactoryPath): URL to the latest build that will be used to download archive         self.zip_file (str): path to the zip file on the PC         self.target_unpack_dir (str): path where to unpack .zip         self.installed_product_info (str): path to the product.info of the installed build         self.product_root_path (str): root path of Ansys Electronics Desktop/ Workbench installation         self.product_version (str): version to use in env variables eg 202         self.setup_exe (str): path to the setup.exe from downloaded and unpacked zip         self.remote_build_date (str): build date that receive from SharePoint         self.hash (str): hash code used for this run of the program         self.pid: pid (process ID of the current Python run, required to allow kill in UI)         self.ctx: context object to authorize in SharePoint using office365 module         self.settings_folder (str): default folder where all configurations would be saved         self.history_file (str): file where installation progress would be written (this file is tracked by UI)         self.history (dict): dict with history of installation processes         self.logging_file (str): file where detailed log for all runs is saved # this part of the code creates attributes that were added. Required for compatibility # v2.0.0 # v2.2.0 # v3.0.0 # v3.0.0 Function that uses PnP to authorize user in SharePoint using Windows account and to get actual client_id and         client_secret         Returns: ctx: authorization context for Office365 library Function that executes the download-installation process         :return: None # License Manager can be updated even if running # download can take time, better to recheck again # all caught errors are here Verify that installation and download path exists.         If not tries to create a requested path         :parameter: paths: list of paths that we need to check and create Verifies that enough disk space is available. Raises error if not enough space         :param path: path where to check         :param required: value in GB that should be available on drive to pass the check         :return: Verify if some executable is running from installation folder         Abort installation if any process is running from installation folder         :return: None # to fit into UI verify if version on the server is newer compared to installed         Returns:             (bool) True if remote is newer or no version is installed, False if remote is the same or older # always update LM # get first line # some of the versions could not be parsed, need installation Create URL for new build extraction or extract version from self.remote_build_date for SharePoint download         Returns:             new_product_version (int) version of the product on the server # todo add license manager handling Function that sends HTTP request to JFrog and get the list of folders with builds for Electronics Desktop and         checks user password         If use SharePoint then readdress to SP list         :modify: (str) self.build_artifactory_path: URL link to the latest build that will be used to download archive # fill the dictionary with Electronics Desktop and Workbench keys since builds could be different # still parse the list because of different names on servers # extract real repo name in case it is cached # repo might be syncing (happens on new release addition) Gets link to the latest build from SharePoint and builddate itself         Returns: None Downloads file in chunks and saves to the temp.zip file         Uses url to the zip archive or special JFrog API to download Workbench folder         :modify: (str) zip_file: link to the zip file Download file from Artifactory         Args:             archive_type: type of the archive, zip or tgz             chunk_size: (int) chunk size in bytes when download          Returns: None Downloads file from Sharepoint         Args:             chunk_size: (int) chunk size in bytes when download         Returns: None Unpack downloaded zip and proceed to installation. Different executions for Electronics Desktop and Workbench         :param local_lang: if not specified then use English as default installation language         :return: None Install Electronics Desktop. Make verification that the same version is not yet installed and makes         silent installation         Get Workbench installation path from environment variable and enables integration if exists.         :return: None # the "shared files" is created at the same level as the "AnsysEMxx.x" so if installing to unique folders, # the Shared Files folder will be unique as well. Thus we can check install folder for license Silently uninstall build of the same version         :return: None Verify result code of the InstallShield log file         :param log_file: installation log file         :param installation: True if verify log after installation elif after uninstallation False         :return: None extract information about Electronics Desktop build date and version         Args:             product_info_file: path to the product.info             file_content (list): accepts list with file content as well instead of file          Returns: (int) build_date # cannot find line with build date Open directory with unpacked build of Electronics Desktop and search for SilentInstallationTemplate.iss to         extract product ID which is GUID hash         Args:             unpacked_dir: directory where AEDT package was unpacked         Returns:             product_id: product GUID extracted from iss template             setup_exe: set path to setup.exe if exists             installshield_version: set version from file Install license manager and feed it with license file Check build date of installation package of License Manager # check again if file was unpacked Check build date of installed License Manager Install Workbench to the target installation directory         :param local_lang: if not specified then use English as default installation language # the "shared files" is created at the same level as the "ANSYS Inc" so if installing to unique folders, # the Shared Files folder will be unique as well. Thus we can check install folder for license # convert command to string to easy append custom flags Uninstall Workbench if such exists in the target installation directory         :return: uninstall_exe: name of the executable of uninstaller Function to safely remove path if rmtree fails         :param path:         :return: # try this dirty method to force remove all files in directory Downloads product info file from artifactory          :param (ArtifactoryPath) build_info: arti path to the package_id file         :return: (int): package_id if extracted Cleans downloaded zip and unpacked folder with content         :return: None Function to remove newly created AEDT shortcuts and replace them with new one # include Public, user folder and user folder when OneDrive sync is enabled Update Electronics Desktop registry based on the files in the HPC_Options folder that are added from UI         :return: None # get first line Update ordered dictionary with new data and write it to the file         :param status: Failed | Success | In-Progress (important, used in JS)         :param details: Message for details field         :return: # in case if file was deleted during run of installation Send toaster notification         :param status: Failed | Success | In-Progress         :param details: Message for details field         :return: # dev path Read a file with installation history         create a file if does not exist         :return: dict with history or empty in case if file was deleted during run of installation Send usage statistics to the database.         Collect username, time, version and software installed         in case of crash send also crash data         :parameter: error: error message of what went wrong         :return: None Send statistics to InfluxDB         Args:             tool: product that is installed             version: version             time_now: time             error: error if some crash occurred             downloader_ver: version of the backend         Returns:             None Send statistics to SharePoint list         Args:             time_now: time             tool: product that is installed             version: version             error: error if some crash occurred         Returns:             None Wrapper for subprocess call to handle non admin run or UAC issue          Args:             command: (str/list) command to run             shell: call with shell mode or not             popen: in case if you need output we need to use Popen. Pyinstaller compiles in -noconsole, need               to explicitly define stdout, in, err          Returns:             output (str), output of the command run Function to parse arguments provided to the script. Search for -p key to get settings path         :return: settings_path: path to the configuration file # Add long and short argument generate random hash. Letter A at the end is important to preserver Order in JS     :return: hash code (str) Function to setup logging output to stream and log file. Will be used by UI and backend     :param: logging_file (str): path to the log file     :return: None # add logging to console and log file # If you set the log level to INFO, it will include INFO, WARNING, ERROR, and CRITICAL messages