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
from application.model.configuration import Configurations
from application.model.customexception import (
    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,                       # type: LoaderUpdaterService
        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 = self.pos_repository.get_last_loader_update()
                external_update_id = last_loader_update.server_update_identifier
                last_loader_update_id = int(last_loader_update.update_id)

                self._delete_directories()
                changed_pos_ctrl_type = self.download_and_validate_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")
                current_update_id = str(last_loader_update_id + 1)
                self.pos_repository.insert_new_loader_update(
                    update_id=current_update_id,
                    update_name="Loaders",
                    external_update_id=external_update_id,
                    obtained_date=last_loader_update.obtained_date,
                )

                self.pos_repository.delete_update_line(
                    external_update_id=external_update_id,
                    update_id=last_loader_update_id,
                )

                self._extract_new_loaders(self.data_path)
                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 external_update_id:
                    self.send_result_message(
                        current_update_id=current_update_id,
                        external_update_id=external_update_id,
                    )
                if restart_hv:
                    self._delete_directories()
                    logger.info("Starting Hypervisor to apply new Loaders")
                    self.pos_repository.common_pos_repository.restart_hv(
                        event_type="POS_UPDATER_LOADER",
                    )
                    time.sleep(5)

                logger.info("Loaders update fully concluded with success!")
        except NotInsideUpdateWindow:
            settings.set_update_control(self.update_name, "$NOT_INSIDE_UPDATE_WINDOW")
            logger.info("Sleeping until enter the update window")
            raise
        except ErrorValidatingOpenedUsers:
            settings.set_update_control(self.update_name, "$UPDATE_LOADERS_ERRORVALIDATINGOPENEDUSERS")
            logger.info("Error validating loaders. Necessary to close all the operators and the day")
            raise
        except (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 download_and_validate_loaders(self):
        settings.set_update_control(self.update_name, "$GETTING_NEW_LOADERS")
        self._download_new_loaders()

        self.updater_service.create_dir(self.work_path)
        self._extract_new_loaders(os.path.join(self.work_path, self.data_relative_path))

        settings.set_update_control(self.update_name, "$VALIDATING_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()

        return changed_pos_ctrl_type

    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()

    def send_result_message(
        self,                 # type: LoaderUpdaterService
        current_update_id,    # type: str
        external_update_id,   # type: str
    ):
        # type: (...) -> None

        last_versions, versions_dates = self.version_repository.get_last_versions()
        body = self._get_update_notify_body(
            last_versions=last_versions,
            versions_dates=versions_dates,
        )
        success = self.api_repository.send_result_message(
            last_versions=last_versions,
            body=body,
        )
        if success:
            self.pos_repository.update_loader_notified_date(
                update_id=current_update_id,
                external_update_id=external_update_id,
            )

    def _get_update_notify_body(
        self,               # type: LoaderUpdaterService
        last_versions,      # type: Dict[str, str]
        versions_dates,     # type: Dict[str, str]
    ):
        # type: (...) -> Dict[str, str]

        src_version = last_versions.get(UpdateType.binaries.value).split("|")[1].replace("SRC:", "")
        src_applied_date = versions_dates.get(UpdateType.binaries.value)
        src_applied_date = datetime.strptime(src_applied_date, '%d/%m/%Y %H:%M')
        loader_applied_date = datetime.strptime(versions_dates.get(UpdateType.loader.value), '%d/%m/%Y %H:%M')
        return {
            "storeId": self.store_id,
            "srcVersion": src_version,
            "srcAppliedDate": src_applied_date.isoformat(),
            "menuVersion": last_versions.get(UpdateType.catalog.value).split("_")[0],
            "loaderAppliedDate": loader_applied_date.isoformat(),
            "externalUpdateId": self.pos_repository.get_last_loader_update().server_update_identifier,
        }

    @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,                   # type: LoaderUpdaterService
        boh_pos_values,         # type: Dict
        current_pos_values,     # type: Dict
        key_index,              # type: str
        validate_state=True     # type: bool
    ):
        # type: (...) -> Dict

        update_pos = {}
        some_pos_has_day_opened = False
        has_new_pos = len(boh_pos_values) > len(current_pos_values)
        for pos, value in current_pos_values.items():

            pos_id = int(pos[3:])
            if pos_id > 12:
                continue

            if not has_new_pos and (pos not in boh_pos_values.keys() or value == boh_pos_values[pos]):
                continue

            pos_id = int(pos[3:])
            model = self.pos_repository.get_model(pos_id=pos_id)
            self._validate_users(model=model)
            day_is_opened_in_pos = self._day_is_opened_in_pos(model=model)
            some_pos_has_day_opened |= day_is_opened_in_pos
            if validate_state and day_is_opened_in_pos:
                logger.exception("Error, trying to update loader with day not closed")
                raise ErrorValidatingOpenedUsers("ERRORDAYISOPEN")

            logger.info("{}: has a different values for {}".format(pos, key_index))
            update_pos[pos_id] = value

        if has_new_pos and some_pos_has_day_opened:
            logger.exception("Error, trying to add new POS with day not closed")
            raise ErrorValidatingOpenedUsers("ERRORDAYISOPEN")

        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

        boh_user_control_type = {}
        current_user_control_type = {}
        boh_pod_type = {}
        current_pod_type = {}
        boh_pos_ctrl_dir = os.path.join(self.work_path, self.server_relative_path, 'bundles', 'posctrl')
        current_pos_ctrl_dir = os.path.join(self.server_path, 'bundles', 'posctrl')

        if not os.path.exists(current_pos_ctrl_dir) or not os.path.exists(boh_pos_ctrl_dir):
            return {}

        boh_loaders_paths = os.listdir(boh_pos_ctrl_dir) or []
        boh_loaders_paths = [x for x in boh_loaders_paths if ".cfg" in x]
        for path in boh_loaders_paths:
            self._add_value_to_pos_dict(
                element_name="userControlType",
                xml_path=os.path.join(boh_pos_ctrl_dir, path),
                pos_dict=boh_user_control_type
            )
            self._add_value_to_pos_dict(
                element_name="pod",
                xml_path=os.path.join(boh_pos_ctrl_dir, path),
                pos_dict=boh_pod_type
            )

        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
            )

        self._validate_pod_type(
            boh_pod_type=boh_pod_type,
            current_pod_type=current_pod_type
        )
        return self._validate_user_control_type(
            boh_pos_values=boh_user_control_type,
            current_pos_values=current_user_control_type
        )

    def _validate_user_control_type(
        self,                   # type: LoaderUpdaterService
        boh_pos_values,         # type: Dict
        current_pos_values,     # type: Dict
    ):
        # type: (...) -> Dict

        return self._validate_update_pos_values(
            boh_pos_values=boh_pos_values,
            current_pos_values=current_pos_values,
            key_index="user_control_type"
        )

    def _validate_pod_type(
        self,                       # type: LoaderUpdaterService
        boh_pod_type,               # type: Dict
        current_pod_type,       # type: Dict
    ):
        # type: (...) -> None

        self._validate_update_pos_values(
            boh_pos_values=boh_pod_type,
            current_pos_values=current_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 _download_new_loaders(self):
        # type: () -> None

        try:
            logger.info("Downloading new loaders")
            file_path = os.path.join(self.downloads_path, self.loaders_zip)
            self.updater_service.create_dir(path=self.downloads_path)
            last_versions, _ = self.version_repository.get_last_versions()
            self.api_repository.download_loaders(
                file_path=file_path,
                last_versions=last_versions
            )
            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,):
            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,):
            logger.exception("Error deleting path")
            raise ErrorDeletingContent()

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

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

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

            logger.info("Loaders extracted in data path")
        except (Exception,):
            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,):
            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,):
                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,):
            logger.exception("Error updating customization files")

    @staticmethod
    def _day_is_opened_in_pos(
        model   # type: eTree.XML
    ):
        # type: (...) -> bool

        pos_state = model.find("PosState").get("state")
        return pos_state not in ("CLOSED", "UNDEFINED")
