import os
from collections import defaultdict
from contextlib import closing
from xml.etree import ElementTree

from parallels.core.dump.dump_archive_writer import DumpArchiveWriter
from parallels.core.logging import get_logger
from parallels.core.registry import Registry
from parallels.core.reports.model.issue import Issue
from parallels.core.utils.common import safe_format, if_not_none
from parallels.core.utils.common.xml import xml_to_string_pretty
from parallels.plesk.source.plesk import messages
from parallels.plesk.source.plesk.hosting_description.dump.base_dump import BaseDump
from plesk_migrator.compatibility.utils import stringio

logger = get_logger(__name__)


class PleskDumpConverter(object):
    """
    :type _main_dump: BaseDump
    :type _extra_dump: BaseDump
    :type _result_dump_writer: parallels.core.dump.dump_archive_writer.DumpArchiveWriter
    :type _extra_dump_entities: dict[str|unicode, dict[str|unicode, str|unicode]]
    :type _xs_types: dict[str|unicode, str|unicode]
    :type _schema_namespaces: dict[str|unicode, str|unicode]
    :type _schema: xml.etree.ElementTree.ElementTree
    :type _identities: dict
    """
    def __init__(self, result_dump_file, main_dump, extra_dump):
        """
        :type result_dump_file: str | unicode
        :type main_dump: BaseDump
        :type extra_dump: BaseDump
        """
        self._main_dump = main_dump
        self._extra_dump = extra_dump
        self._result_dump_writer = DumpArchiveWriter(result_dump_file)
        self._extra_dump_entities = defaultdict(dict)
        xs_namespace = 'http://www.w3.org/2001/XMLSchema'
        self._xs_types = {
            name: '{%s}%s' % (xs_namespace, name)
            for name in ['element', 'sequence', 'choice', 'all', 'complexContent']
        }
        self._schema_namespaces = {'xs': xs_namespace}
        self._schema = ElementTree.ElementTree(ElementTree.Element(None))
        self._identities = {
            'domain-alias': lambda e: e.get('name'),
            'dns-zone-param': lambda e: e.get('name'),
            'dnsrec': lambda e: (e.get('type'), e.get('src'), e.get('dst')),
            'custom-button': lambda e: (e.get('interface-place'), e.get('text')),
            'plan': lambda e: e.get('plan-guid'),
            'db-server': lambda e: (e.get('type'), e.findtext('host'), e.findtext('port')),
            'database': lambda e: (
                e.get('name'), e.get('type'), e.findtext('db-server/host'), e.findtext('db-server/port')
            ),
            'dbuser': lambda e: (e.get('name'), e.findtext('db-server/host'), e.findtext('db-server/port')),
            'limit': lambda e: e.get('name'),
            'permission': lambda e: (
                e.get('name') if e.get('name') is not None else e.text
            ),
            'description': lambda e: (e.get('object-name'), e.get('object-type')),
            'ip': lambda e: e.findtext('ip-address'),
            'param': lambda e: e.findtext('name'),
            'parameter': lambda e: e.findtext('name'),
            'setting': lambda e: e.findtext('name'),
            'property': lambda e: e.findtext('name'),
            'mailuser': lambda e: e.get('name'),
            'maillist': lambda e: e.get('name').lower() if e.get('name') else e.get('name'),
            'certificate': lambda e: e.get('name'),
            'wordpress-instance': lambda e: e.get('path'),
            'composer-instance': lambda e: e.get('path'),
            'pdir': lambda e: e.get('name'),
            'pduser': lambda e: e.get('name'),
            'mime-type': lambda e: e.get('ext'),
            'webuser': lambda e: e.get('name'),
            'ftpuser': lambda e: e.get('name'),
            'subdomain': lambda e: e.get('name'),
            'site': lambda e: e.get('name'),
            'sapp-installed': lambda e: e.findtext('sapp-installdir/sapp-prefix'),
            'sapp-entry-point': lambda e: (
                # Identity each entry point by label and permission attributes; there could be nodes with the same
                # label, but different permissions
                e.findtext('label'),
                if_not_none(
                    # according to the schema, there is always one permission node
                    e.find('limits-and-permissions/permission'),
                    lambda permission: permission.attrib.get('class'),
                ),
                if_not_none(
                    # according to the schema, there is always one permission node
                    e.find('limits-and-permissions/permission'),
                    lambda permission: permission.attrib.get('name'),
                )
            ),
            'forwarding': lambda e: e.text,
            'attach': lambda e: e.get('file'),
            'autoresponder-limit': lambda e: e.get('name'),
            'anonftp-limit': lambda e: e.get('name'),
            'anonftp-permission': lambda e: e.get('name'),
            'extension': lambda e: e.text,
            'friend': lambda e: e.text,
            'owner': lambda e: e.text.lower() if e.text else e.text,
            'recipient': lambda e: e.text.lower() if e.text else e.text,
            'blacklist-member': lambda e: e.text,
            'whitelist-member': lambda e: e.text,
            'trusted-locale': lambda e: e.text,
            'trusted-language': lambda e: e.text,
            'trusted-network': lambda e: e.text,
            'related-site': lambda e: e.get('name'),
            'alias': lambda e: e.text,
            'scheduled-task': lambda e: (
                e.get('command'), e.get('command_arguments'), e.get('period'), e.get('minute'), e.get('hour'),
                e.get('day_of_week'), e.get('day_of_month'), e.get('month'), e.get('is_active'),
                e.get('description'), e.get('notify'), e.get('notifications_email'), e.get('php_handler_id'),
            )
        }

    def load_schema(self, schema_path):
        """
        :type schema_path: str | unicode
        """
        self._schema = self._load_schema(schema_path)

    def merge_dumps(self, dump, extra_dump):
        """
        :type dump: xml.etree.ElementTree.ElementTree
        :type extra_dump: xml.etree.ElementTree.ElementTree
        :rtype: xml.etree.ElementTree.ElementTree
        """
        merged_dump = ElementTree.ElementTree(ElementTree.Element(dump.getroot().tag))
        schema_root_node = self._schema.find("xs:element[@name='%s']" % dump.getroot().tag, self._schema_namespaces)
        self._merge_xml_elements(
            merged_dump.getroot(),
            dump.getroot(),
            extra_dump.getroot(),
            self._get_xml_element_schema(schema_root_node)
        )
        return merged_dump

    def merge(self):
        with closing(self._result_dump_writer):
            for dump_xml, reseller_name, client_name, domain_name, guid in self._main_dump.get_entities():
                if domain_name is not None:
                    merged_dump_xml = self._merge_domain(domain_name, dump_xml)
                    if merged_dump_xml:
                        dump_xml = merged_dump_xml
                self._result_dump_writer.add_dump(
                    dump_xml, reseller_name=reseller_name, client_name=client_name, domain_name=domain_name, guid=guid
                )

    def _merge_domain(self, domain_name, dump_xml):
        """
        :type domain_name: str | unicode
        :type dump_xml: str | unicode
        :rtype: str | unicode | None
        """
        extra_dump_content = self._extra_dump.get_entity_content('domain', domain_name)
        if extra_dump_content is None:
            Registry.get_instance().get_context().pre_check_report.add_issue(
                'hosting_description_merge_domain_not_found',
                Issue.SEVERITY_WARNING,
                safe_format(messages.DOMAIN_CONFIGURATION_BACKUP_IS_NOT_FOUND, name=domain_name)
            )
            return None
        try:
            dump = ElementTree.parse(stringio(dump_xml, is_unicode=False))
            extra_dump = ElementTree.parse(stringio(extra_dump_content, is_unicode=False))
            merged_dump = self.merge_dumps(dump, extra_dump)
            return xml_to_string_pretty(merged_dump)
        except Exception as e:
            logger.exception()
            Registry.get_instance().get_context().pre_check_report.add_issue(
                'hosting_description_merge_domain_failed',
                Issue.SEVERITY_WARNING,
                safe_format(messages.FAILED_TO_MERGE_DOMAIN_CONFIGURATION_BACKUP, name=domain_name, error=e)
            )

    def _merge_xml_elements(self, merged_dump_node, dump_node, extra_dump_node, dump_node_schema):
        """
        :type merged_dump_node: xml.etree.ElementTree.Element
        :type dump_node: xml.etree.ElementTree.Element
        :type extra_dump_node: xml.etree.ElementTree.Element
        :type dump_node_schema: xml.etree.ElementTree.Element | None
        """
        if extra_dump_node.tag in ['content', 'extensions']:
            # skip the 'content' elements of the extra dump because currently we
            # merge XML files only and there is no necessity to merge archives;
            # skip the 'extensions' elements of the extra dump because extension data
            # will be deployed by separate actions
            extra_dump_node = ElementTree.Element(None)

        dump_node_compression = None
        extra_dump_node_compression = None

        for name, value in dump_node.items():
            if name == 'compression':
                dump_node_compression = name, value
                continue
            merged_dump_node.set(name, value)
        for name, value in extra_dump_node.items():
            if name == 'compression':
                extra_dump_node_compression = name, value
                continue
            if (
                    merged_dump_node.get(name) is None and
                    # the following attributes should be skipped - they make no sense for migration
                    # and actually could break restoration of the dump
                    name not in {'encryption-type', 'verification-string'}
            ):
                merged_dump_node.set(name, value)

        dump_node_has_text = self._has_text(dump_node.text)
        extra_dump_node_has_text = self._has_text(extra_dump_node.text)

        if dump_node_has_text:
            if extra_dump_node_has_text and extra_dump_node_compression and not dump_node_compression:
                merged_dump_node.text = extra_dump_node.text
                merged_dump_node.set(*extra_dump_node_compression)
            else:
                merged_dump_node.text = dump_node.text
                if dump_node_compression:
                    merged_dump_node.set(*dump_node_compression)
        elif extra_dump_node_has_text:
            merged_dump_node.text = extra_dump_node.text
            if extra_dump_node_compression:
                merged_dump_node.set(*extra_dump_node_compression)

        if dump_node_schema is not None:
            self._merge_xml_elements_with_schema(merged_dump_node, dump_node, extra_dump_node, dump_node_schema)
        else:
            self._merge_xml_elements_without_schema(merged_dump_node, dump_node, extra_dump_node)

    @staticmethod
    def _has_text(text):
        """
        :type text: str
        :return: bool
        """
        # Our xml_to_string_pretty function do not process XML formatting symbols ('\n', '\t') correctly.
        # So we skip such symbols here.
        return text is not None and (text == '' or text.strip())

    def _merge_xml_elements_with_schema(self, merged_dump_node, dump_node, extra_dump_node, dump_node_schema):
        """Merge using XSD-schema. Fix cases when sequence of elements is wrong or there are unwanted elements.
        :type merged_dump_node: xml.etree.ElementTree.Element
        :type dump_node: xml.etree.ElementTree.Element
        :type extra_dump_node: xml.etree.ElementTree.Element
        :type dump_node_schema: xml.etree.ElementTree.Element
        """
        # 1. Get next child element from schema.
        for node_name, schema_node in self._iter_xml_element_schema_sequence(dump_node_schema):
            # 2. Look for and merge the same elements from extra dump.
            for extra_dump_child_node in extra_dump_node.iterfind(node_name):
                dump_child_node_index = self._find_child_xml_element(dump_node, extra_dump_child_node)
                if dump_child_node_index != -1:
                    dump_child_node = dump_node[dump_child_node_index]
                else:
                    dump_child_node = ElementTree.Element(None)
                merged_dump_child_node = ElementTree.Element(extra_dump_child_node.tag)
                merged_dump_node.append(merged_dump_child_node)
                self._merge_xml_elements(
                    merged_dump_child_node,
                    dump_child_node,
                    extra_dump_child_node,
                    schema_node
                )
            # 3. Look for and copy the same rest elements from dump.
            for dump_child_node in dump_node.iterfind(node_name):
                extra_dump_child_node_index = self._find_child_xml_element(extra_dump_node, dump_child_node)
                if extra_dump_child_node_index == -1:
                    merged_dump_child_node = ElementTree.Element(dump_child_node.tag)
                    merged_dump_node.append(merged_dump_child_node)
                    self._merge_xml_elements(
                        merged_dump_child_node,
                        dump_child_node,
                        ElementTree.Element(None),
                        schema_node
                    )

    def _merge_xml_elements_without_schema(self, merged_dump_node, dump_node, extra_dump_node):
        """Merge without using XSD-schema. It is required for elements with 'any' type or when schema was not found.
        :type merged_dump_node: xml.etree.ElementTree.Element
        :type dump_node: xml.etree.ElementTree.Element
        :type extra_dump_node: xml.etree.ElementTree.Element
        """
        dump_index, extra_dump_index = 0, 0
        while dump_index < len(dump_node) or extra_dump_index < len(extra_dump_node):
            if extra_dump_index < len(extra_dump_node):
                # 1. Get next child element from extra dump.
                extra_dump_child_node = extra_dump_node[extra_dump_index]
                # 2. Look for the same element in dump.
                dump_child_node_index = self._find_child_xml_element(dump_node, extra_dump_child_node)
                if dump_child_node_index != -1:
                    # 3. Merge founded element and all previous elements from dump and then go to step 1.
                    while dump_index <= dump_child_node_index:
                        dump_child_node = dump_node[dump_index]
                        extra_dump_child_node_index = self._find_child_xml_element(extra_dump_node, dump_child_node)
                        if extra_dump_child_node_index == -1 or extra_dump_child_node_index >= extra_dump_index:
                            if extra_dump_child_node_index != -1:
                                extra_dump_child_node = extra_dump_node[extra_dump_child_node_index]
                            else:
                                extra_dump_child_node = ElementTree.Element(None)
                            merged_dump_child_node = ElementTree.Element(dump_child_node.tag)
                            merged_dump_node.append(merged_dump_child_node)
                            self._merge_xml_elements(
                                merged_dump_child_node,
                                dump_child_node,
                                extra_dump_child_node,
                                None
                            )
                        dump_index += 1
                    extra_dump_index += 1
                    continue
                if dump_index < len(dump_node):
                    # 4. Get next child element from dump.
                    dump_child_node = dump_node[dump_index]
                    # 5. Look for the same element in extra dump.
                    extra_dump_child_node_index = self._find_child_xml_element(extra_dump_node, dump_child_node)
                    if extra_dump_child_node_index != -1:
                        # 6. Merge founded element and all previous elements from extra dump and then go to step 1.
                        while extra_dump_index <= extra_dump_child_node_index:
                            extra_dump_child_node = extra_dump_node[extra_dump_index]
                            dump_child_node_index = self._find_child_xml_element(dump_node, extra_dump_child_node)
                            if dump_child_node_index == -1 or dump_child_node_index >= dump_index:
                                if dump_child_node_index != -1:
                                    dump_child_node = dump_node[dump_child_node_index]
                                else:
                                    dump_child_node = ElementTree.Element(None)
                                merged_dump_child_node = ElementTree.Element(extra_dump_child_node.tag)
                                merged_dump_node.append(merged_dump_child_node)
                                self._merge_xml_elements(
                                    merged_dump_child_node,
                                    dump_child_node,
                                    extra_dump_child_node,
                                    None
                                )
                            extra_dump_index += 1
                        dump_index += 1
                        continue
                # 7. Copy child element from extra dump and then go to step 1.
                merged_dump_child_node = ElementTree.Element(extra_dump_child_node.tag)
                merged_dump_node.append(merged_dump_child_node)
                self._merge_xml_elements(
                    merged_dump_child_node,
                    ElementTree.Element(None),
                    extra_dump_child_node,
                    None
                )
                extra_dump_index += 1
                continue

            # 8. Copy rest elements from dump.
            dump_child_node = dump_node[dump_index]
            extra_dump_child_node_index = self._find_child_xml_element(extra_dump_node, dump_child_node)
            if extra_dump_child_node_index == -1 or extra_dump_child_node_index >= extra_dump_index:
                merged_dump_child_node = ElementTree.Element(dump_child_node.tag)
                merged_dump_node.append(merged_dump_child_node)
                self._merge_xml_elements(
                    merged_dump_child_node,
                    dump_child_node,
                    ElementTree.Element(None),
                    None
                )
            dump_index += 1

    def _get_xml_element_schema(self, schema_node):
        """
        :type schema_node: xml.etree.ElementTree.Element | None
        :rtype: xml.etree.ElementTree.Element | None
        """
        if schema_node is None:
            return None
        ref_node_name = schema_node.get('ref')
        if ref_node_name:
            schema_ref_node = self._schema.find("xs:element[@name='%s']" % ref_node_name, self._schema_namespaces)
            return self._get_xml_element_schema(schema_ref_node)
        type_name = schema_node.get('type', schema_node.get('base'))
        if type_name:
            element_schema_node = self._schema.find("xs:complexType[@name='%s']" % type_name, self._schema_namespaces)
            return element_schema_node
        element_schema_node = schema_node.find("xs:complexType", self._schema_namespaces)
        return element_schema_node

    def _iter_xml_element_schema_sequence(self, complex_type_schema_node):
        """
        :type complex_type_schema_node: xml.etree.ElementTree.Element
        :rtype: collections.Iterable[tuple[str | unicode, xml.etree.ElementTree.Element | None]]
        """
        for schema_node in complex_type_schema_node:
            if schema_node.tag == self._xs_types['complexContent']:
                extension_schema_node = schema_node.find("xs:extension", self._schema_namespaces)
                if extension_schema_node is not None:
                    base_schema_node = self._get_xml_element_schema(extension_schema_node)
                    if base_schema_node is not None:
                        for node_name, element_schema_node in self._iter_xml_element_schema_sequence(base_schema_node):
                            yield node_name, element_schema_node
                    for node_name, element_schema_node in self._iter_xml_element_schema_sequence(extension_schema_node):
                        yield node_name, element_schema_node
            if schema_node.tag in [self._xs_types['sequence'], self._xs_types['choice'], self._xs_types['all']]:
                for node_name, element_schema_node in self._iter_xml_element_schema_sequence(schema_node):
                    yield node_name, element_schema_node
            if schema_node.tag == self._xs_types['element']:
                node_name = schema_node.get('name')
                if node_name is None:
                    node_name = schema_node.get('ref')
                if node_name is not None:
                    element_schema_node = self._get_xml_element_schema(schema_node)
                    yield node_name, element_schema_node

    def _load_schema(self, schema_path):
        """
        :type schema_path: str | unicode
        :rtype: xml.etree.ElementTree.ElementTree
        """
        logger.debug(safe_format(messages.LOAD_SCHEMA_FROM, path=schema_path))
        schema = ElementTree.parse(schema_path)
        for include_node in schema.findall("xs:include", self._schema_namespaces):
            schema_location = include_node.get('schemaLocation')
            if schema_location:
                add_schema_path = os.path.join(os.path.dirname(schema_path), schema_location)
                add_schema = self._load_schema(add_schema_path)
                schema.getroot().extend(add_schema.findall("*", self._schema_namespaces))
        return schema

    def _find_child_xml_element(self, node, pattern_node):
        """
        :type node: xml.etree.ElementTree.Element
        :type pattern_node: xml.etree.ElementTree.Element
        :rtype: int
        """
        get_identity = self._identities.get(pattern_node.tag, lambda x: None)
        pattern_node_identity = get_identity(pattern_node)
        for child_node_index, child_node in enumerate(node):
            if child_node.tag != pattern_node.tag:
                continue
            child_node_identity = get_identity(child_node)
            if child_node_identity == pattern_node_identity:
                return child_node_index
        return -1
