#
#
#

from __future__ import absolute_import, division, print_function, \
    unicode_literals

from collections import defaultdict

from azure.identity import ClientSecretCredential
from azure.common.credentials import ServicePrincipalCredentials
from azure.mgmt.dns import DnsManagementClient
from azure.mgmt.trafficmanager import TrafficManagerManagementClient

from azure.mgmt.dns.models import ARecord, AaaaRecord, CaaRecord, \
    CnameRecord, MxRecord, SrvRecord, NsRecord, PtrRecord, TxtRecord, Zone
from azure.mgmt.trafficmanager.models import Profile, DnsConfig, \
    MonitorConfig, Endpoint, MonitorConfigCustomHeadersItem

import logging
from functools import reduce
from ..record import Record, Update, GeoCodes
from .base import BaseProvider


class AzureException(Exception):
    pass


def escape_semicolon(s):
    assert s
    return s.replace(';', '\\;')


def unescape_semicolon(s):
    assert s
    return s.replace('\\;', ';')


def azure_chunked_value(val):
    CHUNK_SIZE = 255
    val_replace = val.replace('"', '\\"')
    value = unescape_semicolon(val_replace)
    if len(val) > CHUNK_SIZE:
        vs = [value[i:i + CHUNK_SIZE]
              for i in range(0, len(value), CHUNK_SIZE)]
    else:
        vs = value
    return vs


def azure_chunked_values(s):
    values = []
    for v in s:
        values.append(azure_chunked_value(v))
    return values


class _AzureRecord(object):
    '''Wrapper for OctoDNS record for AzureProvider to make dns_client calls.

        azuredns.py:
        class: octodns.provider.azuredns._AzureRecord
        An _AzureRecord is easily accessible to Azure DNS Management library
        functions and is used to wrap all relevant data to create a record in
        Azure.
    '''
    TYPE_MAP = {
        'A': ARecord,
        'AAAA': AaaaRecord,
        'CAA': CaaRecord,
        'CNAME': CnameRecord,
        'MX': MxRecord,
        'SRV': SrvRecord,
        'NS': NsRecord,
        'PTR': PtrRecord,
        'TXT': TxtRecord
    }

    def __init__(self, resource_group, record, delete=False,
                 traffic_manager=None):
        '''Constructor for _AzureRecord.

            Notes on Azure records: An Azure record set has the form
            RecordSet(name=<...>, type=<...>, a_records=[...],
            aaaa_records=[...], ...)
            When constructing an azure record as done in self._apply_Create,
            the argument parameters for an A record would be
            parameters={'ttl': <int>, 'a_records': [ARecord(<str ip>),]}.
            As another example for CNAME record:
            parameters={'ttl': <int>, 'cname_record': CnameRecord(<str>)}.

            Below, key_name and class_name are the dictionary key and Azure
            Record class respectively.

            :param resource_group: The name of resource group in Azure
            :type  resource_group: str
            :param record: An OctoDNS record
            :type  record: ..record.Record
            :param delete: If true, omit data parsing; not needed to delete
            :type  delete: bool

            :type return: _AzureRecord
        '''
        self.log = logging.getLogger('AzureRecord')

        self.resource_group = resource_group
        self.zone_name = record.zone.name[:-1]
        self.relative_record_set_name = record.name or '@'
        self.record_type = record._type
        self._record = record
        self.traffic_manager = traffic_manager

        if delete:
            return

        # Refer to function docstring for key_name and class_name.
        key_name = '{}_records'.format(self.record_type).lower()
        if record._type == 'CNAME':
            key_name = key_name[:-1]
        azure_class = self.TYPE_MAP[self.record_type]

        params_for = getattr(self, '_params_for_{}'.format(record._type))
        self.params = params_for(record.data, key_name, azure_class)
        self.params['ttl'] = record.ttl

    def _params_for_A(self, data, key_name, azure_class):
        if self._record.dynamic and self.traffic_manager:
            return {'target_resource': self.traffic_manager}

        try:
            values = data['values']
        except KeyError:
            values = [data['value']]
        return {key_name: [azure_class(ipv4_address=v) for v in values]}

    def _params_for_AAAA(self, data, key_name, azure_class):
        if self._record.dynamic and self.traffic_manager:
            return {'target_resource': self.traffic_manager}

        try:
            values = data['values']
        except KeyError:
            values = [data['value']]
        return {key_name: [azure_class(ipv6_address=v) for v in values]}

    def _params_for_CAA(self, data, key_name, azure_class):
        params = []
        if 'values' in data:
            for vals in data['values']:
                params.append(azure_class(flags=vals['flags'],
                                          tag=vals['tag'],
                                          value=vals['value']))
        else:  # Else there is a singular data point keyed by 'value'.
            params.append(azure_class(flags=data['value']['flags'],
                                      tag=data['value']['tag'],
                                      value=data['value']['value']))
        return {key_name: params}

    def _params_for_CNAME(self, data, key_name, azure_class):
        if self._record.dynamic and self.traffic_manager:
            return {'target_resource': self.traffic_manager}

        return {key_name: azure_class(cname=data['value'])}

    def _params_for_MX(self, data, key_name, azure_class):
        params = []
        if 'values' in data:
            for vals in data['values']:
                params.append(azure_class(preference=vals['preference'],
                                          exchange=vals['exchange']))
        else:  # Else there is a singular data point keyed by 'value'.
            params.append(azure_class(preference=data['value']['preference'],
                                      exchange=data['value']['exchange']))
        return {key_name: params}

    def _params_for_SRV(self, data, key_name, azure_class):
        params = []
        if 'values' in data:
            for vals in data['values']:
                params.append(azure_class(priority=vals['priority'],
                                          weight=vals['weight'],
                                          port=vals['port'],
                                          target=vals['target']))
        else:  # Else there is a singular data point keyed by 'value'.
            params.append(azure_class(priority=data['value']['priority'],
                                      weight=data['value']['weight'],
                                      port=data['value']['port'],
                                      target=data['value']['target']))
        return {key_name: params}

    def _params_for_NS(self, data, key_name, azure_class):
        try:
            values = data['values']
        except KeyError:
            values = [data['value']]
        return {key_name: [azure_class(nsdname=v) for v in values]}

    def _params_for_PTR(self, data, key_name, azure_class):
        try:
            values = data['values']
        except KeyError:
            values = [data['value']]
        return {key_name: [azure_class(ptrdname=v) for v in values]}

    def _params_for_TXT(self, data, key_name, azure_class):

        params = []
        try:  # API for TxtRecord has list of str, even for singleton
            values = [v for v in azure_chunked_values(data['values'])]
        except KeyError:
            values = [azure_chunked_value(data['value'])]

        for v in values:
            if isinstance(v, list):
                params.append(azure_class(value=v))
            else:
                params.append(azure_class(value=[v]))
        return {key_name: params}

    def _equals(self, b):
        '''Checks whether two records are equal by comparing all fields.
            :param b: Another _AzureRecord object
            :type  b: _AzureRecord

            :type return: bool
        '''

        def key_dict(d):
            return sum([hash('{}:{}'.format(k, v)) for k, v in d.items()])

        def parse_dict(params):
            vals = []
            for char in params:
                if char != 'ttl':
                    list_records = params[char]
                    try:
                        for record in list_records:
                            vals.append(record.__dict__)
                    except:
                        vals.append(list_records.__dict__)
            vals.sort(key=key_dict)
            return vals

        return (self.resource_group == b.resource_group) & \
               (self.zone_name == b.zone_name) & \
               (self.record_type == b.record_type) & \
               (self.params['ttl'] == b.params['ttl']) & \
               (parse_dict(self.params) == parse_dict(b.params)) & \
               (self.relative_record_set_name == b.relative_record_set_name)


