"""
Implement the Azure DNS provider.
The Azure DNS API is well documented, with meaningful examples.
The major difficulties are the authentication, that uses OAuth, and require several authentication
parameters, and the fact that records are manipulated as recordsets (entries with multiple values
like TXT are hold on a unique TXTRecords object), and so require methods to go from the Azure
representation of a DNS record to the Lexicon one. To simplify the implementation then, update
actions are managed using the create and delete actions: updating a record consists in removing the
old record, then creating a new record with updated parameters. Note also that Azure DNS does not
support id on records (as they are hold in a recordset) so an id is generated on the fly.
"""

import hashlib
import logging
from argparse import ArgumentParser
from typing import List

import requests

from lexicon.exceptions import AuthenticationError
from lexicon.interfaces import Provider as BaseProvider

LOGGER = logging.getLogger(__name__)

AZURE_AD_URL = "https://login.microsoftonline.com"
MANAGEMENT_URL = "https://management.azure.com"
API_VERSION = "2018-05-01"

SUPPORTED_RECORDS = {"A", "AAAA", "CNAME", "MX", "NS", "SOA", "TXT", "SRV"}


class Provider(BaseProvider):
    """Provider for Azure DNS"""

    @staticmethod
    def get_nameservers() -> List[str]:
        return [
            "azure-dns.com",
            "azure-dns.net",
            "azure-dns.org",
            "azure-dns.info",
        ]

    @staticmethod
    def configure_parser(parser: ArgumentParser) -> None:
        parser.description = """
            The Azure provider orchestrates the DNS zones hosted in a resource group for a subscription
            in Microsoft Azure Cloud. To authenticate, an App registration must be created in an Azure
            Active Directory. This App registration must be granted Admin for API permissions to
            Domain.ReadWrite.All" to this Active Directory, and must have a usable Client secret.
        """
        parser.add_argument(
            "--auth-client-id",
            help="specify the client ID (aka application ID) "
            "of the App registration",
        )
        parser.add_argument(
            "--auth-client-secret",
            help="specify the client secret of the App " "registration",
        )
        parser.add_argument(
            "--auth-tenant-id",
            help="specify the tenant ID (aka directory ID) of " "the App registration",
        )
        parser.add_argument(
            "--auth-subscription-id",
            help="specify the subscription ID attached " "to the resource group",
        )
        parser.add_argument(
            "--resource-group",
            help="specify the resource group hosting the DNS " "zone to edit",
        )

    def __init__(self, config):
        super(Provider, self).__init__(config)
        self.domain_id = None
        self._access_token = None

    def list_records(self, rtype=None, name=None, content=None):
        result = self._get_paginated(f"/{(rtype if rtype else 'recordsets')}")

        records = []
        for raw_record in result["value"]:
            rtype = raw_record["type"].replace("Microsoft.Network/dnszones/", "")
            if rtype not in SUPPORTED_RECORDS:
                continue
            for value in _get_values_from_recordset(rtype, raw_record):
                record = {
                    "type": rtype,
                    "name": self._full_name(raw_record["name"]),
                    "ttl": raw_record["properties"]["TTL"],
                    "content": value,
                }
                record["id"] = _identifier(record)
                records.append(record)

        if name:
            records = [
                record for record in records if record["name"] == self._full_name(name)
            ]
        if content:
            records = [record for record in records if record["content"] == content]

        LOGGER.debug("list_records: %s", records)

        return records

    def create_record(self, rtype, name, content):
        identifier = self._create_record_internal(rtype, name, content)

        LOGGER.debug("create_record: %s", identifier)

        return True

    def _create_record_internal(self, rtype, name, content):
        if not rtype or not name or not content:
            raise Exception(
                "Error, rtype, name and content are mandatory to create a record."
            )

        identifier = _identifier(
            {"type": rtype, "name": self._full_name(name), "content": content}
        )

        records = self.list_records(rtype, name)

        if [record for record in records if record["id"] == identifier]:
            LOGGER.debug("create_record (ignored, duplicate): %s", identifier)
            return True

        # CNAME and SOA record sets only allow one value. When replacing (e.g. ALIAS
        # with regular record), delete existing first.
        if rtype in ("CNAME", "SOA") and records:
            self._delete_record_internal(rtype=rtype, name=name)
            records = []

        values = [record["content"] for record in records]
        values.append(content)

        properties = _build_recordset_from_values(rtype, values)

        ttl = self._get_lexicon_option("ttl")
        if ttl:
            properties["TTL"] = ttl

        self._put(
            f"/{rtype}/{self._relative_name(name)}", data={"properties": properties}
        )

        return identifier

    def update_record(self, identifier, rtype=None, name=None, content=None):
        if not identifier and (not rtype or not name):
            raise Exception("Error, identifier or rtype+name parameters are required.")

        if identifier:
            records = self.list_records()
            records_to_update = [
                record for record in records if record["id"] == identifier
            ]
        else:
            records_to_update = self.list_records(rtype=rtype, name=name)

        if not records_to_update:
            raise Exception(
                f"Error, could not find a record for given identifier: {identifier}"
            )

        if len(records_to_update) > 1:
            LOGGER.warning(
                "Warning, multiple records found for given parameters, "
                "only first one will be updated: %s",
                records_to_update,
            )

        identifier = identifier if identifier else records_to_update[0]["id"]
        rtype = rtype if rtype else records_to_update[0]["type"]
        name = name if name else records_to_update[0]["name"]
        content = content if content else records_to_update[0]["content"]

        self._delete_record_internal(identifier=identifier)
        self._create_record_internal(rtype=rtype, name=name, content=content)

        LOGGER.debug(
            "update_record: %s => %s",
            identifier,
            _identifier({"type": rtype, "name": name, "content": content}),
        )

        return True

    def delete_record(self, identifier=None, rtype=None, name=None, content=None):
        self._delete_record_internal(identifier, rtype, name, content)

        LOGGER.debug("delete_records: %s %s %s %s", identifier, rtype, name, content)

        return True

    def _delete_record_internal(
        self, identifier=None, rtype=None, name=None, content=None
    ):
        result = self._get_paginated(f"/{(rtype if rtype else 'recordsets')}")

        to_delete = []
        to_shrink = []
        for record in result["value"]:
            record_rtype = record["type"].replace("Microsoft.Network/dnszones/", "")
            if record_rtype not in SUPPORTED_RECORDS:
                continue
            new_values = []
            values = _get_values_from_recordset(record_rtype, record)
            for value in values:
                if identifier is not None and identifier != _identifier(
                    {
                        "type": record_rtype,
                        "name": self._full_name(record["name"]),
                        "content": value,
                    }
                ):
                    new_values.append(value)
                elif identifier is None:
                    matching_rtype = rtype is None or record_rtype == rtype
                    matching_name = name is None or self._full_name(
                        name
                    ) == self._full_name(record["name"])
                    matching_content = content is None or value == content
                    if not (matching_rtype and matching_name and matching_content):
                        new_values.append(value)

            to_modify = len(values) != len(new_values)
            if to_modify:
                record["properties"].update(
                    _build_recordset_from_values(record_rtype, new_values)
                )
                if new_values:
                    to_shrink.append(record)
                else:
                    to_delete.append(record)

        for record in to_delete:
            record_rtype = record["type"].replace("Microsoft.Network/dnszones/", "")
            self._delete(f"/{record_rtype}/{self._relative_name(record['name'])}")
        for record in to_shrink:
            record_rtype = record["type"].replace("Microsoft.Network/dnszones/", "")
            self._request(
                "PATCH",
                f"/{record_rtype}/{self._relative_name(record['name'])}",
                data={"properties": record["properties"]},
            )

    def authenticate(self):
        tenant_id = self._get_provider_option("auth_tenant_id")
        client_id = self._get_provider_option("auth_client_id")
        client_secret = self._get_provider_option("auth_client_secret")
        subscription_id = self._get_provider_option("auth_subscription_id")
        resource_group = self._get_provider_option("resource_group")

        assert tenant_id
        assert client_id
        assert client_secret
        assert subscription_id
        assert resource_group

        url = f"{AZURE_AD_URL}/{tenant_id}/oauth2/token"
        data = {
            "grant_type": "client_credentials",
            "client_id": client_id,
            "client_secret": client_secret,
            "resource": MANAGEMENT_URL,
        }

        result = requests.post(url, data=data)
        result.raise_for_status()

        self._access_token = result.json()["access_token"]

        url = "{0}/subscriptions/{1}/resourceGroups/{2}/providers/Microsoft.Network/dnsZones".format(
            MANAGEMENT_URL, subscription_id, resource_group
        )
        headers = {"Authorization": f"Bearer {self._access_token}"}
        params = {"api-version": API_VERSION}

        # Azure ARM list APIs are paginated via nextLink
        domain_id = None
        next_url = url

        while next_url:
            if next_url == url:
                result = requests.get(next_url, headers=headers, params=params)
            else:
                # nextLink already contains api-version and skip token
                result = requests.get(next_url, headers=headers)

            result.raise_for_status()
            data = result.json()

            for one_data in data.get("value", []):
                if one_data.get("name") == self.domain:
                    domain_id = one_data.get("id")
                    break

            if domain_id:
                break

            next_url = data.get("nextLink")

        if not domain_id:
            raise AuthenticationError(
                "Resource group `{0}` in subscription `{1}` "
                "does not contain the DNS zone `{2}`".format(
                    resource_group, subscription_id, self.domain
                )
            )

        self.domain_id = domain_id

    def cleanup(self) -> None:
        pass

    def _request(self, action="GET", url="/", data=None, query_params=None):
        query_params = {} if not query_params else query_params.copy()
        query_params["api-version"] = API_VERSION
        headers = {"Authorization": f"Bearer {self._access_token}"}
        request = requests.request(
            action,
            MANAGEMENT_URL + self.domain_id + url,
            params=query_params,
            headers=headers,
            json=None if not data else data,
        )
        request.raise_for_status()
        if request.content:
            return request.json()
        return None

    def _get_paginated(self, url="/", query_params=None):
        """GET with Azure ARM pagination support via nextLink."""
        query_params = {} if not query_params else query_params.copy()
        query_params["api-version"] = API_VERSION
        headers = {"Authorization": f"Bearer {self._access_token}"}

        all_values = []
        next_url = MANAGEMENT_URL + self.domain_id + url

        while next_url:
            if len(all_values) == 0:
                result = requests.get(next_url, headers=headers, params=query_params)
            else:
                result = requests.get(next_url, headers=headers)

            result.raise_for_status()
            data = result.json()

            all_values.extend(data.get("value", []))
            next_url = data.get("nextLink")

        return {"value": all_values}


