import itertools
from collections import defaultdict

from parallels.core import messages
from parallels.core.logging import get_logger
from parallels.core.utils.common import split_string_by_whitespace_chars, is_empty
from parallels.core.utils.common.ip import is_ipv4, is_ipv6, is_ip
from parallels.core.utils.entity import Entity
from parallels.core.utils.migrator_utils import safe_idn_decode, is_unicode_domain

logger = get_logger(__name__)


class Rec(Entity):
    def __init__(self, rec_type, src, dst, opt):
        self._rec_type = rec_type
        self._src = src
        self._dst = dst
        self._opt = opt

    @property
    def rec_type(self):
        return self._rec_type

    @property
    def src(self):
        return self._src

    @property
    def dst(self):
        return self._dst

    @property
    def opt(self):
        return self._opt

    def __hash__(self):
        return hash((self._rec_type, self._src, self._dst, self._opt))


def replace_dns_record_ips(subscription, target_dns_template=None, srv_dns_records_with_dot=False):
    """Replace source panel IPs with target IPs in subscription DNS zone data.
    
    Assuming, that each source subscription has a single assigned IP address of
    each type (IPv4/IPv6) used by all its services, replace that IP with the
    new ones of the target server.

    :type subscription: parallels.core.migrated_subscription.MigratedSubscription
    :type target_dns_template: parallels.core.hosting_repository.panel.DNSTemplate
    :type srv_dns_records_with_dot: bool
    :rtype: None
    """
    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)

    if len(target_web_ips) == 0:
        logger.ferror(
            messages.SUBSCRIPTION_HAS_NO_IP_ADDRESSES_TARGET, subscription=subscription.name
        )
        return

    subscription_domain_names = {domain.name for domain in subscription.converted_dump.iter_domains()}

    for domain in itertools.chain(
        subscription.converted_dump.iter_domains(),
        subscription.converted_dump.iter_aliases()
    ):
        if domain.dns_zone is None:
            continue

        _fix_srv_dns_records(domain, srv_dns_records_with_dot)
        _remove_duplicate_dns_records(domain)

        dump_dns_zone = domain.dns_zone
        dns_zone = UniqueDnsZoneAdapter(dump_dns_zone)

        source_ips = {
            subscription.converted_dump.ip,
            subscription.converted_dump.ipv6,
        }
        if domain.name in subscription_domain_names:
            raw_domain = subscription.raw_dump.get_domain(domain.name)
            source_ips.update({
                raw_domain.web_ips.v4,
                raw_domain.web_ips.v6
            })
        if subscription.mail_source_server is not None:
            source_mail_server_ip = subscription.mail_source_server.ip()
            if not is_empty(source_mail_server_ip):
                source_ips.add(source_mail_server_ip)

        target_mail_ips = _detect_target_mail_ips(target_web_ips, target_dns_template)

        mx_record_dests = [
            rec.dst.lower() for rec in dump_dns_zone.iter_dns_records() if rec.rec_type == 'MX'
        ]

        with logger.debug_level_context(messages.LOG_REPLACE_DNS_IPS, domain=domain.name):
            for rec in list(dump_dns_zone.iter_dns_records()):
                if rec.dst in source_ips:
                    dns_zone.remove_dns_record(rec)

                    if rec.src.lower() in mx_record_dests:  # A record which corresponds to MX record
                        target_ips = target_mail_ips
                    else:
                        target_ips = target_web_ips

                    for new_ip in target_ips:
                        rec_type = rec.rec_type
                        if rec_type in ('A', 'AAAA'):
                            if is_ipv4(new_ip):
                                rec_type = 'A'
                            elif is_ipv6(new_ip):
                                rec_type = 'AAAA'

                        dns_zone.add_dns_record(Rec(
                            rec_type=rec_type, src=rec.src,
                            dst=new_ip, opt=rec.opt
                        ))
                        logger.debug(messages.LOG_REPLACED_IP, rec, new_ip)
                elif rec.rec_type == 'TXT':
                    _replace_spf_record_ips(dns_zone, rec, subscription)
                elif rec.rec_type == 'PTR':
                    if is_ipv4(rec.src) and rec.src == subscription.converted_dump.ip:
                        dns_zone.remove_dns_record(rec)
                        if subscription.target_public_web_ipv4 is not None:
                            dns_zone.add_dns_record(Rec(
                                rec_type=rec.rec_type, src=subscription.target_public_web_ipv4,
                                dst=rec.dst, opt=rec.opt
                            ))
                    elif is_ipv6(rec.src) and rec.src == subscription.converted_dump.ipv6:
                        dns_zone.remove_dns_record(rec)
                        if subscription.target_public_web_ipv6 is not None:
                            dns_zone.add_dns_record(Rec(
                                rec_type=rec.rec_type, src=subscription.target_public_web_ipv6,
                                dst=rec.dst, opt=rec.opt
                            ))

        # Fix SRC value endings - each SRC should end by DNS zone's domain name, in the same
        # form as domain name.
        for rec in list(dump_dns_zone.iter_dns_records()):
            if rec.rec_type in ('A', 'AAAA', 'MX', 'TXT', 'CNAME', 'NS'):
                rec_src = _convert_subdomain_form(domain.name, rec.src)
                if rec_src != rec.src:
                    dns_zone.remove_dns_record(rec)
                    dns_zone.add_dns_record(Rec(
                        rec_type=rec.rec_type, src=rec_src,
                        dst=rec.dst, opt=rec.opt
                    ))

        # Fix DST value endings - if DST is subdomain of DNS zone's domain,
        # then it should end by DNS zone's domain name, in the same form as domain name.
        for rec in list(dump_dns_zone.iter_dns_records()):
            if rec.rec_type in ('MX', 'CNAME', 'NS', 'PTR'):
                rec_dst = _convert_subdomain_form(domain.name, rec.dst)
                if rec_dst != rec.dst:
                    dns_zone.remove_dns_record(rec)
                    dns_zone.add_dns_record(Rec(
                        rec_type=rec.rec_type, src=rec.src,
                        dst=rec_dst, opt=rec.opt
                    ))