def _check_endswith_dot(string):
    return string if string.endswith('.') else string + '.'


def _parse_azure_type(string):
    '''Converts string representing an Azure RecordSet type to usual type.

        :param string: the Azure type. eg: <Microsoft.Network/dnszones/A>
        :type  string: str

        :type return: str
    '''
    return string.split('/')[-1]


def _root_traffic_manager_name(record):
    # ATM names can only have letters, numbers and hyphens
    # replace dots with double hyphens to ensure unique mapping,
    # hoping that real life FQDNs won't have double hyphens
    name = record.fqdn[:-1].replace('.', '--')
    if record._type != 'CNAME':
        name += '-{}'.format(record._type)
    return name


def _rule_traffic_manager_name(pool, record):
    prefix = _root_traffic_manager_name(record)
    return '{}-rule-{}'.format(prefix, pool)


def _pool_traffic_manager_name(pool, record):
    prefix = _root_traffic_manager_name(record)
    return '{}-pool-{}'.format(prefix, pool)


def _get_monitor(record):
    monitor = MonitorConfig(
        protocol=record.healthcheck_protocol,
        port=record.healthcheck_port,
        path=record.healthcheck_path,
    )
    host = record.healthcheck_host()
    if host:
        monitor.custom_headers = [MonitorConfigCustomHeadersItem(
            name='Host', value=host
        )]
    return monitor


def _check_valid_dynamic(record):
    typ = record._type
    if typ in ['A', 'AAAA']:
        defaults = set(record.values)
        if len(defaults) > 1:
            pools = record.dynamic.pools
            vals = set(
                v['value']
                for _, pool in pools.items()
                for v in pool._data()['values']
            )
            if defaults != vals:
                # we don't yet support multi-value defaults, specifying all
                # pool values allows for Traffic Manager profile optimization
                msg = ('{} {}: Values of A/AAAA dynamic records must either '
                       'have a single value or contain all values from all '
                       'pools')
                raise AzureException(msg.format(record.fqdn, record._type))
    elif typ != 'CNAME':
        # dynamic records of unsupported type
        msg = '{}: Dynamic records in Azure must be of type A/AAAA/CNAME'
        raise AzureException(msg.format(record.fqdn))


