import itertools

from parallels.core import safe_format, get_logger, messages
from parallels.core.actions.base.subscription_action import SubscriptionAction
from parallels.core.actions.utils.logging_properties import LoggingProperties
from parallels.core.converter.dns import UniqueDnsZoneAdapter, Rec

logger = get_logger(__name__)


class FixDnsZoneInconsistency(SubscriptionAction):
    def get_description(self):
        """Get short description of action as string

        :rtype: str | unicode
        """
        return messages.ACTION_FIX_DNS_ZONE_CONSISTENCY

    def get_failure_message(self, global_context, subscription):
        """Get message for situation when action failed

        :type global_context: parallels.core.global_context.GlobalMigrationContext
        :type subscription: parallels.core.migrated_subscription.MigratedSubscription
        """
        return safe_format(messages.ACTION_FIX_DNS_ZONE_CONSISTENCY_FAILURE, subscription=subscription.name)

    def get_logging_properties(self):
        """Get how action should be logged to migration tools end-user

        :rtype: parallels.core.actions.utils.logging_properties.LoggingProperties
        """
        return LoggingProperties(info_log=False)

    def run(self, global_context, subscription):
        """Check and add NS records and related A/AAAA records if missed

        :type global_context: parallels.core.global_context.GlobalMigrationContext
        :type subscription: parallels.core.migrated_subscription.MigratedSubscription
        """
        target_plesk_version = global_context.conn.target.plesk_server.plesk_version

        if target_plesk_version[:2] <= (17, 8):
            self._remove_all_ns_records(subscription)
            # replace _remove_all_ns_records with _remove_external_ns_records when PPP-46944 will be backported to 17.8
            # self._remove_external_ns_records(subscription)
        self._add_missed_ns_records(subscription)
        self._add_missed_a_records_for_ns_records(subscription, target_plesk_version)

    @staticmethod
    def _remove_all_ns_records(subscription):
        """Remove all NS records from DNS zones
        :type subscription: parallels.core.migrated_subscription.MigratedSubscription
        """
        for domain in FixDnsZoneInconsistency._iter_subscription_domains_with_dns_zone(subscription):
            dns_zone = UniqueDnsZoneAdapter(domain.dns_zone)

            records_to_delete = []
            with logger.debug_level_context(messages.REMOVE_NS_RECORDS, domain=domain.name):
                for rec in list(domain.dns_zone.iter_dns_records()):
                    if rec.rec_type == 'NS':
                        records_to_delete.append(rec)
                for record_to_delete in records_to_delete:
                    dns_zone.remove_dns_record(record_to_delete)
                    logger.fdebug(messages.NS_RECORD_DELETED, src=record_to_delete.src, dst=record_to_delete.dst)

    @staticmethod
    def _remove_external_ns_records(subscription):
        """Remove NS records with external servers
        Example of records with external nameservers:
            NSes: 'ns1.provider.com', 'ns2.provider.com' and A: 'mydomain.com'
        Example of records with local nameservers:
            NSes: 'ns1.mydomain.com', 'ns2.mydomain.com' and A: 'mydomain.com'
        :type subscription: parallels.core.migrated_subscription.MigratedSubscription
        """
        for domain in FixDnsZoneInconsistency._iter_subscription_domains_with_dns_zone(subscription):
            dns_zone = UniqueDnsZoneAdapter(domain.dns_zone)

            records_to_delete = []
            with logger.debug_level_context(messages.REMOVE_EXTERNAL_NS_RECORDS, domain=domain.name):
                for rec in list(domain.dns_zone.iter_dns_records()):
                    dst_is_subdomain = rec.dst.endswith('.' + domain.name + '.') or rec.dst.endswith('.' + domain.name)
                    if rec.rec_type == 'NS' and not dst_is_subdomain:
                        records_to_delete.append(rec)
                for record_to_delete in records_to_delete:
                    dns_zone.remove_dns_record(record_to_delete)
                    logger.fdebug(messages.NS_RECORD_DELETED, src=record_to_delete.src, dst=record_to_delete.dst)

    @staticmethod
    def _add_missed_ns_records(subscription):
        """If DNS zone of any domain or alias has no NS records, add them

        :type subscription: parallels.core.migrated_subscription.MigratedSubscription
        """
        for domain in FixDnsZoneInconsistency._iter_subscription_domains_with_dns_zone(subscription):
            dns_zone = UniqueDnsZoneAdapter(domain.dns_zone)

            with logger.debug_level_context(messages.CHECK_AND_ADD_NS_RECORDS, domain=domain.name):
                if not FixDnsZoneInconsistency._dns_zone_has_ns_records(dns_zone=domain.dns_zone):
                    domain_name_with_dot = domain.name + '.'
                    records = [
                        Rec(rec_type='NS', src=domain_name_with_dot, dst='ns1.' + domain_name_with_dot, opt=''),
                        Rec(rec_type='NS', src=domain_name_with_dot, dst='ns2.' + domain_name_with_dot, opt='')
                    ]
                    for record in records:
                        dns_zone.add_dns_record(record)
                        logger.fdebug(messages.NS_RECORD_ADDED, src=record.src, dst=record.dst)

    @staticmethod
    def _add_missed_a_records_for_ns_records(subscription, plesk_version):
        """If A records for NS records are missed, add them (if required by Plesk version)

        :type subscription: parallels.core.migrated_subscription.MigratedSubscription
        """
        target_web_ips = FixDnsZoneInconsistency._get_subscription_ips(subscription)
        if len(target_web_ips) == 0:
            raise Exception(
                safe_format(messages.SUBSCRIPTION_HAS_NO_IP_ADDRESSES_TARGET, subscription=subscription.name)
            )
        ip_address = target_web_ips[0]

        for domain in FixDnsZoneInconsistency._iter_subscription_domains_with_dns_zone(subscription):
            dns_zone = UniqueDnsZoneAdapter(domain.dns_zone)

            with logger.debug_level_context(messages.LOG_CHECK_AND_ADD_A_RECORDS_FOR_NS_RECORDS, domain=domain.name):
                nameservers = set()
                for rec in list(domain.dns_zone.iter_dns_records()):
                    if rec.rec_type == 'NS' and rec.dst not in nameservers:
                        if plesk_version[:2] <= (17, 8):
                            nameservers.add(rec.dst)
                        else:
                            # Plesk 18 allows zones with NS without A if NS are external.
                            # Example: NSes: 'ns1.provider.com', 'ns2.provider.com' and A: 'mydomain.com'
                            # Required to find only local NS.
                            # Example: NSes: 'ns1.mydomain.com', 'ns2.mydomain.com' and A: 'mydomain.com'
                            if rec.dst.endswith('.' + rec.src):
                                nameservers.add(rec.dst)
                for nameserver in nameservers:
                    record = Rec(rec_type='A', src=nameserver, dst=ip_address, opt='')
                    if not FixDnsZoneInconsistency._dns_zone_has_record(domain.dns_zone, record):
                        dns_zone.add_dns_record(record)
                        logger.fdebug(messages.A_RECORD_FOR_NS_ADDED, ns=nameserver, src=record.src, dst=record.dst)

    @staticmethod
    def _dns_zone_has_record(dns_zone, record):
        """
        :type dns_zone: parallels.core.dump.data_model.DnsZone
        :type record: parallels.core.converter.dns.Rec
        :rtype: bool
        """
        for dns_record in list(dns_zone.iter_dns_records()):
            if FixDnsZoneInconsistency._records_are_eq(dns_record, record):
                return True

        return False

    @staticmethod
    def _records_are_eq(rec1, rec2):
        """
        :type rec1: parallels.core.converter.dns.Rec
        :type rec2: parallels.core.converter.dns.Rec
        :rtype: bool
        """
        return (
                rec1.rec_type == rec2.rec_type and
                rec1.src == rec2.src and
                rec1.dst == rec2.dst and
                rec1.opt == rec2.opt
        )

    @staticmethod
    def _iter_subscription_domains_with_dns_zone(subscription):
        """
        :type subscription: parallels.core.migrated_subscription.MigratedSubscription
        :rtype: [ parallels.core.dump.data_model.Domain | parallels.core.dump.data_model.DomainAlias ]
        """
        for domain in itertools.chain(
                subscription.converted_dump.iter_domains(),
                subscription.converted_dump.iter_aliases()
        ):
            if domain.dns_zone is None:
                continue

            if len(list(domain.dns_zone.iter_dns_records())) == 0:
                continue

            yield domain

    @staticmethod
    def _dns_zone_has_ns_records(dns_zone):
        """
        :type dns_zone: parallels.core.dump.data_model.DnsZone
        :rtype: bool
        """
        for rec in list(dns_zone.iter_dns_records()):
            if rec.rec_type == 'NS':
                return True

        return False

    @staticmethod
    def _get_subscription_ips(subscription):
        """Returns list of IP addresses belong to subscription

        :type subscription: parallels.core.migrated_subscription.MigratedSubscription
        :rtype: list
        """
        target_web_ips = []
        if subscription.target_public_web_ipv4 is not None:
            target_web_ips.append(subscription.target_public_web_ipv4)
        if subscription.target_public_web_ipv6 is not None:
            target_web_ips.append(subscription.target_public_web_ipv6)

        if len(target_web_ips) == 0:
            # Subscription has no web hosting, try to take IP of mail hosting.
            # In most cases that should work fine.
            if subscription.target_public_mail_ipv4 is not None:
                target_web_ips.append(subscription.target_public_mail_ipv4)

        return target_web_ips
