import binascii
import os
import re
import shutil
import struct

from parallels.core import messages
from parallels.core.logging import get_logger
from parallels.core.reports.model.issue import Issue
from parallels.core.runners.entities import ExecutionOptions
from parallels.core.utils.common import safe_format
from parallels.core.utils.json_utils import write_json
from parallels.core.utils.pmm.agent import DumpSelected
from parallels.core.utils.pmm.pmmcli import PMMCLICommandWindows, PMMCLICommandUnix
from parallels.plesk.hosting_description.converter_to_dump import HostingDescriptionToPleskDumpConverter
from parallels.plesk.source.plesk.hosting_description.converter import PleskDumpConverter
from parallels.plesk.source.plesk.hosting_description.dump.base_dump import BaseDump
from parallels.plesk.source.plesk.hosting_description.dump.file_dump import FileDump
from parallels.plesk.source.plesk.hosting_description.dump.repo_dump import RepoDump
from parallels.plesk.source.plesk.hosting_description.provider.factory import create_plesk_hosting_description_provider

logger = get_logger(__name__)


class HostingDescriptionAgent(object):
    def __init__(self, dump_agent):
        """Implements logic of creation of source server dump via creation of hosting description.

        :type dump_agent: parallels.core.utils.pmm.agent.PmmMigrationAgentBase
        """
        self._dump_agent = dump_agent

    def create_dump(self, local_dump_path, selection):
        """Create dump of source server

        :type local_dump_path: str | unicode
        :type selection: parallels.core.utils.pmm.agent.DumpAll | parallels.core.utils.pmm.agent.DumpSelected
        """
        local_dump_filename, local_dump_extension = os.path.splitext(local_dump_path)
        hosting_description_json = '%s.hosting-description.json' % (local_dump_filename,)
        hosting_description_dump_path = '%s.hosting-description%s' % (local_dump_filename, local_dump_extension)

        hosting_description = self._create_hosting_description(selection)
        write_json(hosting_description_json, hosting_description, pretty_print=True)
        self._convert_hosting_description_to_dump(hosting_description, hosting_description_dump_path)

        remote_dump_path = '%s.dump%s' % (local_dump_filename, local_dump_extension)
        if not self._create_remote_dump_with_plesk_backup(selection, remote_dump_path):
            shutil.copy(hosting_description_dump_path, local_dump_path)
            return

        import_result = self._import_remote_dump(remote_dump_path)
        if import_result is None:
            shutil.copy(hosting_description_dump_path, local_dump_path)
            return

        merged_dump_path = '%s.merged%s' % (local_dump_filename, local_dump_extension)
        exported_dump_path = '%s.exported%s' % (local_dump_filename, local_dump_extension)
        imported_dump_name, imported_dump_prefix, imported_dump_id = \
            import_result.backup_xml_file_path, import_result.backup_prefix, import_result.backup_id
        if imported_dump_name is None:
            imported_dump_name, imported_dump_prefix, imported_dump_id = \
                self._get_imported_dump_name_prefix_id_from_file(remote_dump_path)
        if imported_dump_name is None:
            shutil.copy(hosting_description_dump_path, local_dump_path)
            return

        try:
            export_result = self._export_dump(imported_dump_name, exported_dump_path)
            hosting_description_dump = FileDump(hosting_description_dump_path)
            if export_result is None:
                dump_dir = self._dump_agent.global_context.conn.target.plesk_server.dump_dir
                plesk_dump = RepoDump(dump_dir, imported_dump_prefix, imported_dump_id)
            else:
                exported_cut_dump_path = self._cut_dump_extra_data(exported_dump_path)
                plesk_dump = FileDump(exported_cut_dump_path, export_result.backup_prefix, export_result.backup_id)
            if not self._merge_dumps(merged_dump_path, hosting_description_dump, plesk_dump):
                shutil.copy(hosting_description_dump_path, local_dump_path)
                return
        finally:
            self._remove_imported_dump(imported_dump_name)

        shutil.copy(merged_dump_path, local_dump_path)

    @staticmethod
    def _get_imported_dump_name_prefix_id_from_file(dump_file):
        """Returns (dump_name, dump_prefix, dump_id) from dump file

        :type dump_file: str | unicode
        :rtype: tuple
        """
        file_dump = FileDump(dump_file)
        xml_files = file_dump.list_xmls()
        for xml_file in xml_files:
            if '/' in xml_file:
                continue
            name = xml_file
            prefix = xml_file[:xml_file.find('_')]
            backup_id = None
            match = re.search(r'(\d+)\.xml$', xml_file)
            if match is not None:
                backup_id = match.group(1)
            return name, prefix, backup_id
        return None, None, None

    def _create_hosting_description(self, selection):
        """Create hosting description of source server

        :type selection: parallels.core.utils.pmm.agent.DumpAll | parallels.core.utils.pmm.agent.DumpSelected
        :rtype: dict
        """
        logger.info(messages.CREATE_HOSTING_DESCRIPTION)
        hosting_description_provider = create_plesk_hosting_description_provider(self._dump_agent, selection)
        return hosting_description_provider.get_description()

    def _convert_hosting_description_to_dump(self, hosting_description, hosting_description_dump_path):
        """Convert hosting description of source server to target Plesk dump format

        :type hosting_description: dict
        :type hosting_description_dump_path: str | unicode
        """
        logger.info(messages.CONVERT_HOSTING_DESCRIPTION.format(dump_path=hosting_description_dump_path))
        target_plesk_version = self._dump_agent.global_context.conn.target.plesk_server.get_plesk_version()
        dump_creator = HostingDescriptionToPleskDumpConverter(
            data=hosting_description, target_dump_file=hosting_description_dump_path,
            target_plesk_version=target_plesk_version, database_servers=[], is_windows=True,
            source_ip=self._dump_agent.source_server().ip()
        )
        dump_creator.write_dump()

    def _create_remote_dump_with_plesk_backup(self, selection, remote_dump_path):
        """Create the source server configuration dump using pleskbackup utility on the source server

        :type selection: parallels.core.utils.pmm.agent.DumpAll | parallels.core.utils.pmm.agent.DumpSelected
        :type remote_dump_path: str | unicode
        :rtype: bool
        """
        logger.info(messages.CREATE_REMOTE_CONFIGURATION_DUMP.format(dump_path=remote_dump_path))
        try:
            with self._dump_agent.source_server().runner() as runner:
                backup_path = self._make_backup(runner, selection)
                runner.get_file(backup_path, remote_dump_path)
            return True
        except Exception as e:
            logger.exception()
            self._dump_agent.global_context.pre_check_report.add_issue(
                'hosting_description_create_remote_dump',
                Issue.SEVERITY_WARNING,
                safe_format(messages.FAILED_TO_CREATE_REMOTE_CONFIGURATION_DUMP, error=e)
            )
            return False

    def _import_remote_dump(self, remote_dump_path):
        """Import the configuration dump created on the source server

        :type remote_dump_path: str | unicode
        :rtype: parallels.core.utils.pmm.pmmcli.PmmCliResult | None
        """
        logger.info(messages.IMPORT_REMOTE_CONFIGURATION_DUMP)
        try:
            if self._dump_agent.source_server().is_windows():
                pmmcli = PMMCLICommandWindows(self._dump_agent.global_context.conn.target.plesk_server)
            else:
                pmmcli = PMMCLICommandUnix(self._dump_agent.global_context.conn.target.plesk_server)
            result = pmmcli.import_dump(backup_path=remote_dump_path)
            if result.error_code not in {'0', '116', '152'}:
                self._dump_agent.global_context.pre_check_report.add_issue(
                    'hosting_description_import_remote_dump',
                    Issue.SEVERITY_WARNING,
                    safe_format(messages.FAILED_TO_IMPORT_REMOTE_CONFIGURATION_DUMP, error=result.error_message)
                )
                logger.fwarn(messages.FAILED_TO_IMPORT_REMOTE_CONFIGURATION_DUMP, error=result.error_message)
                return None
            return result
        except Exception as e:
            logger.exception()
            self._dump_agent.global_context.pre_check_report.add_issue(
                'hosting_description_import_remote_dump',
                Issue.SEVERITY_WARNING,
                safe_format(messages.FAILED_TO_IMPORT_REMOTE_CONFIGURATION_DUMP, error=e)
            )
            logger.fwarn(messages.FAILED_TO_IMPORT_REMOTE_CONFIGURATION_DUMP, error=e)
            return None

    def _export_dump(self, specification_file_path, exported_dump_path):
        """Export the configuration dump from Plesk repository

        :type specification_file_path: str | unicode
        :rtype: parallels.core.utils.pmm.pmmcli.PmmCliResult | None
        """
        logger.info(messages.EXPORT_CONFIGURATION_DUMP)
        try:
            if self._dump_agent.source_server().is_windows():
                pmmcli = PMMCLICommandWindows(self._dump_agent.global_context.conn.target.plesk_server)
            else:
                pmmcli = PMMCLICommandUnix(self._dump_agent.global_context.conn.target.plesk_server)
            return pmmcli.export_dump(
                specification_file_path=specification_file_path,
                exported_dump_path=exported_dump_path
            )
        except Exception as e:
            logger.exception()
            self._dump_agent.global_context.pre_check_report.add_issue(
                'hosting_description_export_dump',
                Issue.SEVERITY_WARNING,
                safe_format(messages.FAILED_TO_EXPORT_CONFIGURATION_DUMP, error=e)
            )
            logger.fwarn(messages.FAILED_TO_EXPORT_CONFIGURATION_DUMP, error=e)
            return None

    def _remove_imported_dump(self, dump_name):
        """Remove the imported configuration dump

        :type dump_name: str | unicode
        """
        logger.info(safe_format(messages.REMOVE_IMPORTED_CONFIGURATION_DUMP, dump_name=dump_name))
        try:
            if self._dump_agent.source_server().is_windows():
                pmmcli = PMMCLICommandWindows(self._dump_agent.global_context.conn.target.plesk_server)
            else:
                pmmcli = PMMCLICommandUnix(self._dump_agent.global_context.conn.target.plesk_server)
            pmmcli.delete_dump(dump_name)
        except Exception as e:
            logger.exception()
            logger.warning(safe_format(
                messages.FAILED_TO_REMOVE_IMPORTED_CONFIGURATION_DUMP, dump_name=dump_name, error=e)
            )

    def _merge_dumps(self, merged_dump_path, main_dump, extra_dump):
        """Merge configuration dumps

        :type merged_dump_path: str | unicode
        :type main_dump: BaseDump
        :type extra_dump: BaseDump
        :rtype: bool
        """
        logger.info(messages.MERGE_CONFIGURATION_DUMPS.format(dump_path=merged_dump_path))
        try:
            if os.path.exists(merged_dump_path):
                os.remove(merged_dump_path)
            converter = PleskDumpConverter(
                result_dump_file=merged_dump_path, main_dump=main_dump, extra_dump=extra_dump
            )
            schema_path = os.path.join(
                self._dump_agent.global_context.conn.target.plesk_server.plesk_dir, 'PMM', 'plesk.xsd'
            )
            try:
                converter.load_schema(schema_path)
            except Exception as e:
                logger.exception()
                self._dump_agent.global_context.pre_check_report.add_issue(
                    'hosting_description_load_schema',
                    Issue.SEVERITY_WARNING, safe_format(messages.FAILED_TO_LOAD_PLESK_DUMP_SCHEMA, error=e)
                )
            converter.merge()
            return True
        except Exception as e:
            logger.exception()
            self._dump_agent.global_context.pre_check_report.add_issue(
                'hosting_description_merge_dumps',
                Issue.SEVERITY_WARNING, safe_format(messages.FAILED_TO_MERGE_CONFIGURATION_DUMPS, error=e)
            )
            return False

    def _make_backup(self, runner, selection):
        """
        :type runner: parallels.core.runners.base.BaseRunner
        :type selection: parallels.core.utils.pmm.agent.DumpAll|parallels.core.utils.pmm.agent.DumpSelected
        :rtype: str|unicode
        """
        command, options = self._get_create_backup_command(runner, selection)
        runner.remove_file(options['output_file_path'])
        runner.execute_command(command, options, ExecutionOptions(ignore_exit_code=True))
        return options['output_file_path']

    def _get_create_backup_command(self, runner, selection):
        """
        :type runner: parallels.core.runners.base.BaseRunner
        :type selection: parallels.core.utils.pmm.agent.DumpAll|parallels.core.utils.pmm.agent.DumpSelected
        :rtype: tuple[str|unicode, dict[str|unicode, str|unicode]]
        """
        command = (
            u'{command} {backup_command} {configuration} {output_file} {output_file_path} '
            u'{verbose} {polling_interval} {polling_interval_value}'
        )
        if self._dump_agent.source_server().is_windows():
            options = {
                'command': r'%s\bin\pleskbackup.exe' % self._dump_agent.source_server().plesk_dir,
                'backup_command': '--domains-name',
                'configuration': '-configuration',
                'output_file': '-output-file',
                'output_file_path': self._dump_agent.source_server().get_session_file_path('backup.zip'),
                'verbose': '-verbose',
                'polling_interval': '-polling-interval',
                'polling_interval_value': '5',
            }
        else:
            verbose_value = '-v'
            if self._dump_agent.source_server().get_plesk_version() < (18, 0, 36):
                verbose_value = '-vvvvv'
            options = {
                'command': r'%s/bin/pleskbackup' % self._dump_agent.source_server().plesk_dir,
                'backup_command': '--domains-name',
                'configuration': '--configuration',
                'output_file': '--output-file',
                'output_file_path': self._dump_agent.source_server().get_session_file_path('backup.tar'),
                'verbose': verbose_value,
                'polling_interval': '--polling-interval',
                'polling_interval_value': '5',
            }
        if isinstance(selection, DumpSelected):
            options['from_file'] = '-from-file' if self._dump_agent.source_server().is_windows() else '--from-file'
            options['from_file_path'] = self._create_filter_list_file(runner, selection)
            command += u' {from_file} {from_file_path}'
        return command, options

    def _create_filter_list_file(self, runner, selection):
        """
        :type runner: parallels.core.runners.base.BaseRunner
        :type selection: parallels.core.utils.pmm.agent.DumpSelected
        :rtype: str|unicode
        """
        list_filename = self._dump_agent.source_server().get_session_file_path('filter.list')
        list_content = "\n".join(domain.encode('utf-8') for domain in selection.domains)
        runner.upload_file_content(list_filename, list_content)
        return list_filename

    def _cut_dump_extra_data(self, dump_path):
        """Remove extra data after the end of archive, save result to a new file and return a path to this file
        If extra data cut failed return the same path

        :type dump_path: str|unicode
        :rtype: str|unicode
        """
        if not self._dump_agent.global_context.conn.target.plesk_server.is_windows():
            return dump_path

        logger.finfo(messages.CUT_EXTRA_DATA_FROM_EXPORTED_DUMP, dump_path=dump_path)
        dump_filename, dump_extension = os.path.splitext(dump_path)
        if dump_extension != '.zip':
            logger.fwarn(messages.EXPORTED_DUMP_IS_NOT_ZIP, dump_path=dump_path)
            return dump_path

        logger.fdebug(messages.GETTING_END_OF_ARCHIVE_OFFSET)
        with open(dump_path, 'rb') as f:
            try:
                offset = self._get_end_of_zip_offset(f)
            except Exception as e:
                logger.fwarn(
                    messages.FAILED_TO_FIND_THE_END_OF_ARCHIVE_DESCRIPTION,
                    archive=dump_path, description=e.message
                )
                return dump_path
        if offset is None:
            logger.fwarn(messages.FAILED_TO_FIND_THE_END_OF_ARCHIVE, archive=dump_path)
            return dump_path

        result_dump_path = '%s.cut%s' % (dump_filename, dump_extension)
        logger.finfo(messages.SAVING_DUMP_WIHOUT_EXTRA_DATA, path=result_dump_path)
        self._copy_first_n_bytes(dump_path, result_dump_path, offset)
        return result_dump_path

    @staticmethod
    def _get_end_of_zip_offset(file_object):
        """
        :type IO[bytes]:
        :rtype: int|None
        """
        # eocds - end of central directory record
        eocds = ('06', '05', '4b', '50')
        eocds_pos = 0
        eocds_len = len(eocds)
        eocds_offset = None

        offset = 0
        file_object.seek(-offset, os.SEEK_END)
        file_size = file_object.tell()
        while offset <= file_size:
            offset += 1
            file_object.seek(-offset, os.SEEK_END)
            byte = file_object.read(1)
            if not byte:
                break

            hexdata = binascii.hexlify(byte)
            if hexdata == eocds[eocds_pos]:
                eocds_pos += 1
                if eocds_pos >= eocds_len:
                    eocds_offset = file_object.tell() - 1
                    break
            elif eocds_pos > 0:
                eocds_pos = 0

        if eocds_offset is None:
            return None

        comment_length_offset = eocds_offset + 20
        file_object.seek(comment_length_offset)
        comment_length_bin = file_object.read(2)
        comment_length = struct.unpack('H', comment_length_bin)[0]
        end_of_zip_offset = file_object.tell() + comment_length

        return end_of_zip_offset

    @staticmethod
    def _copy_first_n_bytes(src, dst, offset, length=16*1024):
        """
        :type src: str|unicode
        :type dst: str|unicode
        :type offset: int
        :type length: int
        :rtype: None
        """
        with open(src, 'rb') as fsrc:
            with open(dst, 'wb') as fdst:
                while 1:
                    buf = fsrc.read(length)
                    if not buf:
                        break
                    if fsrc.tell() > offset:
                        stop_pos = fsrc.tell() - offset
                        buf = buf[:len(buf) - stop_pos]
                        fdst.write(buf)
                        break
                    fdst.write(buf)
        shutil.copymode(src, dst)