def _get_alias_content(properties):
    """Extract content from Azure ALIAS record (targetResource or trafficManagementProfile)."""
    target = properties.get("targetResource") or properties.get(
        "trafficManagementProfile"
    )
    if target and target.get("id"):
        return target["id"]
    return None


def _get_values_from_recordset(rtype, record):
    properties = record["properties"]
    values = None
    if rtype == "A":
        values = [entry["ipv4Address"] for entry in properties.get("ARecords", [])]
        if not values:
            alias = _get_alias_content(properties)
            if alias:
                values = [alias]
    if rtype == "AAAA":
        values = [entry["ipv6Address"] for entry in properties.get("AAAARecords", [])]
        if not values:
            alias = _get_alias_content(properties)
            if alias:
                values = [alias]
    if rtype == "CNAME":
        cname_record = properties.get("CNAMERecord")
        values = [cname_record["cname"]] if cname_record else []
        if not values:
            alias = _get_alias_content(properties)
            if alias:
                values = [alias]
    if rtype == "MX":
        values = [entry["exchange"] for entry in properties.get("MXRecords", [])]
    if rtype == "NS":
        values = [entry["nsdname"] for entry in properties.get("NSRecords", [])]
    if rtype == "SOA":
        soa_record = properties.get("SOARecord")
        values = [soa_record["email"]] if soa_record else []
    if rtype == "TXT":
        values = [
            value
            for entry in properties.get("TXTRecords", [])
            for value in entry["value"]
        ]
    if rtype == "SRV":
        values = [entry["target"] for entry in properties.get("SRVRecords", [])]

    if values is not None:
        return values

    raise Exception(f"Error, `{rtype}` entries are not supported by this provider.")


