import json
import os
import shutil
import tarfile
import time
import zipfile
from datetime import datetime

from systools import sys_parsefilepath

from application.model import (
    Payment,
    PosSaleTypeNavigation,
    UpdateData,
    UpdateStatus,
    UpdateStep,
    UpdateType,
)
from application.model.customexception import (
    ErrorCheckingPackageIntegrity,
    ErrorGenesisDatabaseNotFound,
    ErrorRetrievingJsonCatalog,
    ErrorRetrievingPendingUpdate,
    ErrorUpdatingDatabases,
    NoCatalogJsonFoundException,
    NoCatalogUpdateFoundException,
)
from application.util import LoggerUtil
from helper import (
    json_serialize,
    remove_accents,
)
from typing import (
    Dict,
    Optional,
)
from ._CommonService import CommonService

logger = LoggerUtil.get_logger_name(__name__)

GENESIS_PATH = "../../genesis"


class UpdaterService(object):
    def __init__(self, api_repository, pos_repository, common_repository, working_on_update, configs):
        self.api_repository = api_repository
        self.pos_repository = pos_repository
        self.working_on_update = working_on_update
        self.common_repository = common_repository

        update_type = UpdateType.catalog
        self.local_configs = configs.updaters[update_type.name]
        data_path = sys_parsefilepath("{HVDATADIR}")
        self.backup_directory = sys_parsefilepath(configs.backup_directory)
        default_work_path = os.path.join(self.backup_directory, update_type.name)

        self.data_relative_path = "data"
        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.genesis_databases_path = os.path.join(data_path, "../../genesis/data/server/databases")
        self.genesis_campaign_databases_path = os.path.join(data_path, "../../genesis/data/server/databases-campaign")
        self.backup_path = os.path.join(default_work_path, "backups")
        self.downloads_path = os.path.join(default_work_path, "downloads")
        self.payments_json = os.path.join(self.server_path, "bundles/payments.json")
        self.navigation_json = os.path.join(self.server_path, "bundles/posSaleTypeNavigation.json")
        self.catalog_json = os.path.join(self.genesis_campaign_databases_path, "catalog.json")

    def download_campaign_pending_version(
        self,                           # type: UpdaterService
        need_backoffice_json=False,     # type: bool
        force=False,                    # type: bool
    ):
        # type: (...) -> Optional[str]

        self._update_catalog_json(need_backoffice_json, force=force)
        catalog_json = self._get_json_file_databases_campaign()
        self._set_best_id_database_campaign(catalog_json)
        update_data = self.pos_repository.has_catalog_pending_update_to_apply()
        if update_data:
            menu_id = str(self.get_database_id(update_data.update_name))
            catalog_names = self._get_catalog_names(catalog_json)
            return catalog_names[str(menu_id)]

    def _update_catalog_json(
        self,                       # type: UpdaterService
        need_backoffice_json,       # type: bool
        force=False,                # type: bool
    ):
        try:
            try:
                catalog_json = self._download_catalog_json(force=force)
            except NoCatalogJsonFoundException as ex:
                logger.info(ex.message)
                return
            except NoCatalogUpdateFoundException as ex:
                logger.info(ex.message)
                return

            catalog_json_version = str(catalog_json["version"])
            update_info = json_serialize(dict(id=catalog_json["id"], name=catalog_json_version))
            logger.info("New databases update found: {}".format(update_info))
            self.download_databases_campaign(catalog_json)
        except Exception as ex:
            logger.exception("Error get update_catalog_json")
            if need_backoffice_json:
                raise ex

    @staticmethod
    def _get_catalog_names(catalog_json):
        # type: (Dict) -> Dict

        catalog_names = {}
        for x in catalog_json.get("menus"):
            catalog_names[str(x.get("id"))] = x.get("name")

        return catalog_names

    def download_databases_campaign(
        self,           # type: UpdaterService
        catalog_json,   # type: str
        new_line=True,  # type: Optional[bool]
    ):
        # type: (...) -> None

        catalog_json_id = catalog_json.get("id")
        self._create_databases_campaign_backup(str(catalog_json_id))
        try:
            self._remove_directory_databases_campaign()
            for menu in catalog_json.get("menus"):
                menu_link = menu.get("link")
                if self._download_is_valid(menu) and menu_link is not None:
                    menu_id = menu.get("id")
                    self.download_menu_databases(menu_id, menu_link)
            if new_line:
                self._insert_new_update(catalog_json, UpdateType.catalog)
            self.pos_repository.update_step(catalog_json_id, UpdateStep.all, UpdateType.catalog)
            self.pos_repository.update_status(catalog_json_id, UpdateStatus.applied)
        except Exception as ex:
            self._remove_directory_databases_campaign()
            self._restore_backup_campaign(catalog_json_id)
            logger.info("Error download database campaign")
            raise ex

    @staticmethod
    def _download_is_valid(menu):
        # type: (str) -> bool

        end_in = menu.get("endIn")
        if end_in is not None and datetime.strptime(end_in, "%Y-%m-%d").date() < datetime.now().date():
            return False

        return True

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

        try:
            original_path = os.path.abspath(os.getcwd())
            logger.info("Initializing Delete directory database campaign...")
            os.chdir(self.genesis_campaign_databases_path)
            for _, sub_dirs, _ in os.walk("."):
                for dirname in sub_dirs:
                    self.delete_directory(dirname)

            os.chdir(original_path)
            logger.info("Delete directory database campaign successfully!")
        except (Exception,):
            logger.info("Error Delete directory database campaign")
            raise

    def _create_databases_campaign_backup(self, catalog_json_id):
        # type: (str) -> None

        logger.info("Creating databases campaign backup...")

        original_path = os.path.abspath(os.getcwd())
        backup_package = os.path.join(self.backup_path, catalog_json_id + "_databases_campaign.zip")
        with zipfile.ZipFile(backup_package, "w") as zip_object:
            os.chdir(self.genesis_campaign_databases_path)
            for dirname, sub_dirs, files in os.walk("."):
                zip_object.write(dirname)
                for filename in files:
                    zip_object.write(os.path.join(dirname, filename))

        os.chdir(original_path)
        self.test_zip_file(backup_package)
        self.pos_repository.update_step(catalog_json_id, UpdateStep.backup, UpdateType.catalog_json)

        logger.info("Databases backup successfully created!")

    def _download_catalog_json(
        self,                       # type: UpdaterService
        force=False,                # type: bool
    ):
        # type: (...) -> str

        catalog_json = self.retrieve_pending_update()
        catalog_json_id = str(catalog_json.get("id", ''))
        catalog_json_version = str(catalog_json.get("version"))
        if catalog_json is None or catalog_json_id == '':
            raise NoCatalogJsonFoundException("No JSON update found")

        last_catalog_json_id_applied = self._get_last_downloaded_version(UpdateType.catalog_json)
        last_catalog_json_id = last_catalog_json_id_applied.get("updateId")
        last_catalog_json_name = last_catalog_json_id_applied.get("updateName")

        if not force and catalog_json_id == last_catalog_json_id and catalog_json_version == last_catalog_json_name:
            exception_message = "Databases already downloaded: {}".format(json_serialize(catalog_json))
            raise NoCatalogUpdateFoundException(exception_message)

        logger.info("JSON update found")
        with self.working_on_update:
            self._verify_genesis_campaign_directory()

            self._insert_new_update(catalog_json, UpdateType.catalog_json)
            self._store_downloaded_catalog_json(catalog_json)
            self.pos_repository.update_step(catalog_json_id, UpdateStep.all, UpdateType.catalog_json)

        return catalog_json

    @staticmethod
    def get_package_name(update_data):
        # type: (UpdateData) -> str

        return str(update_data.update_id) + ".zip"

    @staticmethod
    def test_zip_file(zip_path):
        # type: (str) -> None

        logger.info("Validating zip file...")

        try:
            zip_file = zipfile.ZipFile(zip_path)
            if zip_file.testzip():
                logger.error("Failed to check package integrity. [{}] is corrupted!".format(zip_path))
                raise ErrorCheckingPackageIntegrity()
            logger.info("Zip file successfully validated!")
        except (Exception,):
            logger.error("Failed to check package integrity. [{}] is corrupted!".format(zip_path))
            raise ErrorCheckingPackageIntegrity()

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

        try:
            return self.api_repository.retrieve_pending_update()
        except Exception as ex:
            raise ErrorRetrievingPendingUpdate(ex)

    def _get_last_downloaded_version(self, type_id=None):
        # type: (Optional[str]) -> Optional[str]

        return self.pos_repository.get_last_downloaded_version(type_id)

    def _insert_new_update(self, latest_server_update, type_id):
        # type: (dict, UpdateType) -> None

        logger.info("Inserting new update on database...")
        update_id = latest_server_update["id"]
        update_name = remove_accents(str(latest_server_update["version"]))
        return self.pos_repository.insert_new_update(update_id, update_name, type_id)

    def _verify_genesis_directory(self):
        if not os.path.exists(self.genesis_databases_path):
            os.makedirs(self.genesis_databases_path)

    def _verify_genesis_campaign_directory(self):
        self.create_dir(self.genesis_campaign_databases_path, False)

    def download_menu_databases(self, menu_id, menu_link):
        # type: (int, str) -> None

        logger.info("Trying to download databases")

        package_name = str(menu_id) + ".zip"
        update_package = os.path.join(self.downloads_path, package_name)
        logger.info("Downloading package {} and saving in {}".format(package_name, update_package))
        self._store_menu_databases(menu_link, update_package)
        self.test_zip_file(update_package)
        self.extract_zip(update_package, os.path.join(self.genesis_campaign_databases_path, str(menu_id)))

        logger.info("Package successfully stored and validated!")

    def _store_downloaded_catalog_json(self, catalog_dict):
        # type: (Dict) -> None

        logger.info("Writing package on file...")

        with open(self.catalog_json, 'w') as f:
            json.dump(catalog_dict, f)

        logger.info("Catalog successfully stored and validated!")

    def _store_menu_databases(self, menu_link, update_package):
        # type: (str, str) -> None
        package = self.api_repository.get_menu_databases(menu_link)

        logger.info("Writing package on file...")
        with open(update_package, "wb") as zip_object:
            for chunk in package.iter_content(chunk_size=128):
                zip_object.write(chunk)
        logger.info("Package successfully written on file!")

    def _restore_backup_campaign(self, catalog_json_id):
        # type: (str) -> None

        backup_package = os.path.join(self.backup_path, "{}_databases_campaign.zip".format(catalog_json_id))
        self.extract_zip(backup_package, os.path.join(self.genesis_campaign_databases_path), False)

    @staticmethod
    def create_dir(path, delete_if_exists=True):
        # type: (str, bool) -> None

        CommonService.create_dir(path, delete_if_exists)

    def copy_directory(self, source_path, destination_path):
        # type: (str, str) -> None

        self.delete_directory(path=destination_path)
        shutil.copytree(source_path, destination_path)

    def copy_all_files(self, source_path, destination_path):
        # type: (str, str) -> None
        self.create_dir(destination_path, delete_if_exists=False)

        src_files = os.listdir(source_path)
        for file_name in src_files:
            full_file_name = os.path.join(source_path, file_name)
            if os.path.isfile(full_file_name):
                shutil.copy(full_file_name, destination_path)

    @staticmethod
    def copy_all_content(source_path, destination_path):
        # type: (str, str) -> None

        CommonService.copy_all_content(source_path, destination_path)

    @staticmethod
    def save_json(
        data,               # type: Dict
        destination_path    # type: str
    ):
        # type: (str, str) -> None

        with open(destination_path, "wb") as f:
            json.dump(data, f)

    @staticmethod
    def delete_file(
        path    # type: str
    ):
        if os.path.exists(path):
            os.remove(path)

    @staticmethod
    def copy_file(
        original_file_path,     # type: str
        destination_file_path,  # type: str
    ):
        if os.path.exists(original_file_path):
            shutil.copy(original_file_path, destination_file_path)

    @staticmethod
    def move_directory(source_path, destination_path):
        # type: (str, str) -> None

        shutil.move(source_path, destination_path)

    @staticmethod
    def save_directory_zip(source_path, destination_path):
        # type: (str, str) -> None

        UpdaterService._fix_file_timestamps(directory=source_path)
        shutil.make_archive(destination_path, "zip", source_path)

    @staticmethod
    def _fix_file_timestamps(
        directory,  # type: str
    ):
        # type: (...) -> None

        current_time = time.time()
        min_valid_timestamp = UpdaterService._get_min_timestamp()
        for root, dirs, files in os.walk(directory):
            for f in files:
                file_path = os.path.join(root, f)
                try:
                    file_stat = os.stat(file_path)
                    if file_stat.st_mtime < min_valid_timestamp:
                        os.utime(file_path, (current_time, current_time))
                except (Exception,):
                    logger.exception("Error fixing timestamp for file {}".format(file_path))

    @staticmethod
    def _get_min_timestamp():
        # type: (...) -> float

        min_valid_date = datetime(
            year=1980,
            month=1,
            day=1,
        )
        return time.mktime(min_valid_date.timetuple())

    @staticmethod
    def delete_directory(path):
        # type: (str) -> None

        CommonService.delete_directory(path)

    def extract_zip(self, zip_name, path_to_extract, delete_if_exists=True):
        self.create_dir(path_to_extract, delete_if_exists)

        with zipfile.ZipFile(zip_name, "r") as zip_object:
            zip_object.extractall(path_to_extract)

    @staticmethod
    def extract_tar_gz(file_name, path_to_extract):
        with tarfile.open(file_name, "r:gz") as zip_object:
            zip_object.extractall(path_to_extract)

    @staticmethod
    def rename_folder(original_path, renamed_path):
        if os.path.exists(renamed_path):
            shutil.rmtree(renamed_path, ignore_errors=False, onerror=CommonService.handle_remove_read_only)

        os.rename(original_path, renamed_path)

    def _set_best_id_database_campaign(self, catalog_json):
        # type: (Dict) -> int

        logger.info("Applying: get databases campaign...")
        today = datetime.now()
        databases_ids = dict()
        if catalog_json is None:
            raise ErrorRetrievingJsonCatalog()

        for menu in catalog_json.get("menus"):
            end_in = self._get_date(menu, "endIn", '2099-12-31')
            start_in = self._get_date(menu, "startIn", '1970-01-01')
            if end_in.date() < today.date() or start_in.date() > today.date():
                continue

            databases_ids[menu.get("id")] = abs(start_in - today)

        database_id = min(databases_ids, key=databases_ids.get)
        version = catalog_json["version"]
        update_name = "{}_{}".format(version, database_id)
        last_catalog_id_applied = self._get_last_downloaded_version(UpdateType.catalog)
        last_catalog_id_applied = last_catalog_id_applied.get("updateName")

        if last_catalog_id_applied != update_name:
            self.pos_repository.insert_new_update(database_id, update_name, UpdateType.catalog)
            self.pos_repository.update_step(database_id, UpdateStep.download, UpdateType.catalog)
            logger.info("Applying: get databases campaign finish, catalog to apply: {}...".format(database_id))

        return database_id

    @staticmethod
    def _get_date(menu, key, default):
        # type: (str, str, str) -> datetime

        date = menu.get(key) if menu.get(key, None) is not None else default
        return datetime.strptime(date, "%Y-%m-%d")

    def _get_json_file_databases_campaign(self):
        try:
            with open(self.catalog_json, "r") as catalog_json:
                return json.loads(catalog_json.read().decode('utf-8'))
        except (Exception,):
            logger.info("Error open json file in directory")
            return None

    def update_databases_with_json_config(
        self,               # type: UpdaterService
        update_name,        # type: str
        settings,           # type: settings
    ):
        # type (...) -> None

        settings.set_update_control(
            key=update_name,
            value="$UPDATING_PAYMENTS",
        )
        self._update_payments()

        settings.set_update_control(
            key=update_name,
            value="$UPDATING_NAVIGATIONS",
        )
        self._update_navigations()

    def _update_payments(
        self,       # type: UpdaterService
    ):
        # type (...) -> None

        try:
            logger.info("Updating Payments")

            if not os.path.exists(self.payments_json):
                logger.info("The file {} was not found to update payments database".format(self.payments_json))
                return

            with open(self.payments_json, "r") as payments_json:
                payments = json.loads(payments_json.read())

            all_payments = []
            for payment in payments:
                payment_id = payment.get("id", 0)
                name = payment.get("name", "").encode('utf-8').strip()
                currency = payment.get("tenderCurrency", "BRL")
                change_limit = payment.get("changeLimit", 0)
                electronic_type = payment.get("electronicType", 0)
                open_drawer = payment.get("openDrawer", False)
                need_auth = int(payment.get("needAuth", 0))
                parent_id = payment.get("parentId", None)
                custom_attr = payment.get("customAttr", {})

                link_with_tef = payment.get("linkWithTef", False)
                if link_with_tef:
                    custom_attr["linkWithTef"] = link_with_tef

                payment = Payment(
                    payment_id=payment_id,
                    name=name,
                    currency=currency,
                    change_limit=change_limit,
                    electronic_type=electronic_type,
                    open_drawer=open_drawer,
                    need_auth=need_auth,
                    parent_id=parent_id,
                    custom_attr=json.dumps(custom_attr)
                )
                all_payments.append(payment)

            product_db = os.path.join(self.genesis_databases_path, "product.db")

            if not os.path.exists(product_db):
                raise ErrorGenesisDatabaseNotFound()

            self.common_repository.delete_all_payments(product_db)
            self.common_repository.insert_payments(product_db, all_payments)

            logger.info("Payments updated")
        except ErrorGenesisDatabaseNotFound:
            logger.exception("Error inserting payments on database")
            raise
        except (Exception,):
            logger.exception("Error inserting payments on database")
            raise ErrorUpdatingDatabases()

    def _update_navigations(
        self,       # type: UpdaterService
    ):
        # type (...) -> None

        try:
            logger.info("Updating Navigations")

            product_db = os.path.join(self.genesis_databases_path, "product.db")
            has_necessary_info_to_update_navigations = self._has_necessary_info_to_update_navigations(
                product_db=product_db,
            )
            if not has_necessary_info_to_update_navigations:
                return

            with open(self.navigation_json, "rb") as navigation_json:
                json_navigations = json.loads(navigation_json.read().decode("utf-8"))

            all_navigations = []
            for json_navigation in json_navigations:
                pos_id = json_navigation.get("POSId")
                sale_type = json_navigation.get("SaleType")
                navigation_id = json_navigation.get("NavId")
                if pos_id is None or sale_type is None or navigation_id is None:
                    continue

                json_navigation = PosSaleTypeNavigation(
                    pos_id=pos_id,
                    sale_type=sale_type,
                    navigation_id=navigation_id,
                )
                all_navigations.append(json_navigation)

            self.common_repository.delete_all_pos_navigation_sale_type(
                database_path=product_db,
            )
            self.common_repository.insert_pos_navigation_sale_type(
                database_path=product_db,
                navigations=all_navigations,
            )

            logger.info("Navigations updated")
        except ErrorGenesisDatabaseNotFound:
            logger.exception("Error inserting navigations on database")
            raise
        except (Exception,):
            logger.exception("Error inserting navigations on database")
            raise ErrorUpdatingDatabases()

    def _has_necessary_info_to_update_navigations(
        self,           # type: UpdaterService
        product_db,     # type: str
    ):
        # type: (...) -> bool

        if not os.path.exists(product_db):
            raise ErrorGenesisDatabaseNotFound()

        table_exists = self.common_repository.table_exists_in_db(
            database_path=product_db,
            table_name="PosSaleTypeNavigation",
        )
        if not table_exists:
            return False

        if not os.path.exists(self.navigation_json):
            logger.info("The file {} was not found to update navigation on database".format(self.navigation_json))
            return False

        return True

    @staticmethod
    def get_database_id(database_id):
        # type: (str) -> Optional[str]

        if database_id is None:
            return database_id

        database_id = database_id.split("_")
        position = 1 if len(database_id) > 0 else 0
        return database_id[position]
