import errno
import json
import os
import shutil
import time
from datetime import datetime
from threading import Lock
from typing import Dict
import xml.etree.ElementTree as eTree

from application.model import UpdateType, OperatorStatus
from application.model.configuration import Configurations
from application.model.customexception import NotExpectedException, DisabledFunctionality, ErrorCreatingBackup, \
    ErrorDeletingContent, ErrorRecoveringFolder, ErrorExtractingLoaders, ErrorDownloadingLoaders, RollbackError, \
    NotInsideUpdateWindow, ErrorValidatingOpenedUsers, ErrorUpdatingUserRoles
from application.repository.api import LoaderUpdaterAPIRepository
from application.repository.pos import LoaderUpdaterPOSRepository, VersionsPOSRepository
from application.service import UpdaterService, StaticConfigurationService
from application.util import LoggerUtil, ValidationUtil, settings
from systools import sys_parsefilepath

GENESIS_PATH = "../../genesis"

logger = LoggerUtil.get_logger_name(__name__)


class LoaderUpdaterService(object):
    def __init__(self, configs, working_on_update, api_repository, pos_repository, version_repository, updater_service):
        # type: (Configurations, Lock, LoaderUpdaterAPIRepository, LoaderUpdaterPOSRepository, VersionsPOSRepository, UpdaterService) -> None # noqa

        self.update_type = UpdateType.loader
        self.update_name = self.update_type.name
        self.enabled = configs.updaters[self.update_name].enabled
        self.local_configs = configs.updaters[self.update_name]
        self.store_id = configs.store_id

        self.working_on_update = working_on_update
        self.api_repository = api_repository
        self.pos_repository = pos_repository
        self.version_repository = version_repository
        self.updater_service = updater_service

        data_path = sys_parsefilepath("{HVDATADIR}")
        work_dir = sys_parsefilepath(configs.backup_directory)
        self.data_relative_path = "data"
        self.server_relative_path = "data/server"
        self.databases_relative_path = "data/server/databases"
        self.data_path = os.path.join(data_path, GENESIS_PATH, self.data_relative_path)
        self.server_path = os.path.join(self.data_path, "server")
        self.databases_path = os.path.join(data_path, GENESIS_PATH, self.databases_relative_path)
        default_work_path = os.path.join(work_dir, self.update_type.name)
        self.downloads_path = os.path.join(default_work_path, "downloads")
        self.backups_path = os.path.join(default_work_path, "backups")
        self.work_path = os.path.join(default_work_path, "work_path")
        self.payments_json = os.path.join(self.server_path, "bundles/payments.json")
        self.genesis_operator_levels_json = os.path.join(self.server_path, "bundles/operatorLevels.json")
        self.htdocs_path = os.path.join(data_path, 'htdocs')
        self.htdocs_kui_path = os.path.join(self.htdocs_path, 'kui')
        self.sui_static_path = os.path.join(data_path, 'htdocs/sui/static')
        self.loaders_zip = "loaders.zip"

        if not os.path.exists(self.backups_path):
            os.makedirs(self.backups_path)
        if not os.path.exists(self.downloads_path):
            os.makedirs(self.downloads_path)

    def update_loaders(
        self,
        restart_hv=False,       # type: bool
        retry_on_error=False,   # type: bool
    ):
        # type: (...) -> None

        settings.set_update_control(self.update_name, None)
        if not self.enabled:
            settings.set_update_control(self.update_name, "$UPDATE_DISABLED")
            raise DisabledFunctionality()

        rollback_necessary = False
        backup_package_zip = ''

        try:
            logger.info("Waiting Updating Loaders")
            with self.working_on_update:
                logger.info("Updating loaders")
                settings.set_update_control(self.update_name, "$UPDATING_LOADERS")
                settings.set_update_control("updating", True)
                last_loader_update_id = self.pos_repository.get_last_loader_update_id()

                self._delete_directories()
                self.updater_service.create_dir(self.work_path)
                settings.set_update_control(self.update_name, "$GETTING_NEW_LOADERS")
                self._download_new_loaders()
                self.pos_repository.insert_new_loader_update(str(last_loader_update_id + 1), "Loaders")

                settings.set_update_control(self.update_name, "$LOADERS_BACKUP")
                backup_package_zip = self._create_backup()

                rollback_necessary = True

                settings.set_update_control(self.update_name, "$APPLYING_NEW_LOADERS")
                self._get_new_loaders()

                current_pos_ctrl_dir = os.path.join(self.work_path, self.server_relative_path, 'bundles', 'posctrl')
                changed_pos_ctrl_type = None
                if os.path.exists(current_pos_ctrl_dir):
                    changed_pos_ctrl_type = self._could_apply_new_loaders()

                self._recover_folders()

                settings.set_update_control(self.update_name, "$UPDATING_USER_FUNCTIONS")
                self.update_operator_levels()

                self.updater_service.update_databases_with_json_config(
                    update_name=self.update_name,
                    settings=settings,
                )

                self.pos_repository.mark_loader_update_as_applied()

                StaticConfigurationService.update_static_loaders()
                StaticConfigurationService.run_databases_script()
                settings.set_update_control(self.update_name, "$SUCCESS_UPDATE")

                if changed_pos_ctrl_type is not None:
                    self._update_pos_ctrl_type(changed_pos_ctrl_type)

                self._update_ui_customizations()

                rollback_necessary = False
                if restart_hv:
                    self._delete_directories()
                    logger.info("Starting Hypervisor to apply new Loaders")
                    self.pos_repository.restart_hv()
                    time.sleep(5)

                logger.info("Loaders update fully concluded with success!")
        except NotInsideUpdateWindow as _:
            settings.set_update_control(self.update_name, "$NOT_INSIDE_UPDATE_WINDOW")
            logger.info("Sleeping until enter the update window")
            raise
        except (NotExpectedException, ErrorDownloadingLoaders, Exception) as ex:
            if rollback_necessary:
                with self.working_on_update:
                    self._rollback(backup_package_zip)

            if isinstance(ex, ErrorDownloadingLoaders):
                settings.set_update_control(self.update_name, ex.message)
            else:
                settings.set_update_control(self.update_name, "$UNKNOWN_ERROR")

            if not retry_on_error:
                self.pos_repository.mark_loader_update_as_error()

            logger.exception("Not expected exception on loaders update")
            raise
        finally:
            self._delete_directories()
            settings.set_update_control("updating", False)

    def _update_pos_ctrl_type(self, pos_ctrl_type):
        # type: (Dict) -> None

        for pos_id, value in pos_ctrl_type.items():
            user_control_type = 1 if value == "QSR" else 2
            self.pos_repository.update_pos_ctrl_type(pos_id, user_control_type)

    def update_operator_levels(
            self
    ):
        # type: () -> None

        try:
            if os.path.exists(self.genesis_operator_levels_json) and os.path.exists(self.sui_static_path):
                current_operator_levels = os.path.join(self.sui_static_path, 'operatorLevels.json')

                with open(self.genesis_operator_levels_json, 'rb') as f:
                    new_operator_level = json.load(f)

                has_some_configuration = sum([len(x) for x in new_operator_level["levels"].values()]) > 0
                if has_some_configuration:
                    self._create_support_menu_buttons(
                        operator_level=new_operator_level
                    )
                    self.updater_service.save_json(
                        data=new_operator_level,
                        destination_path=current_operator_levels
                    )
                else:
                    self.updater_service.delete_file(
                        path=current_operator_levels
                    )
        except Exception as e:
            logger.info("Error processing operatorLevels.json: {}".format(e))
            raise ErrorUpdatingUserRoles()

    @staticmethod
    def _create_support_menu_buttons(
        operator_level,     # type: Dict
    ):
        # type: (...) -> None

        rows = int(operator_level["rows"])
        support_menu = {
            "function": "SUPPORT_MENU",
            "row": rows,
            "column": 0,
            "color": "#274E13"
        }
        update_users = {
            "function": "UPDATE_USERS",
            "row": rows,
            "column": 1,
            "color": "#274E13"
        }

        operator_level["levels"]["30"] = operator_level["levels"]["10"][:]
        operator_level["levels"]["30"].extend([support_menu, update_users])

    @staticmethod
    def _add_value_to_pos_dict(
        element_name,   # type: str
        xml_path,       # type: str
        pos_dict        # type: Dict
    ):
        # type: (...) -> None

        xml_loader = eTree.parse(xml_path)
        element_pos = xml_loader.find(".//*[@name='POS']/group")
        pos = element_pos.get("name") if element_pos is not None else None
        element = xml_loader.find(".//*[@name='{}']/string".format(element_name))
        element_value = element.text if element is not None else None
        if pos and element_value:
            pos_dict[pos] = element_value

    def _validate_update_pos_values(
        self,
        current_pos_values,     # type: Dict
        new_pos_values,         # type: Dict
        key_index,              # type: str
        validate_state=True     # type: bool
    ):
        # type: (...) -> Dict

        update_pos = {}
        for pos, value in new_pos_values.items():
            if pos not in current_pos_values.keys() or value == current_pos_values[pos]:
                continue

            pos_id = int(pos[3:])
            model = self.pos_repository.get_model(pos_id=pos_id)
            self._validate_users(model=model)
            if validate_state:
                self._validate_pos_state(model=model)
            logger.info("{}: has a different values for {}".format(pos, key_index))
            update_pos[pos_id] = value

        return update_pos

    @staticmethod
    def _validate_users(
        model  # type: eTree.XML
    ):
        # type: (...) -> None

        operators = model.find("Operators").findall("Operator")
        for operator in operators:
            status = operator.get("state")
            if status != "LOGGEDOUT":
                logger.exception("can't update loader with operator status: {}".format(status))
                raise ErrorValidatingOpenedUsers("NEED_TO_CLOSE_OPENED_USERS")

    def _could_apply_new_loaders(self):
        # type: () -> Dict

        current_user_control_type = {}
        new_user_control_type = {}
        current_pod_type = {}
        new_pod_type = {}
        new_pos_ctrl_dir = os.path.join(self.server_path, 'bundles', 'posctrl')
        current_pos_ctrl_dir = os.path.join(self.work_path, self.server_relative_path, 'bundles', 'posctrl')
        if not os.path.exists(new_pos_ctrl_dir) or not os.path.exists(current_pos_ctrl_dir):
            return {}

        current_loaders_paths = os.listdir(current_pos_ctrl_dir) or []
        current_loaders_paths = [x for x in current_loaders_paths if ".cfg" in x]
        for path in current_loaders_paths:
            self._add_value_to_pos_dict(
                element_name="userControlType",
                xml_path=os.path.join(current_pos_ctrl_dir, path),
                pos_dict=current_user_control_type
            )
            self._add_value_to_pos_dict(
                element_name="pod",
                xml_path=os.path.join(current_pos_ctrl_dir, path),
                pos_dict=current_pod_type
            )

        new_loaders_paths = os.listdir(new_pos_ctrl_dir) or []
        new_loaders_paths = [x for x in new_loaders_paths if ".cfg" in x]
        for path in new_loaders_paths:
            self._add_value_to_pos_dict(
                element_name="userControlType",
                xml_path=os.path.join(new_pos_ctrl_dir, path),
                pos_dict=new_user_control_type
            )
            self._add_value_to_pos_dict(
                element_name="pod",
                xml_path=os.path.join(new_pos_ctrl_dir, path),
                pos_dict=new_pod_type
            )

        self._validate_pod_type(
            current_pod_type=current_pod_type,
            new_pod_type=new_pod_type
        )
        return self._validate_user_control_type(
            current_user_control_type=current_user_control_type,
            new_user_control_type=new_user_control_type
        )

    def _validate_user_control_type(
        self,
        current_user_control_type,  # type: Dict
        new_user_control_type       # type: Dict
    ):
        # type: (...) -> Dict

        return self._validate_update_pos_values(
            current_pos_values=current_user_control_type,
            new_pos_values=new_user_control_type,
            key_index="user_control_type"
        )

    def _validate_pod_type(
        self,
        current_pod_type,   # type: Dict
        new_pod_type        # type: Dict
    ):
        # type: (...) -> None
        self._validate_update_pos_values(
            current_pos_values=current_pod_type,
            new_pos_values=new_pod_type,
            key_index="pod_type",
            validate_state=False
        )

    def _delete_directories(self):
        self.updater_service.delete_directory(self.work_path)

    def _check_update_window(self):
        # type: () -> None

        start_time = self.local_configs.window.start_time
        end_time = self.local_configs.window.end_time
        if not ValidationUtil().is_inside_allowed_window(start_time, end_time):
            str_start_time = start_time.strftime("%H:%M:%S")
            str_end_time = end_time.strftime("%H:%M:%S")
            message = "Not inside allowed window. Start: {} / End: {}".format(str_start_time, str_end_time)
            logger.info(message)
            raise NotInsideUpdateWindow()

    def _build_params(self):
        params = {}
        last_versions, _ = self.version_repository.get_last_versions()
        binaries = last_versions[UpdateType.binaries.value] if UpdateType.binaries.value in last_versions else ""
        pos_version = binaries.split("|")[1].split(":")[1] if binaries != "" else ""
        params['pos_version'] = pos_version
        return params

    def _download_new_loaders(self):
        # type: () -> None

        try:
            logger.info("Downloading new loaders")
            file_path = os.path.join(self.downloads_path, self.loaders_zip)
            params = self._build_params()
            self.api_repository.download_loaders(file_path, params)
            logger.info("Loaders successfully downloaded")
        except Exception as ex:
            logger.exception("Error downloading loaders")
            raise ErrorDownloadingLoaders(ex.message)

    def _create_backup(self):
        # type: () -> str

        try:
            logger.info("Creating loaders backup")

            backup_zip_name = "loaders_{}".format(datetime.now().strftime("%Y%m%d_%H%M%S"))
            backup_package = os.path.join(self.backups_path, backup_zip_name)
            backup_package_zip = os.path.join(self.backups_path, backup_zip_name) + ".zip"

            zip_path = os.path.join(self.work_path, self.data_relative_path)
            self.updater_service.copy_directory(self.data_path, zip_path)
            self.updater_service.save_directory_zip(zip_path, backup_package)
            self.updater_service.test_zip_file(backup_package_zip)
            self._clean_backups()

            logger.info("Loaders backup successfully created!")
        except Exception as _:
            logger.exception("Error creating loaders backup")
            raise ErrorCreatingBackup()

        return backup_package_zip

    def _clean_backups(
        self,   # type: LoaderUpdaterService
    ):
        # type: (...) -> None

        list_of_files = os.listdir(self.backups_path)
        if len(list_of_files) > 5:
            full_path_files = [os.path.join(self.backups_path, x) for x in list_of_files]
            full_path_files = sorted(full_path_files, key=lambda p: os.path.getmtime(p))
            files_to_delete = full_path_files[:-5]
            for backup_file in files_to_delete:
                try:
                    os.remove(backup_file)
                except Exception as e:
                    logger.exception("Failed to remove backup file {}. Error {}".format(backup_file, e))

    def _delete_content(self, path):
        # type: (str) -> None

        try:
            logger.info("Deleting path {}".format(path))

            self.updater_service.delete_directory(path)

            logger.info("Path deleted")
        except Exception as _:
            logger.exception("Error deleting path")
            raise ErrorDeletingContent()

    def _get_new_loaders(self):
        # type: () -> None

        try:
            logger.info("Extracting loaders in data path")

            self.updater_service.create_dir(self.data_path)
            zip_path = os.path.join(self.downloads_path, self.loaders_zip)
            self.updater_service.extract_zip(zip_path, self.data_path)

            logger.info("Loaders extracted in data path")
        except Exception as _:
            logger.exception("Error extracting loaders to data path")
            raise ErrorExtractingLoaders()

    def _recover_folders(self):
        # type: () -> None

        current_folder = None
        try:
            work_path_server_folder = os.path.join(self.work_path, self.server_relative_path)
            if not os.path.exists(work_path_server_folder):
                return

            for current_folder in os.listdir(work_path_server_folder):
                if current_folder == "bundles":
                    continue

                logger.info("Recovering {} folder".format(current_folder))
                folder_path = os.path.join(self.server_path, current_folder)
                work_path_path = os.path.join(work_path_server_folder, current_folder)
                if not os.path.isdir(work_path_path):
                    continue

                self.updater_service.copy_directory(work_path_path, folder_path)
                logger.info("{} folder recovered".format(current_folder))
        except Exception as _:
            logger.exception("Error recovering {}".format(current_folder))
            raise ErrorRecoveringFolder()

    def _rollback(self, backup_package_zip):
        # type: (str) -> None

        logger.info("Starting rollback")

        retries = 0
        max_retries = 2

        while True:
            try:
                self._delete_content(self.data_path)
                self.updater_service.extract_zip(backup_package_zip, self.data_path)
            except Exception as _:
                logger.exception("Error while executing rollback")
                if retries == max_retries:
                    raise RollbackError()

                retries += 1
                continue

            logger.info("Rollback finished")
            break

    def _update_ui_customizations(
        self,
    ):
        # (...) -> None

        try:
            customization_kui_dir = os.path.join(
                self.server_path,
                "bundles",
                "customization",
                "kui",
            )
            if not os.path.isdir(customization_kui_dir):
                return

            self.updater_service.create_dir(
                path=self.htdocs_kui_path,
                delete_if_exists=False,
            )

            os_walk = os.walk(customization_kui_dir)
            for step_index, (base_dir, dirs, files) in enumerate(os_walk):
                for dir_name in dirs:
                    file_path = os.path.join(self.htdocs_kui_path, dir_name)
                    self.updater_service.create_dir(
                        path=file_path,
                        delete_if_exists=False,
                    )

                last_path_component = os.path.split(base_dir)[1]
                use_last_path_component = step_index != 0
                for file_name in files:
                    src_file = os.path.join(
                        base_dir,
                        file_name,
                    )
                    if use_last_path_component:
                        dst_file = os.path.join(
                            self.htdocs_kui_path,
                            last_path_component,
                            file_name,
                        )
                    else:
                        dst_file = os.path.join(
                            self.htdocs_kui_path,
                            file_name,
                        )
                    shutil.copy2(
                        src_file,
                        dst_file,
                    )
        except Exception as _:
            logger.exception("Error updating customization files")

    @staticmethod
    def _validate_pos_state(
        model   # type: eTree.XML
    ):
        # type: (...) -> None

        pos_state = model.find("PosState").get("state")
        if pos_state not in ("CLOSED", "UNDEFINED"):
            logger.exception("Error, trying to update loader with day {}".format(pos_state))
            raise ErrorValidatingOpenedUsers("ERRORDAYISOPEN")