def _profile_is_match(have, desired):
    if have is None or desired is None:
        return False

    log = logging.getLogger('azuredns._profile_is_match').debug

    def false(have, desired, name=None):
        prefix = 'profile={}'.format(name) if name else ''
        attr = have.__class__.__name__
        log('%s have.%s = %s', prefix, attr, have)
        log('%s desired.%s = %s', prefix, attr, desired)
        return False

    # compare basic attributes
    if have.name != desired.name or \
       have.traffic_routing_method != desired.traffic_routing_method or \
       len(have.endpoints) != len(desired.endpoints):
        return false(have, desired)

    # compare dns config
    dns_have = have.dns_config
    dns_desired = desired.dns_config
    if dns_have.ttl != dns_desired.ttl or \
       dns_have.relative_name is None or \
       dns_desired.relative_name is None or \
       dns_have.relative_name != dns_desired.relative_name:
        return false(dns_have, dns_desired, have.name)

    # compare monitoring configuration
    monitor_have = have.monitor_config
    monitor_desired = desired.monitor_config
    if monitor_have.protocol != monitor_desired.protocol or \
       monitor_have.port != monitor_desired.port or \
       monitor_have.path != monitor_desired.path or \
       monitor_have.custom_headers != monitor_desired.custom_headers:
        return false(monitor_have, monitor_desired, have.name)

    # compare endpoints
    method = have.traffic_routing_method
    if method == 'Priority':
        have_endpoints = sorted(have.endpoints, key=lambda e: e.priority)
        desired_endpoints = sorted(desired.endpoints,
                                   key=lambda e: e.priority)
    elif method == 'Weighted':
        have_endpoints = sorted(have.endpoints, key=lambda e: e.target)
        desired_endpoints = sorted(desired.endpoints, key=lambda e: e.target)
    else:
        have_endpoints = have.endpoints
        desired_endpoints = desired.endpoints
    endpoints = zip(have_endpoints, desired_endpoints)
    for have_endpoint, desired_endpoint in endpoints:
        if have_endpoint.name != desired_endpoint.name or \
           have_endpoint.type != desired_endpoint.type:
            return false(have_endpoint, desired_endpoint, have.name)
        target_type = have_endpoint.type.split('/')[-1]
        if target_type == 'externalEndpoints':
            # compare value, weight, priority
            if have_endpoint.target != desired_endpoint.target:
                return false(have_endpoint, desired_endpoint, have.name)
            if method == 'Weighted' and \
               have_endpoint.weight != desired_endpoint.weight:
                return false(have_endpoint, desired_endpoint, have.name)
        elif target_type == 'nestedEndpoints':
            # compare targets
            if have_endpoint.target_resource_id != \
               desired_endpoint.target_resource_id:
                return false(have_endpoint, desired_endpoint, have.name)
            # compare geos
            if method == 'Geographic':
                have_geos = sorted(have_endpoint.geo_mapping)
                desired_geos = sorted(desired_endpoint.geo_mapping)
                if have_geos != desired_geos:
                    return false(have_endpoint, desired_endpoint, have.name)
        else:
            # unexpected, give up
            return False

    return True


