"""Provide support to Lexicon for AWS Route 53 DNS changes."""

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

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

try:
    import boto3  # type: ignore
    import botocore  # type: ignore
except ImportError:
    pass

LOGGER = logging.getLogger(__name__)


class RecordSetPaginator:
    """Paginate through complete list of record sets."""

    def __init__(self, r53_client, hosted_zone_id, max_items=None):
        """Initialize paginator."""
        self.r53_client = r53_client
        self.hosted_zone_id = hosted_zone_id
        self.max_items = max_items

    def get_record_sets(self, **kwargs):
        """Retrieve a page from API."""
        return self.r53_client.list_resource_record_sets(**kwargs)

    def get_base_kwargs(self):
        """Get base kwargs for API call."""
        kwargs = {"HostedZoneId": self.hosted_zone_id}
        if self.max_items is not None:
            kwargs.update({"MaxItems": str(self.max_items)})
        return kwargs

    def all_record_sets(self):
        """Generator to loop through current record set.

        Call next page if it exists.
        """
        is_truncated = True
        start_record_name = None
        start_record_type = None
        kwargs = self.get_base_kwargs()
        while is_truncated:
            if start_record_name is not None:
                kwargs.update(
                    {
                        "StartRecordName": start_record_name,
                        "StartRecordType": start_record_type,
                    }
                )
            result = self.get_record_sets(**kwargs)
            for record_set in result.get("ResourceRecordSets", []):
                yield record_set

            is_truncated = result.get("IsTruncated", False)

            start_record_name = result.get("NextRecordName", None)
            start_record_type = result.get("NextRecordType", None)


