#
# Copyright (C) 2016  FreeIPA Contributors see COPYING for license
#

from __future__ import absolute_import

import logging

import six

from collections import defaultdict, OrderedDict
from dns import (
    rdata,
    rdataclass,
    rdatatype,
    zone,
)

from time import sleep, time

from ipalib import errors
from ipalib.dns import record_name_format
from ipapython.dnsutil import DNSName
from ipaserver.install import installutils

if six.PY3:
    unicode=str

logger = logging.getLogger(__name__)


IPA_DEFAULT_MASTER_SRV_REC = (
    # srv record name, port
    (DNSName('_ldap._tcp'), 389),
    (DNSName('_kerberos._tcp'), 88),
    (DNSName('_kerberos._udp'), 88),
    (DNSName('_kerberos-master._tcp'), 88),
    (DNSName('_kerberos-master._udp'), 88),
    (DNSName('_kpasswd._tcp'), 464),
    (DNSName('_kpasswd._udp'), 464),
)

IPA_DEFAULT_ADTRUST_SRV_REC = (
    # srv record name, port
    (DNSName('_ldap._tcp.Default-First-Site-Name._sites.dc._msdcs'), 389),
    (DNSName('_ldap._tcp.dc._msdcs'), 389),
    (DNSName('_kerberos._tcp.Default-First-Site-Name._sites.dc._msdcs'), 88),
    (DNSName('_kerberos._udp.Default-First-Site-Name._sites.dc._msdcs'), 88),
    (DNSName('_kerberos._tcp.dc._msdcs'), 88),
    (DNSName('_kerberos._udp.dc._msdcs'), 88),
)

IPA_DEFAULT_NTP_SRV_REC = (
    # srv record name, port
    (DNSName("_ntp._udp"), 123),
)

CA_RECORDS_DNS_TIMEOUT = 15  # timeout in seconds


class IPADomainIsNotManagedByIPAError(Exception):
    pass