class AzureProvider(BaseProvider):
    '''
    Azure DNS Provider

    azuredns.py:
        class: octodns.provider.azuredns.AzureProvider
        # Current support of authentication of access to Azure services only
        # includes using a Service Principal:
        # https://docs.microsoft.com/en-us/azure/azure-resource-manager/
        #                        resource-group-create-service-principal-portal
        # The Azure Active Directory Application ID (aka client ID):
        client_id:
        # Authentication Key Value: (note this should be secret)
        key:
        # Directory ID (aka tenant ID):
        directory_id:
        # Subscription ID:
        sub_id:
        # Resource Group name:
        resource_group:
        # All are required to authenticate.

        Example config file with variables:
            "
            ---
            providers:
              config:
                class: octodns.provider.yaml.YamlProvider
                directory: ./config (example path to directory of zone files)
              azuredns:
                class: octodns.provider.azuredns.AzureProvider
                client_id: env/AZURE_APPLICATION_ID
                key: env/AZURE_AUTHENTICATION_KEY
                directory_id: env/AZURE_DIRECTORY_ID
                sub_id: env/AZURE_SUBSCRIPTION_ID
                resource_group: 'TestResource1'

            zones:
              example.com.:
                sources:
                  - config
                targets:
                  - azuredns
            "
        The first four variables above can be hidden in environment variables
        and octoDNS will automatically search for them in the shell. It is
        possible to also hard-code into the config file: eg, resource_group.

        Please read https://github.com/octodns/octodns/pull/706 for an overview
        of how dynamic records are designed and caveats of using them.
    '''
    SUPPORTS_GEO = False
    SUPPORTS_DYNAMIC = True
    SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'PTR', 'SRV',
                    'TXT'))

    def __init__(self, id, client_id, key, directory_id, sub_id,
                 resource_group, *args, **kwargs):
        self.log = logging.getLogger('AzureProvider[{}]'.format(id))
        self.log.debug('__init__: id=%s, client_id=%s, '
                       'key=***, directory_id:%s', id, client_id, directory_id)
        super(AzureProvider, self).__init__(id, *args, **kwargs)

        # Store necessary initialization params
        self._dns_client_handle = None
        self._dns_client_client_id = client_id
        self._dns_client_key = key
        self._dns_client_directory_id = directory_id
        self._dns_client_subscription_id = sub_id
        self.__dns_client = None
        self.__tm_client = None

        self._resource_group = resource_group
        self._azure_zones = set()
        self._traffic_managers = dict()

    @property
    def _dns_client(self):
        if self.__dns_client is None:
            # Azure's logger spits out a lot of debug messages at 'INFO'
            # level, override it by re-assigning `info` method to `debug`
            # (ugly hack until I find a better way)
            logger_name = 'azure.core.pipeline.policies.http_logging_policy'
            logger = logging.getLogger(logger_name)
            logger.info = logger.debug
            self.__dns_client = DnsManagementClient(
                credential=ClientSecretCredential(
                    client_id=self._dns_client_client_id,
                    client_secret=self._dns_client_key,
                    tenant_id=self._dns_client_directory_id,
                    logger=logger,
                ),
                subscription_id=self._dns_client_subscription_id,
            )
        return self.__dns_client

    @property
    def _tm_client(self):
        if self.__tm_client is None:
            self.__tm_client = TrafficManagerManagementClient(
                ServicePrincipalCredentials(
                    self._dns_client_client_id,
                    secret=self._dns_client_key,
                    tenant=self._dns_client_directory_id,
                ),
                self._dns_client_subscription_id,
            )
        return self.__tm_client

    def _populate_zones(self):
        self.log.debug('azure_zones: loading')
        list_zones = self._dns_client.zones.list_by_resource_group
        for zone in list_zones(self._resource_group):
            self._azure_zones.add(zone.name.rstrip('.'))

    def _check_zone(self, name, create=False):
        '''Checks whether a zone specified in a source exist in Azure server.

            Note that Azure zones omit end '.' eg: contoso.com vs contoso.com.
            Returns the name if it exists.

            :param name: Name of a zone to checks
            :type  name: str
            :param create: If True, creates the zone of that name.
            :type  create: bool

            :type return: str or None
        '''
        self.log.debug('_check_zone: name=%s create=%s', name, create)
        # Check if the zone already exists in our set
        if name in self._azure_zones:
            return name
        # If not, and its time to create, lets do it.
        if create:
            self.log.debug('_check_zone:no matching zone; creating %s', name)
            create_zone = self._dns_client.zones.create_or_update
            create_zone(self._resource_group, name, Zone(location='global'))
            self._azure_zones.add(name)
            return name
        else:
            # Else return nothing (aka false)
            return

    def _populate_traffic_managers(self):
        self.log.debug('traffic managers: loading')
        list_profiles = self._tm_client.profiles.list_by_resource_group
        for profile in list_profiles(self._resource_group):
            self._traffic_managers[profile.id] = profile
        # link nested profiles in advance for convenience
        for _, profile in self._traffic_managers.items():
            self._populate_nested_profiles(profile)

    def _populate_nested_profiles(self, profile):
        for ep in profile.endpoints:
            target_id = ep.target_resource_id
            if target_id and target_id in self._traffic_managers:
                target = self._traffic_managers[target_id]
                ep.target_resource = self._populate_nested_profiles(target)
        return profile

    def _get_tm_profile_by_id(self, resource_id):
        if not self._traffic_managers:
            self._populate_traffic_managers()
        return self._traffic_managers.get(resource_id)

    def _profile_name_to_id(self, name):
        return '/subscriptions/' + self._dns_client_subscription_id + \
            '/resourceGroups/' + self._resource_group + \
            '/providers/Microsoft.Network/trafficManagerProfiles/' + \
            name

    def _get_tm_profile_by_name(self, name):
        profile_id = self._profile_name_to_id(name)
        return self._get_tm_profile_by_id(profile_id)

    def _get_tm_for_dynamic_record(self, record):
        name = _root_traffic_manager_name(record)
        return self._get_tm_profile_by_name(name)

    def populate(self, zone, target=False, lenient=False):
        '''Required function of manager.py to collect records from zone.

            Special notes for Azure.
            Azure zone names omit final '.'
            Azure root records names are represented by '@'. OctoDNS uses ''
            Azure records created through online interface may have null values
            (eg, no IP address for A record).
            Azure online interface allows constructing records with null values
            which are destroyed by _apply.

            Specific quirks such as these are responsible for any non-obvious
            parsing in this function and the functions '_params_for_*'.

            :param zone: A dns zone
            :type  zone: octodns.zone.Zone
            :param target: Checks if Azure is source or target of config.
                           Currently only supports as a target. Unused.
            :type  target: bool
            :param lenient: Unused. Check octodns.manager for usage.
            :type  lenient: bool

            :type return: void
        '''
        self.log.debug('populate: name=%s', zone.name)

        exists = False
        before = len(zone.records)

        zone_name = zone.name[:-1]
        self._populate_zones()

        records = self._dns_client.record_sets.list_by_dns_zone
        if self._check_zone(zone_name):
            exists = True
            for azrecord in records(self._resource_group, zone_name):
                typ = _parse_azure_type(azrecord.type)
                if typ not in self.SUPPORTS:
                    continue

                record = self._populate_record(zone, azrecord, lenient)
                zone.add_record(record, lenient=lenient)

        self.log.info('populate: found %s records, exists=%s',
                      len(zone.records) - before, exists)
        return exists

    def _populate_record(self, zone, azrecord, lenient=False):
        record_name = azrecord.name if azrecord.name != '@' else ''
        typ = _parse_azure_type(azrecord.type)

        data_for = getattr(self, '_data_for_{}'.format(typ))
        data = data_for(azrecord)
        data['type'] = typ
        data['ttl'] = azrecord.ttl
        return Record.new(zone, record_name, data, source=self,
                          lenient=lenient)

    def _data_for_A(self, azrecord):
        if azrecord.a_records is None:
            if azrecord.target_resource.id:
                return self._data_for_dynamic(azrecord)
            else:
                # dynamic record alias is broken, return dummy value and apply
                # will likely overwrite/fix it
                self.log.warn('_data_for_A: Missing Traffic Manager '
                              'alias for dynamic A record %s, forcing '
                              're-link by setting an invalid value',
                              azrecord.fqdn)
                return {'values': ['255.255.255.255']}

        return {'values': [ar.ipv4_address for ar in azrecord.a_records]}

    def _data_for_AAAA(self, azrecord):
        if azrecord.aaaa_records is None:
            if azrecord.target_resource.id:
                return self._data_for_dynamic(azrecord)
            else:
                # dynamic record alias is broken, return dummy value and apply
                # will likely overwrite/fix it
                self.log.warn('_data_for_AAAA: Missing Traffic Manager '
                              'alias for dynamic AAAA record %s, forcing '
                              're-link by setting an invalid value',
                              azrecord.fqdn)
                return {'values': ['::1']}

        return {'values': [ar.ipv6_address for ar in azrecord.aaaa_records]}

    def _data_for_CAA(self, azrecord):
        return {'values': [{'flags': ar.flags,
                            'tag': ar.tag,
                            'value': ar.value}
                           for ar in azrecord.caa_records]}

    def _data_for_CNAME(self, azrecord):
        '''Parsing data from Azure DNS Client record call
            :param azrecord: a return of a call to list azure records
            :type  azrecord: azure.mgmt.dns.models.RecordSet

            :type  return: dict
        '''
        if azrecord.cname_record is None:
            if azrecord.target_resource.id:
                return self._data_for_dynamic(azrecord)
            else:
                # dynamic record alias is broken, return dummy value and apply
                # will likely overwrite/fix it
                self.log.warn('_data_for_CNAME: Missing Traffic Manager '
                              'alias for dynamic CNAME record %s, forcing '
                              're-link by setting an invalid value',
                              azrecord.fqdn)
                return {'value': 'iam.invalid.'}

        return {'value': _check_endswith_dot(azrecord.cname_record.cname)}

    def _data_for_MX(self, azrecord):
        return {'values': [{'preference': ar.preference,
                            'exchange': ar.exchange}
                           for ar in azrecord.mx_records]}

    def _data_for_NS(self, azrecord):
        vals = [ar.nsdname for ar in azrecord.ns_records]
        return {'values': [_check_endswith_dot(val) for val in vals]}

    def _data_for_PTR(self, azrecord):
        ptrdname = azrecord.ptr_records[0].ptrdname
        return {'value': _check_endswith_dot(ptrdname)}

    def _data_for_SRV(self, azrecord):
        return {'values': [{'priority': ar.priority, 'weight': ar.weight,
                            'port': ar.port, 'target': ar.target}
                           for ar in azrecord.srv_records]}

    def _data_for_TXT(self, azrecord):
        return {'values': [escape_semicolon(reduce((lambda a, b: a + b),
                                                   ar.value))
                           for ar in azrecord.txt_records]}

    def _data_for_dynamic(self, azrecord):
        default = set()
        pools = defaultdict(lambda: {'fallback': None, 'values': []})
        rules = []
        typ = _parse_azure_type(azrecord.type)

        # top level profile
        root_profile = self._get_tm_profile_by_id(azrecord.target_resource.id)
        if root_profile.traffic_routing_method != 'Geographic':
            # This record does not use geo fencing, so we skip the Geographic
            # profile hop; let's pretend to be a geo-profile's only endpoint
            geo_ep = Endpoint(
                name=root_profile.endpoints[0].name.split('--', 1)[0],
                target_resource_id=root_profile.id
            )
            geo_ep.target_resource = root_profile
            endpoints = [geo_ep]
        else:
            endpoints = root_profile.endpoints

        for geo_ep in endpoints:
            rule = {}

            # resolve list of regions
            geo_map = list(geo_ep.geo_mapping or [])
            if geo_map and geo_map != ['WORLD']:
                if 'GEO-ME' in geo_map:
                    # Azure treats Middle East as a separate group, but
                    # its part of Asia in octoDNS, so we need to remove GEO-ME
                    # if GEO-AS is also in the list
                    # Throw exception otherwise, it should not happen if the
                    # profile was generated by octoDNS
                    if 'GEO-AS' not in geo_map:
                        msg = 'Profile={} for record {}: '.format(
                            root_profile.name, azrecord.fqdn)
                        msg += 'Middle East (GEO-ME) is not supported by ' + \
                               'octoDNS. It needs to be either paired ' + \
                               'with Asia (GEO-AS) or expanded into ' + \
                               'individual list of countries.'
                        raise AzureException(msg)
                    geo_map.remove('GEO-ME')
                geos = rule.setdefault('geos', [])
                for code in geo_map:
                    if code.startswith('GEO-'):
                        # continent
                        if code == 'GEO-AP':
                            # Azure uses Australia/Pacific (AP) instead of
                            # Oceania https://docs.microsoft.com/en-us/azure/
                            #                                traffic-manager/
                            #              traffic-manager-geographic-regions
                            geos.append('OC')
                        else:
                            geos.append(code[len('GEO-'):])
                    elif '-' in code:
                        # state
                        country, province = code.split('-', 1)
                        country = GeoCodes.country_to_code(country)
                        geos.append('{}-{}'.format(country, province))
                    elif code == 'WORLD':
                        geos.append(code)
                    else:
                        # country
                        geos.append(GeoCodes.country_to_code(code))

            # build fallback chain from second level priority profile
            if geo_ep.target_resource_id and \
               geo_ep.target_resource.traffic_routing_method == 'Priority':
                rule_endpoints = geo_ep.target_resource.endpoints
                rule_endpoints.sort(key=lambda e: e.priority)
            else:
                # this geo directly points to a pool containing the default
                # so we skip the Priority profile hop and directly use an
                # external endpoint or Weighted profile
                # let's pretend to be a Priority profile's only endpoint
                rule_endpoints = [geo_ep]

            pool = None
            for rule_ep in rule_endpoints:
                pool_name = rule_ep.name

                # last/default pool
                if pool_name.endswith('--default--'):
                    default.add(rule_ep.target)
                    if pool_name == '--default--':
                        # this should be the last one, so let's break here
                        break
                    # last pool is a single value pool and its value is same
                    # as record's default value
                    pool_name = pool_name[:-len('--default--')]

                # set first priority endpoint as the rule's primary pool
                if 'pool' not in rule:
                    rule['pool'] = pool_name

                if pool:
                    # set current pool as fallback of the previous pool
                    pool['fallback'] = pool_name

                if pool_name in pools:
                    # we've already populated this and subsequent pools
                    break

                # populate the pool from Weighted profile
                # these should be leaf node entries with no further nesting
                pool = pools[pool_name]
                endpoints = []

                if rule_ep.target_resource_id:
                    # third (and last) level weighted RR profile
                    endpoints = rule_ep.target_resource.endpoints
                else:
                    # single-value pool, so we skip the Weighted profile hop
                    # and directly use an external endpoint; let's pretend to
                    # be a Weighted profile's only endpoint
                    endpoints = [rule_ep]

                for pool_ep in endpoints:
                    val = pool_ep.target
                    if typ == 'CNAME':
                        val = _check_endswith_dot(val)
                    pool['values'].append({
                        'value': val,
                        'weight': pool_ep.weight or 1,
                    })
                    if pool_ep.name.endswith('--default--'):
                        default.add(val)

            rules.append(rule)

        # add separate rule for re-used world pool
        for rule in list(rules):
            geos = rule.get('geos', [])
            if len(geos) > 1 and 'WORLD' in geos:
                geos.remove('WORLD')
                rules.append({'pool': rule['pool']})

        # Order and convert to a list
        default = sorted(default)

        data = {
            'dynamic': {
                'pools': pools,
                'rules': rules,
            },
        }

        if typ == 'CNAME':
            data['value'] = _check_endswith_dot(default[0])
        else:
            data['values'] = default

        return data

    def _extra_changes(self, existing, desired, changes):
        changed = set(c.record for c in changes)

        log = self.log.info
        seen_profiles = {}
        extra = []
        for record in desired.records:
            if not getattr(record, 'dynamic', False):
                # Already changed, or not dynamic, no need to check it
                continue

            # Abort if there are unsupported dynamic record configurations
            _check_valid_dynamic(record)

            # let's walk through and show what will be changed even if
            # the record is already in list of changes
            added = (record in changed)

            active = set()
            profiles = self._generate_traffic_managers(record)

            for profile in profiles:
                name = profile.name

                endpoints = set()
                for ep in profile.endpoints:
                    if not ep.target:
                        continue
                    if ep.target in endpoints:
                        msg = '{} contains duplicate endpoint {}'
                        raise AzureException(msg.format(name, ep.target))
                    endpoints.add(ep.target)

                if name in seen_profiles:
                    # exit if a possible collision is detected, even though
                    # we've tried to ensure unique mapping
                    msg = 'Collision in Traffic Manager names detected'
                    msg = '{}: {} and {} both want to use {}'.format(
                        msg, seen_profiles[name], record.fqdn, name)
                    raise AzureException(msg)
                else:
                    seen_profiles[name] = record.fqdn

                active.add(name)
                existing_profile = self._get_tm_profile_by_name(name)
                if not _profile_is_match(existing_profile, profile):
                    log('_extra_changes: Profile name=%s will be synced',
                        name)
                    if not added:
                        extra.append(Update(record, record))
                        added = True

            existing_profiles = self._find_traffic_managers(record)
            for name in existing_profiles - active:
                log('_extra_changes: Profile name=%s will be destroyed', name)
                if not added:
                    extra.append(Update(record, record))
                    added = True

        return extra

    def _generate_tm_profile(self, routing, endpoints, record, label=None):
        # figure out profile name and Traffic Manager FQDN
        name = _root_traffic_manager_name(record)
        if routing == 'Weighted' and label:
            name = _pool_traffic_manager_name(label, record)
        elif routing == 'Priority' and label:
            name = _rule_traffic_manager_name(label, record)

        # set appropriate endpoint types
        endpoint_type_prefix = 'Microsoft.Network/trafficManagerProfiles/'
        for ep in endpoints:
            if ep.target_resource_id:
                ep.type = endpoint_type_prefix + 'nestedEndpoints'
            elif ep.target:
                ep.type = endpoint_type_prefix + 'externalEndpoints'
            else:
                msg = ('Invalid endpoint {} in profile {}, needs to have ' +
                       'either target or target_resource_id').format(
                           ep.name, name)
                raise AzureException(msg)

        # build and return
        return Profile(
            id=self._profile_name_to_id(name),
            name=name,
            traffic_routing_method=routing,
            dns_config=DnsConfig(
                relative_name=name.lower(),
                ttl=record.ttl,
            ),
            monitor_config=_get_monitor(record),
            endpoints=endpoints,
            location='global',
        )

    def _convert_tm_to_root(self, profile, record):
        profile.name = _root_traffic_manager_name(record)
        profile.id = self._profile_name_to_id(profile.name)
        profile.dns_config.relative_name = profile.name.lower()

        return profile

    def _generate_traffic_managers(self, record):
        traffic_managers = []
        pools = record.dynamic.pools
        rules = record.dynamic.rules
        typ = record._type

        if typ == 'CNAME':
            defaults = [record.value[:-1]]
        else:
            defaults = record.values
        profile = self._generate_tm_profile

        # a pool can be re-used only with a world pool, record the pool
        # to later consolidate it with a geo pool if one exists since we
        # can't have multiple endpoints with the same target in ATM
        world_pool = None
        for rule in rules:
            if not rule.data.get('geos', []):
                world_pool = rule.data['pool']
        world_seen = False

        geo_endpoints = []
        pool_profiles = {}

        for rule in record.dynamic.rules:
            pool_name = rule.data['pool']
            if pool_name == world_pool and world_seen:
                # this world pool is already mentioned in another geo rule
                continue

            # Prepare the list of Traffic manager geos
            rule_geos = rule.data.get('geos', [])
            geos = []
            for geo in rule_geos:
                if '-' in geo:
                    # country/state
                    geos.append(geo.split('-', 1)[-1])
                else:
                    # continent
                    if geo == 'AS':
                        # Middle East is part of Asia in octoDNS, but
                        # Azure treats it as a separate "group", so let's
                        # add it in the list of geo mappings. We will drop
                        # it when we later parse the list of regions.
                        geos.append('GEO-ME')
                    elif geo == 'OC':
                        # Azure uses Australia/Pacific (AP) instead of
                        # Oceania
                        geo = 'AP'

                    geos.append('GEO-{}'.format(geo))
            if not geos or pool_name == world_pool:
                geos.append('WORLD')
                world_seen = True

            rule_endpoints = []
            priority = 1
            default_seen = False

            while pool_name:
                # iterate until we reach end of fallback chain
                pool = pools[pool_name].data
                if len(pool['values']) > 1:
                    # create Weighted profile for multi-value pool
                    pool_profile = pool_profiles.get(pool_name)
                    if pool_profile is None:
                        endpoints = []
                        for val in pool['values']:
                            target = val['value']
                            # strip trailing dot from CNAME value
                            if typ == 'CNAME':
                                target = target[:-1]
                            ep_name = '{}--{}'.format(pool_name, target)
                            # Endpoint names cannot have colons, drop them
                            # from IPv6 addresses
                            ep_name = ep_name.replace(':', '-')
                            if target in defaults:
                                # mark default
                                ep_name += '--default--'
                                default_seen = True
                            endpoints.append(Endpoint(
                                name=ep_name,
                                target=target,
                                weight=val.get('weight', 1),
                            ))
                        pool_profile = profile(
                            'Weighted', endpoints, record, pool_name)
                        traffic_managers.append(pool_profile)
                        pool_profiles[pool_name] = pool_profile

                    # append pool to endpoint list of fallback rule profile
                    rule_endpoints.append(Endpoint(
                        name=pool_name,
                        target_resource_id=pool_profile.id,
                        priority=priority,
                    ))
                else:
                    # Skip Weighted profile hop for single-value pool
                    # append its value as an external endpoint to fallback
                    # rule profile
                    target = pool['values'][0]['value']
                    if typ == 'CNAME':
                        target = target[:-1]
                    ep_name = pool_name
                    if target in defaults:
                        # mark default
                        ep_name += '--default--'
                        default_seen = True
                    rule_endpoints.append(Endpoint(
                        name=ep_name,
                        target=target,
                        priority=priority,
                    ))

                priority += 1
                pool_name = pool.get('fallback')

            # append default endpoint unless it is already included in
            # last pool of rule profile
            if not default_seen:
                rule_endpoints.append(Endpoint(
                    name='--default--',
                    target=defaults[0],
                    priority=priority,
                ))

            if len(rule_endpoints) > 1:
                # create rule profile with fallback chain
                rule_profile = profile(
                    'Priority', rule_endpoints, record, rule.data['pool'])
                traffic_managers.append(rule_profile)

                # append rule profile to top-level geo profile
                geo_endpoints.append(Endpoint(
                    name=rule.data['pool'],
                    target_resource_id=rule_profile.id,
                    geo_mapping=geos,
                ))
            else:
                # Priority profile has only one endpoint; skip the hop and
                # append its only endpoint to the top-level profile
                rule_ep = rule_endpoints[0]
                if rule_ep.target_resource_id:
                    # point directly to the Weighted pool profile
                    geo_endpoints.append(Endpoint(
                        name=rule_ep.name,
                        target_resource_id=rule_ep.target_resource_id,
                        geo_mapping=geos,
                    ))
                else:
                    # just add the value of single-value pool
                    geo_endpoints.append(Endpoint(
                        name=rule_ep.name,
                        target=rule_ep.target,
                        geo_mapping=geos,
                    ))

        if len(geo_endpoints) == 1 and \
           geo_endpoints[0].geo_mapping == ['WORLD'] and \
           geo_endpoints[0].target_resource_id:
            # Single WORLD rule does not require a Geographic profile, use
            # the target profile as the root profile
            self._convert_tm_to_root(traffic_managers[-1], record)
        else:
            geo_profile = profile('Geographic', geo_endpoints, record)
            traffic_managers.append(geo_profile)

        return traffic_managers

    def _sync_traffic_managers(self, desired_profiles):
        seen = set()

        tm_sync = self._tm_client.profiles.create_or_update
        populate = self._populate_nested_profiles

        for desired in desired_profiles:
            name = desired.name
            if name in seen:
                continue

            existing = self._get_tm_profile_by_name(name)
            if not _profile_is_match(existing, desired):
                self.log.info(
                    '_sync_traffic_managers: Syncing profile=%s', name)
                profile = tm_sync(self._resource_group, name, desired)
                self._traffic_managers[profile.id] = populate(profile)
            else:
                self.log.debug(
                    '_sync_traffic_managers: Skipping profile=%s: up to date',
                    name)
            seen.add(name)

        return seen

    def _find_traffic_managers(self, record):
        tm_prefix = _root_traffic_manager_name(record)

        profiles = set()
        for profile_id in self._traffic_managers:
            # match existing profiles with record's prefix
            name = profile_id.split('/')[-1]
            if name == tm_prefix or \
               name.startswith('{}-pool-'.format(tm_prefix)) or \
               name.startswith('{}-rule-'.format(tm_prefix)):
                profiles.add(name)

        return profiles

    def _traffic_managers_gc(self, record, active_profiles):
        existing_profiles = self._find_traffic_managers(record)

        # delete unused profiles
        for profile_name in existing_profiles - active_profiles:
            self.log.info('_traffic_managers_gc: Deleting profile=%s',
                          profile_name)
            self._tm_client.profiles.delete(self._resource_group, profile_name)

    def _apply_Create(self, change):
        '''A record from change must be created.

            :param change: a change object
            :type  change: octodns.record.Change

            :type return: void
        '''
        record = change.new

        dynamic = getattr(record, 'dynamic', False)
        root_profile = None
        endpoints = []
        if dynamic:
            profiles = self._generate_traffic_managers(record)
            root_profile = profiles[-1]
            if record._type in ['A', 'AAAA'] and len(profiles) > 1:
                # A/AAAA records cannot be aliased to Traffic Managers that
                # contain other nested Traffic Managers. To work around this
                # limitation, we remove nesting before adding the record, and
                # then add the nested endpoints later.
                endpoints = root_profile.endpoints
                root_profile.endpoints = []
            self._sync_traffic_managers(profiles)

        ar = _AzureRecord(self._resource_group, record,
                          traffic_manager=root_profile)
        create = self._dns_client.record_sets.create_or_update

        create(resource_group_name=ar.resource_group,
               zone_name=ar.zone_name,
               relative_record_set_name=ar.relative_record_set_name,
               record_type=ar.record_type,
               parameters=ar.params)

        if endpoints:
            # add nested endpoints for A/AAAA dynamic record limitation after
            # record creation
            root_profile.endpoints = endpoints
            self._sync_traffic_managers([root_profile])

        self.log.debug('*  Success Create: {}'.format(record))

    def _apply_Update(self, change):
        '''A record from change must be created.

            :param change: a change object
            :type  change: octodns.record.Change

            :type return: void
        '''
        existing = change.existing
        new = change.new
        existing_is_dynamic = getattr(existing, 'dynamic', False)
        new_is_dynamic = getattr(new, 'dynamic', False)

        update_record = True

        if new_is_dynamic:
            endpoints = []
            profiles = self._generate_traffic_managers(new)
            root_profile = profiles[-1]

            if new._type in ['A', 'AAAA']:
                if existing_is_dynamic:
                    # update to the record is not needed
                    update_record = False
                elif len(profiles) > 1:
                    # record needs to aliased; remove nested endpoints, we
                    # will add them at the end
                    endpoints = root_profile.endpoints
                    root_profile.endpoints = []
            elif existing.ttl == new.ttl and existing_is_dynamic:
                # CNAME dynamic records only have TTL in them, everything else
                # goes inside the aliased traffic managers; skip update if TTL
                # is unchanged and existing record is already aliased to its
                # traffic manager
                update_record = False

            active = self._sync_traffic_managers(profiles)

        if update_record:
            profile = self._get_tm_for_dynamic_record(new)
            ar = _AzureRecord(self._resource_group, new,
                              traffic_manager=profile)
            update = self._dns_client.record_sets.create_or_update

            update(resource_group_name=ar.resource_group,
                   zone_name=ar.zone_name,
                   relative_record_set_name=ar.relative_record_set_name,
                   record_type=ar.record_type,
                   parameters=ar.params)

        if new_is_dynamic:
            # add any pending nested endpoints
            if endpoints:
                root_profile.endpoints = endpoints
                self._sync_traffic_managers([root_profile])
            # let's cleanup unused traffic managers
            self._traffic_managers_gc(new, active)
        elif existing_is_dynamic:
            # cleanup traffic managers when a dynamic record gets
            # changed to a simple record
            self._traffic_managers_gc(existing, set())

        self.log.debug('*  Success Update: {}'.format(new))

    def _apply_Delete(self, change):
        '''A record from change must be deleted.

            :param change: a change object
            :type  change: octodns.record.Change

            :type return: void
        '''
        record = change.record
        ar = _AzureRecord(self._resource_group, record, delete=True)
        delete = self._dns_client.record_sets.delete

        delete(self._resource_group, ar.zone_name, ar.relative_record_set_name,
               ar.record_type)

        if getattr(record, 'dynamic', False):
            self._traffic_managers_gc(record, set())

        self.log.debug('*  Success Delete: {}'.format(record))

    def _apply(self, plan):
        '''Required function of manager.py to actually apply a record change.

            :param plan: Contains the zones and changes to be made
            :type  plan: octodns.provider.base.Plan

            :type return: void
        '''
        desired = plan.desired
        changes = plan.changes
        self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
                       len(changes))

        azure_zone_name = desired.name[:len(desired.name) - 1]
        self._check_zone(azure_zone_name, create=True)

        '''
        Force the operation order to be Delete() before all other operations.
        Helps avoid problems in updating
            - a CNAME record into an A record.
            - an A record into a CNAME record.
        '''

        for change in changes:
            class_name = change.__class__.__name__
            if class_name == 'Delete':
                self._apply_Delete(change)

        for change in changes:
            class_name = change.__class__.__name__
            if class_name != 'Delete':
                getattr(self, '_apply_{}'.format(class_name))(change)