def remove_caa_dns_records(subscription):
    for domain in itertools.chain(
        subscription.converted_dump.iter_domains(),
        subscription.converted_dump.iter_aliases()
    ):
        if domain.dns_zone is None:
            continue

        dump_dns_zone = domain.dns_zone
        dns_zone = UniqueDnsZoneAdapter(dump_dns_zone)

        with logger.debug_level_context(messages.LOG_REMOVE_CAA_RECORDS, domain=domain.name):
            for rec in list(dump_dns_zone.iter_dns_records()):
                if rec.rec_type == 'CAA':
                    dns_zone.remove_dns_record(rec)


def _detect_target_mail_ips(target_web_ips, target_dns_template):
    """
    :type target_web_ips: list[str | unicode]
    :type target_dns_template: parallels.core.hosting_repository.panel.DNSTemplate
    :rtype: list[str | unicode]
    """
    target_mail_ips = target_web_ips

    if target_dns_template is not None:
        mx_records = [rec for rec in target_dns_template.dns_records if rec.rec_type == 'MX']
        a_records = {rec.src.lower(): rec for rec in target_dns_template.dns_records if rec.rec_type in ('A', 'AAAA')}
        target_mail_templates = [a_records[rec.dst.lower()].dst for rec in mx_records if rec.dst in a_records]

        if all(is_ip(s) for s in target_mail_templates):
            target_mail_ips = target_mail_templates

    return target_mail_ips


def _convert_subdomain_form(domain, subdomain):
    # Subdomain already ends with domain name - leave it as is
    if (
        subdomain.endswith('.%s' % domain) or
        subdomain.endswith('.%s.' % domain) or
        subdomain == domain or
        subdomain == ('%s.' % domain)
    ):
        return subdomain

    # Convert subdomain name to the same form as domain name:
    # If domain name is in unicode, then subdomain name should be in unicode
    # If domain name is in punycode (IDNA), then subdomain name should be in punycode
    # Also, subdomain name should end with domain name considering case. For example, if
    # domain name is 'exAMPle.com', then SRC should be 'test.exAMPle.com', not 'test.example.com' and
    # not 'test.EXample.com'.

    if is_unicode_domain(domain):
        subdomain_fixed = safe_idn_decode(subdomain)
    else:
        subdomain_fixed = subdomain.encode('idna')

    if subdomain_fixed.lower() == domain.lower():
        return domain
    elif subdomain_fixed.lower() == '%s.' % domain.lower():
        return '%s.' % domain
    elif subdomain_fixed.lower().endswith('.%s' % domain.lower()):
        return '%s%s' % (subdomain_fixed[:-len(domain)], domain)
    elif subdomain_fixed.lower().endswith('.%s.' % domain.lower()):
        return '%s%s.' % (subdomain_fixed[:-(len(domain) + 1)], domain)
    else:
        # Subdomain is not actually a subdomain of domain , it is some another external domain; return it as is
        return subdomain


def _fix_srv_dns_records(domain, srv_dns_records_with_dot):
    for rec in list(domain.dns_zone.iter_dns_records()):
        if rec.rec_type != 'SRV':
            continue
        new_src = rec.src
        new_dst = rec.dst
        need_replace = False
        if srv_dns_records_with_dot:
            if not rec.src.endswith('.'):
                new_src = rec.src + '.'
                need_replace = True
            if not rec.dst.endswith('.'):
                new_dst = rec.dst + '.'
                need_replace = True
        else:
            if rec.src.endswith('.'):
                new_src = rec.src[:-1]
                need_replace = True
            if rec.dst.endswith('.'):
                new_dst = rec.dst[:-1]
                need_replace = True
        if need_replace:
            domain.dns_zone.remove_dns_record(rec)
            changed_record = Rec(rec_type=rec.rec_type, src=new_src, dst=new_dst, opt=rec.opt)
            domain.dns_zone.add_dns_record(changed_record)