class IPASystemRecords:

    # fixme do it configurable
    PRIORITY_HIGH = 0
    PRIORITY_LOW = 50

    def __init__(self, api_instance, all_servers=False):
        self.api_instance = api_instance
        self.domain_abs = DNSName(self.api_instance.env.domain).make_absolute()
        self.servers_data = OrderedDict()
        self.__init_data(all_servers=all_servers)

    def reload_data(self):
        """
        After any change made to IPA servers, this method must be called to
        update data in the object, otherwise invalid records may be
        created/updated
        """
        self.__init_data()

    def __get_server_attrs(self, server_result):
        weight = int(server_result.get('ipaserviceweight', ['100'])[0])
        location = server_result.get('ipalocation_location', [None])[0]
        roles = set(server_result.get('enabled_role_servrole', ()))

        return weight, location, roles

    def __get_location_suffix(self, location):
        return location + DNSName('_locations') + self.domain_abs

    def __init_data(self, all_servers=False):
        self.servers_data.clear()

        kwargs = dict(no_members=False)
        if not all_servers:
            # only active, fully installed masters]
            kwargs["servrole"] = "IPA master"
        servers = self.api_instance.Command.server_find(**kwargs)

        for s in servers['result']:
            weight, location, roles = self.__get_server_attrs(s)
            self.servers_data[s['cn'][0]] = {
                'weight': weight,
                'location': location,
                'roles': roles,
            }

    def __add_srv_records(
        self, zone_obj, hostname, rname_port_map,
        weight=100, priority=0, location=None
    ):
        assert isinstance(hostname, DNSName)
        assert isinstance(priority, int)
        assert isinstance(weight, int)

        if location:
            suffix = self.__get_location_suffix(location)
        else:
            suffix = self.domain_abs

        for name, port in rname_port_map:
            rd = rdata.from_text(
                rdataclass.IN, rdatatype.SRV,
                '{0} {1} {2} {3}'.format(
                    priority, weight, port, hostname.make_absolute()
                )
            )

            r_name = name.derelativize(suffix)

            rdataset = zone_obj.get_rdataset(
                r_name, rdatatype.SRV, create=True)
            rdataset.add(rd, ttl=86400)  # FIXME: use TTL from config

    def __add_ca_records_from_hostname(self, zone_obj, hostname):
        assert isinstance(hostname, DNSName) and hostname.is_absolute()
        r_name = DNSName('ipa-ca') + self.domain_abs
        rrsets = None
        end_time = time() + CA_RECORDS_DNS_TIMEOUT
        while True:
            try:
                # function logs errors
                rrsets = installutils.resolve_rrsets_nss(hostname)
            except OSError:
                # also retry on EAI_AGAIN, EAI_FAIL
                pass
            if rrsets:
                break
            if time() >= end_time:
                break
            sleep(3)

        if not rrsets:
            logger.error('unable to resolve host name %s to IP address, '
                         'ipa-ca DNS record will be incomplete', hostname)
            return

        for rrset in rrsets:
            for rd in rrset:
                rdataset = zone_obj.get_rdataset(
                    r_name, rd.rdtype, create=True)
                rdataset.add(rd, ttl=86400)  # FIXME: use TTL from config

    def __add_kerberos_txt_rec(self, zone_obj):
        # FIXME: with external DNS, this should generate records for all
        # realmdomains
        r_name = DNSName('_kerberos') + self.domain_abs
        rd = rdata.from_text(rdataclass.IN, rdatatype.TXT,
                             self.api_instance.env.realm)
        rdataset = zone_obj.get_rdataset(
            r_name, rdatatype.TXT, create=True
        )
        rdataset.add(rd, ttl=86400)  # FIXME: use TTL from config

    def _add_base_dns_records_for_server(
            self, zone_obj, hostname, roles=None, include_master_role=True,
            include_kerberos_realm=True,
    ):
        server = self.servers_data[hostname]
        if roles:
            eff_roles = server['roles'] & set(roles)
        else:
            eff_roles = server['roles']
        hostname_abs = DNSName(hostname).make_absolute()

        if include_kerberos_realm:
            self.__add_kerberos_txt_rec(zone_obj)

        # get master records
        if include_master_role:
            self.__add_srv_records(
                zone_obj,
                hostname_abs,
                IPA_DEFAULT_MASTER_SRV_REC,
                weight=server['weight']
            )

        if 'CA server' in eff_roles:
            self.__add_ca_records_from_hostname(zone_obj, hostname_abs)

        if 'AD trust controller' in eff_roles:
            self.__add_srv_records(
                zone_obj,
                hostname_abs,
                IPA_DEFAULT_ADTRUST_SRV_REC,
                weight=server['weight']
            )

        if 'NTP server' in eff_roles:
            self.__add_srv_records(
                zone_obj,
                hostname_abs,
                IPA_DEFAULT_NTP_SRV_REC,
                weight=server['weight']
            )

    def _get_location_dns_records_for_server(
            self, zone_obj, hostname, locations,
            roles=None, include_master_role=True):
        server = self.servers_data[hostname]
        if roles:
            eff_roles = server['roles'] & roles
        else:
            eff_roles = server['roles']
        hostname_abs = DNSName(hostname).make_absolute()

        # generate locations specific records
        for location in locations:
            if location == self.servers_data[hostname]['location']:
                priority = self.PRIORITY_HIGH
            else:
                priority = self.PRIORITY_LOW

            if include_master_role:
                self.__add_srv_records(
                    zone_obj,
                    hostname_abs,
                    IPA_DEFAULT_MASTER_SRV_REC,
                    weight=server['weight'],
                    priority=priority,
                    location=location
                )

            if 'AD trust controller' in eff_roles:
                self.__add_srv_records(
                    zone_obj,
                    hostname_abs,
                    IPA_DEFAULT_ADTRUST_SRV_REC,
                    weight=server['weight'],
                    priority=priority,
                    location=location
                )

            if 'NTP server' in eff_roles:
                self.__add_srv_records(
                    zone_obj,
                    hostname_abs,
                    IPA_DEFAULT_NTP_SRV_REC,
                    weight=server['weight'],
                    priority=priority,
                    location=location
                )

        return zone_obj

    def __prepare_records_update_dict(self, node):
        update_dict = defaultdict(list)
        for rdataset in node:
            for rdata in rdataset:
                option_name = (record_name_format % rdatatype.to_text(
                    rdata.rdtype).lower())
                update_dict[option_name].append(unicode(rdata.to_text()))
        return update_dict

    def __update_dns_records(
            self, record_name, nodes, set_cname_template=True
    ):
        update_dict = self.__prepare_records_update_dict(nodes)
        cname_template = {
            'addattr': ['objectclass=idnsTemplateObject'],
            'setattr': [
                r'idnsTemplateAttribute;cnamerecord=%s'
                r'.\{substitutionvariable_ipalocation\}._locations' %
                record_name.relativize(self.domain_abs)
            ]
        }
        try:
            if set_cname_template:
                # only srv records should have configured cname templates
                update_dict.update(cname_template)
            self.api_instance.Command.dnsrecord_mod(
                self.domain_abs, record_name,
                **update_dict
            )
        except errors.NotFound:
            # because internal API magic, addattr and setattr doesn't work with
            # dnsrecord-add well, use dnsrecord-mod instead later
            update_dict.pop('addattr', None)
            update_dict.pop('setattr', None)

            self.api_instance.Command.dnsrecord_add(
                self.domain_abs, record_name, **update_dict)

            if set_cname_template:
                try:
                    self.api_instance.Command.dnsrecord_mod(
                        self.domain_abs,
                        record_name, **cname_template)
                except errors.EmptyModlist:
                    pass
        except errors.EmptyModlist:
            pass

    def get_base_records(
            self, servers=None, roles=None, include_master_role=True,
            include_kerberos_realm=True
    ):
        """
        Generate IPA service records for specific servers and roles
        :param servers: list of server which will be used in records,
        if None all IPA servers will be used
        :param roles: roles for which DNS records will be generated,
        if None all roles will be used
        :param include_master_role: generate records required by IPA master
        role
        :return: dns.zone.Zone object that contains base DNS records
        """

        zone_obj = zone.Zone(self.domain_abs, relativize=False)
        if servers is None:
            servers = list(self.servers_data)

        for server in servers:
            self._add_base_dns_records_for_server(zone_obj, server,
                roles=roles, include_master_role=include_master_role,
                include_kerberos_realm=include_kerberos_realm
            )
        return zone_obj

    def get_locations_records(
            self, servers=None, roles=None, include_master_role=True):
        """
        Generate IPA location records for specific servers and roles.
        :param servers: list of server which will be used in records,
        if None all IPA servers will be used
        :param roles: roles for which DNS records will be generated,
        if None all roles will be used
        :param include_master_role: generate records required by IPA master
        role
        :return: dns.zone.Zone object that contains location DNS records
        """
        zone_obj = zone.Zone(self.domain_abs, relativize=False)
        if servers is None:
            servers = list(self.servers_data)

        locations_result = self.api_instance.Command.location_find()['result']
        locations = [l['idnsname'][0] for l in locations_result]

        for server in servers:
            self._get_location_dns_records_for_server(
                zone_obj, server,
                locations, roles=roles,
                include_master_role=include_master_role)
        return zone_obj

    def update_base_records(self):
        """
        Update base DNS records for IPA services
        :return: [(record_name, node), ...], [(record_name, node, error), ...]
        where the first list contains successfully updated records, and the
        second list contains failed updates with particular exceptions
        """
        fail = []
        success = []
        names_requiring_cname_templates = set(
            rec[0].derelativize(self.domain_abs) for rec in (
                IPA_DEFAULT_MASTER_SRV_REC +
                IPA_DEFAULT_ADTRUST_SRV_REC +
                IPA_DEFAULT_NTP_SRV_REC
            )
        )

        base_zone = self.get_base_records()
        for record_name, node in base_zone.items():
            set_cname_template = record_name in names_requiring_cname_templates
            try:
                self.__update_dns_records(
                    record_name, node, set_cname_template)
            except errors.PublicError as e:
                fail.append((record_name, node, e))
            else:
                success.append((record_name, node))
        return success, fail

    def update_locations_records(self):
        """
        Update locations DNS records for IPA services
        :return: [(record_name, node), ...], [(record_name, node, error), ...]
        where the first list contains successfully updated records, and the
        second list contains failed updates with particular exceptions
        """
        fail = []
        success = []

        location_zone = self.get_locations_records()
        for record_name, nodes in location_zone.items():
            try:
                self.__update_dns_records(
                    record_name, nodes,
                    set_cname_template=False)
            except errors.PublicError as e:
                fail.append((record_name, nodes, e))
            else:
                success.append((record_name, nodes))
        return success, fail

    def update_dns_records(self):
        """
        Update all IPA DNS records
        :return: (sucessfully_updated_base_records, failed_base_records,
        sucessfully_updated_locations_records, failed_locations_records)
        For format see update_base_records or update_locations_method
        :raise IPADomainIsNotManagedByIPAError: if IPA domain is not managed by
        IPA DNS
        """
        try:
            self.api_instance.Command.dnszone_show(self.domain_abs)
        except errors.NotFound:
            raise IPADomainIsNotManagedByIPAError()

        return (
            self.update_base_records(),
            self.update_locations_records()
        )

    def remove_location_records(self, location):
        """
        Remove all location records
        :param location: DNSName object
        :return: list of successfuly removed record names, list of record
        names that cannot be removed and returned exception in tuples
        [rname1, ...], [(rname2, exc), ...]
        """
        success = []
        failed = []

        location = DNSName(location)
        loc_records = []
        for records in (
                IPA_DEFAULT_MASTER_SRV_REC,
                IPA_DEFAULT_ADTRUST_SRV_REC,
                IPA_DEFAULT_NTP_SRV_REC
        ):
            for name, _port in records:
                loc_records.append(
                    name + self.__get_location_suffix(location))

        for rname in loc_records:
            try:
                self.api_instance.Command.dnsrecord_del(
                    self.domain_abs, rname, del_all=True)
            except errors.NotFound:
                pass
            except errors.PublicError as e:
                failed.append((rname, e))
            else:
                success.append(rname)
        return success, failed


    @classmethod
    def records_list_from_node(cls, name, node):
        records = []
        for rdataset in node:
            for rd in rdataset:
                records.append(
                    '{name} {ttl} {rdclass} {rdtype} {rdata}'.format(
                        name=name.ToASCII(),
                        ttl=rdataset.ttl,
                        rdclass=rdataclass.to_text(rd.rdclass),
                        rdtype=rdatatype.to_text(rd.rdtype),
                        rdata=rd.to_text()
                    )
                )
        return records

    @classmethod
    def records_list_from_zone(cls, zone_obj, sort=True):
        records = []
        for name, node in zone_obj.items():
            records.extend(IPASystemRecords.records_list_from_node(name, node))
        if sort:
            records.sort()
        return records