class Provider(BaseProvider):
    """Provide AWS Route 53 implementation of Lexicon Provider interface."""

    @staticmethod
    def get_nameservers() -> List[re.Pattern]:
        return [re.compile(r"^awsdns-\d+\.\w+$")]

    @staticmethod
    def configure_parser(parser: ArgumentParser) -> None:
        parser.add_argument(
            "--auth-access-key", help="specify ACCESS_KEY for authentication"
        )
        parser.add_argument(
            "--auth-access-secret", help="specify ACCESS_SECRET for authentication"
        )
        parser.add_argument(
            "--private-zone",
            help=(
                "indicates what kind of hosted zone to use. If true, use "
                "only private zones. If false, use only public zones"
            ),
        )

        # Allow bypassing the zone-id lookup for complex use cases like delegated subdomain
        parser.add_argument(
            "--zone-id", help="the AWS HostedZone ID to use; e.g. 'A1B2ZABCDEFGHI'"
        )

        # TODO: these are only required for testing, we should figure out
        # a way to remove them & update the integration tests
        # to dynamically populate the auth credentials that are required.
        parser.add_argument(
            "--auth-username",
            help="alternative way to specify the ACCESS_KEY for authentication",
        )
        parser.add_argument(
            "--auth-token",
            help="alternative way to specify the ACCESS_SECRET for authentication",
        )

    def __init__(self, config):
        """Initialize AWS Route 53 DNS provider."""
        super(Provider, self).__init__(config)
        # Allow setting domain_id from the command line
        self.domain_id = self._get_provider_option("zone_id") or None
        self.private_zone = self._get_provider_option("private_zone")
        # instantiate the client
        self.r53_client = boto3.client(
            "route53",
            aws_access_key_id=self._get_provider_option("auth_access_key")
            or self._get_provider_option("auth_username"),
            aws_secret_access_key=self._get_provider_option("auth_access_secret")
            or self._get_provider_option("auth_token"),
        )

    def filter_zone(self, data):
        """Check if a zone is private"""
        if self.private_zone is not None:
            if data["Config"]["PrivateZone"] != self.str2bool(self.private_zone):
                return False

        if data["Name"] != f"{self.domain}.":
            return False

        return True

    @staticmethod
    def str2bool(input_string):
        """Convert a string to boolean"""
        return input_string.lower() in ("true", "yes")

    def authenticate(self):
        """Authenticate the credentials early in the process"""

        # if this was set via the command-line argument, we don't need to look it up
        if self.domain_id is None:
            self._lookup_hosted_zone()
        # per https://github.com/AnalogJ/lexicon/pull/481#issuecomment-623203995
        # authenticate should do something even if we already know the ZoneID.
        # This will raise an exception if the credentials are wrong.
        # However, we do not want to do this if they have not specified the
        # ZoneID, since this may break existing user IAM roles.
        else:
            self.r53_client.get_hosted_zone(Id=self.domain_id)

    def cleanup(self) -> None:
        pass

    def _lookup_hosted_zone(self):
        """Determine the hosted zone id for the domain."""

        try:
            is_truncated = True
            next_dns_name = None
            next_hz_id = None
            hosted_zones = []

            while is_truncated:
                if not next_dns_name:
                    new_zones = self.r53_client.list_hosted_zones_by_name()
                else:
                    new_zones = self.r53_client.list_hosted_zones_by_name(
                        DNSName=next_dns_name, HostedZoneId=next_hz_id
                    )

                hosted_zones.extend(new_zones.get("HostedZones"))
                is_truncated = new_zones.get("IsTruncated")

                if is_truncated:
                    next_dns_name = new_zones.get("NextDNSName")
                    next_hz_id = new_zones.get("NextHostedZoneId")

            hosted_zone = next(hz for hz in hosted_zones if self.filter_zone(hz))
            self.domain_id = hosted_zone.get("Id")
        except StopIteration:
            raise AuthenticationError("No domain found")

    def _change_record_sets(self, action, rtype, name, content):
        ttl = self._get_lexicon_option("ttl")
        resource_records = []
        if isinstance(content, list):
            for i in content:
                value = f'"{i}"' if rtype in ["TXT", "SPF"] else i
                resource_records.append({"Value": value})
        else:
            value = f'"{content}"' if rtype in ["TXT", "SPF"] else content
            resource_records.append({"Value": value})
        try:
            self.r53_client.change_resource_record_sets(
                HostedZoneId=self.domain_id,
                ChangeBatch={
                    "Comment": f"{action} using lexicon Route 53 provider",
                    "Changes": [
                        {
                            "Action": action,
                            "ResourceRecordSet": {
                                "Name": self._fqdn_name(name),
                                "Type": rtype,
                                "TTL": ttl if ttl is not None else 300,
                                "ResourceRecords": resource_records,
                            },
                        }
                    ],
                },
            )
            return True
        except botocore.exceptions.ClientError as error:
            if "Duplicate Resource Record" in error.response["Error"]["Message"]:
                # Duplicate resource, that have been a noop. This is expected.
                return True
            LOGGER.error(str(error), exc_info=True)
            return False

    def create_record(self, rtype, name, content):
        """Create a record in the hosted zone."""
        existing_records = self._list_record_sets(rtype, name)
        if existing_records:
            existing_record = existing_records[0]
            if isinstance(existing_records[0]["content"], list):
                return self._change_record_sets(
                    "UPSERT",
                    existing_record["type"],
                    existing_record["name"],
                    existing_record["content"] + [content],
                )
            return self._change_record_sets(
                "UPSERT", rtype, name, [existing_record["content"]] + [content]
            )
        return self._change_record_sets("CREATE", rtype, name, content)

    def update_record(self, identifier=None, rtype=None, name=None, content=None):
        """Update a record from the hosted zone."""
        if identifier:
            records = [
                record
                for record in self.list_records()
                if identifier == _identifier(record)
            ]
            if not records:
                raise ValueError(f"No record found for identifier {identifier}")
            record = records[0]
            rtype = record["type"]
            name = record["name"]

        existing_records = self._list_record_sets(rtype, name)
        if not existing_records:
            raise ValueError("No matching record to update was found.")

        for existing_record in existing_records:
            if isinstance(existing_record["content"], list):
                # Multiple values in record.
                LOGGER.warning(
                    "Warning, multiple records found for given parameters, "
                    "only first entry will be updated: %s",
                    existing_record,
                )
                new_content = existing_record["content"].copy()
                new_content[0] = content
            else:
                new_content = content

            self._change_record_sets(
                "UPSERT", existing_record["type"], existing_record["name"], new_content
            )

        return True

    def delete_record(self, identifier=None, rtype=None, name=None, content=None):
        """Delete a record from the hosted zone."""
        if identifier:
            matching_records = [
                record
                for record in self.list_records()
                if identifier == _identifier(record)
            ]
            if not matching_records:
                raise ValueError(f"No record found for identifier {identifier}")
            rtype = matching_records[0]["type"]
            name = matching_records[0]["name"]
            content = matching_records[0]["content"]

        existing_records = self._list_record_sets(rtype, name, content)
        if not existing_records:
            raise ValueError("No record found for the provided type, name and content")

        for existing_record in existing_records:
            if isinstance(existing_record["content"], list) and content is not None:
                # multiple values in record, just remove one value and only if it actually exist
                if content in existing_record["content"]:
                    existing_record["content"].remove(content)
                    self._change_record_sets(
                        "UPSERT",
                        existing_record["type"],
                        existing_record["name"],
                        existing_record["content"],
                    )
            else:
                # if only one record exist, or if content is not specified, remove whole record
                self._change_record_sets(
                    "DELETE",
                    existing_record["type"],
                    existing_record["name"],
                    existing_record["content"],
                )

        return True

    def list_records(self, rtype=None, name=None, content=None):
        """List all records for the hosted zone."""
        records = self._list_record_sets(rtype, name, content)

        flatten_records = []
        for record in records:
            if isinstance(record["content"], list):
                for one_content in record["content"]:
                    flatten_record = record.copy()
                    flatten_record["content"] = one_content
                    flatten_record["id"] = _identifier(flatten_record)
                    flatten_records.append(flatten_record)
            else:
                record["id"] = _identifier(record)
                flatten_records.append(record)

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

        return flatten_records

    def _list_record_sets(self, rtype=None, name=None, content=None):
        records = []
        paginator = RecordSetPaginator(self.r53_client, self.domain_id)
        for record in paginator.all_record_sets():
            record_content = []
            if rtype is not None and record["Type"] != rtype:
                continue
            if name is not None and record["Name"] != self._fqdn_name(name):
                continue
            if record.get("AliasTarget", None) is not None:
                record_content = [record["AliasTarget"].get("DNSName", None)]
            if record.get("ResourceRecords", None) is not None:
                record_content = [
                    _format_content(record["Type"], value["Value"])
                    for value in record["ResourceRecords"]
                ]
            if content is not None and content not in record_content:
                continue

            LOGGER.debug("record: %s", record)
            records.append(
                {
                    "type": record["Type"],
                    "name": self._full_name(record["Name"]),
                    "ttl": record.get("TTL", None),
                    "content": (
                        record_content[0]
                        if len(record_content) == 1
                        else record_content
                    ),
                }
            )
        return records

    def _request(self, action="GET", url="/", data=None, query_params=None):
        # Helper _request is not used in Route53 provider
        pass


def _format_content(rtype, content):
    return content[1:-1] if rtype in ["TXT", "SPF"] else content


def _identifier(record):
    sha256 = hashlib.sha256()
    sha256.update(("type=" + record.get("type", "") + ",").encode("utf-8"))
    sha256.update(("name=" + record.get("name", "") + ",").encode("utf-8"))
    sha256.update(("data=" + record.get("data", "") + ",").encode("utf-8"))
    return sha256.hexdigest()[0:7]