def _remove_duplicate_dns_records(domain):
    """Remove duplicate DNS records from domain's DNS zone

    Duplicate DNS records are not allowed in Plesk, and either DNS conversion algorithm will fails,
    or Plesk will fail to provision these records.
    """
    records_unique = defaultdict(list)
    for rec in list(domain.dns_zone.iter_dns_records()):
        records_unique[Rec(rec_type=rec.rec_type, dst=rec.dst, src=rec.src, opt=rec.opt)].append(rec)
    for _, records in records_unique.items():
        # leave the first record only, remove all the others
        for record in records[1:]:
            # log to debug log, not to bother customer with such minor issue
            logger.fdebug(
                messages.DUPLICATE_DNS_RECORD_REMOVED, record=pretty_record_str(record), domain=domain.name
            )
            domain.dns_zone.remove_dns_record(record)


def _replace_spf_record_ips(dns_zone, rec, subscription):
    """Replace IP addresses in SPF records"""

    parts = split_string_by_whitespace_chars(rec.dst)
    parts_stripped = split_string_by_whitespace_chars(rec.dst.strip())
    new_parts = []
    if len(parts_stripped) > 0 and parts_stripped[0].lower() == 'v=spf1':
        for part in parts:
            if (
                subscription.converted_dump.ip is not None and
                _get_subscription_ipv4(subscription) is not None and
                part.lower() == 'ip4:%s' % subscription.converted_dump.ip
            ):
                new_parts.append('ip4:%s' % _get_subscription_ipv4(subscription))
            elif (
                subscription.converted_dump.ip is not None and
                _get_subscription_ipv4(subscription) is not None and
                part[1:].lower() == 'ip4:%s' % subscription.converted_dump.ip
            ):
                new_parts.append('%sip4:%s' % (part[0], _get_subscription_ipv4(subscription)))
            elif (
                subscription.converted_dump.ipv6 is not None and
                _get_subscription_ipv6(subscription) is not None and
                part.lower() == 'ip6:%s' % subscription.converted_dump.ipv6
            ):
                new_parts.append('ip6:%s' % _get_subscription_ipv6(subscription))
            elif (
                subscription.converted_dump.ipv6 is not None and
                _get_subscription_ipv6(subscription) is not None and
                part[1:].lower() == 'ip6:%s' % subscription.converted_dump.ipv6
            ):
                new_parts.append('%sip6:%s' % (part[0], _get_subscription_ipv6(subscription)))
            else:
                new_parts.append(part)

        dns_zone.remove_dns_record(rec)
        changed_record = Rec(rec_type=rec.rec_type, src=rec.src, dst=''.join(new_parts), opt=rec.opt)
        dns_zone.add_dns_record(changed_record)


class UniqueDnsZoneAdapter(object):
    """DNS zone adapter which makes changed DNS records unique.

    This class makes resulting DNS records unique: skip duplicates. Example:
    when there are 2 source web IPs, but only one target web IP. 
    """
    def __init__(self, dns_zone):
        self.dns_zone = dns_zone

        self.xml_recs = {
            self._create_rec(r): r
            for r in dns_zone.iter_dns_records()
        }

    def remove_dns_record(self, rec):
        rec = self._create_rec(rec)
        self.dns_zone.remove_dns_record(self.xml_recs[rec])
        del self.xml_recs[rec]

    def add_dns_record(self, rec):
        rec = self._create_rec(rec)
        if rec not in self.xml_recs:
            self.xml_recs[rec] = self.dns_zone.add_dns_record(rec)
        else:
            pass

    @staticmethod
    def _create_rec(xml_rec):
        return Rec(
            rec_type=xml_rec.rec_type, src=xml_rec.src, 
            dst=xml_rec.dst, opt=xml_rec.opt
        )


def pretty_record_str(rec):
    opt_str = u" %s" % (rec.opt,) if rec.opt is not None and rec.opt != "" else ""
    return u"%s %s%s %s" % (rec.src, rec.rec_type, opt_str, rec.dst)


def _get_subscription_ipv4(subscription):
    if subscription.target_public_web_ipv4 is not None:
        return subscription.target_public_web_ipv4
    else:
        # Subscription has no web hosting, try to take IP of mail hosting.
        # In most cases that should work fine.
        return subscription.target_public_mail_ipv4


def _get_subscription_ipv6(subscription):
    if subscription.target_public_web_ipv6 is not None:
        return subscription.target_public_web_ipv6
    elif subscription.target_public_web_ipv4 is None:
        # Subscription has no web hosting, try to take IP of mail hosting.
        # In most cases that should work fine.
        return subscription.target_public_mail_ipv6
    else:
        # Subscription has web hosting, but has no IPv6 - return None
        return None