def _build_recordset_from_values(rtype, values):
    recordset = None
    if rtype == "A":
        recordset = {"ARecords": [{"ipv4Address": value} for value in values]}
    if rtype == "AAAA":
        recordset = {"AAAARecords": [{"ipv6Address": value} for value in values]}
    if rtype == "CNAME":
        recordset = {"CNAMERecord": {"cname": values[0]} if values else {}}
    if rtype == "MX":
        recordset = {"MXRecords": [{"exchange": value} for value in values]}
    if rtype == "NS":
        recordset = {"NSRecords": [{"nsdname": value} for value in values]}
    if rtype == "SOA":
        recordset = {"SOARecord": {"email": values[0]} if values else {}}
    if rtype == "TXT":
        recordset = {"TXTRecords": [{"value": [value]} for value in values]}
    if rtype == "SRV":
        recordset = {"SRVRecords": [{"target": value} for value in values]}

    if recordset:
        return recordset

    raise Exception(f"Error, `{rtype}` entries are not supported by this provider.")


def _identifier(record):
    m = hashlib.sha256()
    m.update(("type=" + record.get("type", "") + ",").encode("utf-8"))
    m.update(("name=" + record.get("name", "") + ",").encode("utf-8"))
    m.update(("content=" + record.get("content", "") + ",").encode("utf-8"))

    return m.hexdigest()[0:7]
