# Copyright 2024-2026 The DNS-AID Authors
# SPDX-License-Identifier: Apache-2.0

"""
AWS Route 53 DNS backend.

Creates DNS-AID records (SVCB, TXT) in AWS Route 53 hosted zones.
Supports both zone ID and domain name for zone identification.
"""

from __future__ import annotations

import os
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING

import structlog

from dns_aid.backends.base import DNSBackend

if TYPE_CHECKING:
    from mypy_boto3_route53 import Route53Client

    from dns_aid.core.models import AgentRecord

logger = structlog.get_logger(__name__)

# Standard SVCB SvcParamKeys that Route53 accepts (RFC 9460).
# Route53 rejects private-use keys (key65001–key65534) with
# "SVCB does not support undefined parameters."
# When publishing, custom BANDAID params are automatically demoted
# to TXT records so the publish succeeds.
_ROUTE53_SVCB_KEYS = frozenset(
    {
        "mandatory",
        "alpn",
        "no-default-alpn",
        "port",
        "ipv4hint",
        "ipv6hint",
        "ech",
    }
)


class Route53Backend(DNSBackend):
    """
    AWS Route 53 DNS backend.

    Creates and manages DNS-AID records in Route 53 hosted zones.

    Example:
        >>> backend = Route53Backend(zone_id="ZEXAMPLEZONEID")
        >>> await backend.create_svcb_record(
        ...     zone="example.com",
        ...     name="_chat._a2a._agents",
        ...     priority=1,
        ...     target="chat.example.com.",
        ...     params={"alpn": "a2a", "port": "443"}
        ... )

        >>> # Or use domain name to auto-discover zone
        >>> backend = Route53Backend()
        >>> await backend.create_svcb_record(
        ...     zone="example.com",  # Will find zone ID automatically
        ...     ...
        ... )
    """

    def __init__(
        self,
        zone_id: str | None = None,
        region: str | None = None,
        aws_access_key_id: str | None = None,
        aws_secret_access_key: str | None = None,
    ):
        """
        Initialize Route 53 backend.

        Args:
            zone_id: Optional hosted zone ID (e.g., "ZEXAMPLEZONEID").
                     If not provided, will be looked up by domain name.
            region: AWS region (defaults to us-east-1 for Route 53)
            aws_access_key_id: AWS access key (defaults to env/credentials)
            aws_secret_access_key: AWS secret key (defaults to env/credentials)
        """
        self._zone_id = zone_id
        self._region = region or os.environ.get("AWS_REGION", "us-east-1")
        self._aws_access_key_id = aws_access_key_id
        self._aws_secret_access_key = aws_secret_access_key
        self._client: Route53Client | None = None
        self._zone_cache: dict[str, str] = {}  # domain -> zone_id

    @property
    def name(self) -> str:
        return "route53"

    def _get_client(self) -> Route53Client:
        """Get or create boto3 Route 53 client."""
        if self._client is None:
            import boto3

            kwargs = {"region_name": self._region}
            if self._aws_access_key_id and self._aws_secret_access_key:
                kwargs["aws_access_key_id"] = self._aws_access_key_id
                kwargs["aws_secret_access_key"] = self._aws_secret_access_key

            self._client = boto3.client("route53", **kwargs)  # type: ignore[call-overload]

        return self._client

    async def _get_zone_id(self, zone: str) -> str:
        """
        Get Route 53 hosted zone ID for a domain.

        Args:
            zone: Domain name (e.g., "example.com")

        Returns:
            Zone ID (e.g., "ZEXAMPLEZONEID")

        Raises:
            ValueError: If zone not found
        """
        # Use configured zone ID if set
        if self._zone_id:
            return self._zone_id

        # Check cache
        if zone in self._zone_cache:
            return self._zone_cache[zone]

        # Normalize domain
        domain = zone.lower().rstrip(".")

        client = self._get_client()

        # List hosted zones and find matching one
        paginator = client.get_paginator("list_hosted_zones")

        for page in paginator.paginate():
            for hz in page["HostedZones"]:
                hz_name = hz["Name"].rstrip(".")
                if hz_name == domain:
                    # Extract zone ID (remove /hostedzone/ prefix)
                    zone_id = hz["Id"].replace("/hostedzone/", "")
                    self._zone_cache[zone] = zone_id
                    logger.debug("Found zone ID", domain=domain, zone_id=zone_id)
                    return zone_id

        raise ValueError(f"No hosted zone found for domain: {zone}")

    def _format_svcb_value(
        self,
        priority: int,
        target: str,
        params: dict[str, str],
    ) -> str:
        """
        Format SVCB record value for Route 53.

        Route 53 SVCB format: <priority> <target> [<params>...]

        Example: 1 chat.example.com. alpn="a2a" port="443"
        """
        # Ensure target has trailing dot
        if not target.endswith("."):
            target = f"{target}."

        # Build parameter string
        param_parts = []
        for key, value in params.items():
            # Route 53 uses key="value" format for SVCB params
            param_parts.append(f'{key}="{value}"')

        params_str = " ".join(param_parts)

        if params_str:
            return f"{priority} {target} {params_str}"
        else:
            return f"{priority} {target}"

    async def create_svcb_record(
        self,
        zone: str,
        name: str,
        priority: int,
        target: str,
        params: dict[str, str],
        ttl: int = 3600,
    ) -> str:
        """Create SVCB record in Route 53."""
        zone_id = await self._get_zone_id(zone)
        client = self._get_client()

        # Build FQDN
        fqdn = f"{name}.{zone}"
        if not fqdn.endswith("."):
            fqdn = f"{fqdn}."

        # Format SVCB value
        svcb_value = self._format_svcb_value(priority, target, params)

        logger.info(
            "Creating SVCB record",
            zone=zone,
            zone_id=zone_id,
            name=fqdn,
            value=svcb_value,
            ttl=ttl,
        )

        # Create change batch
        change_batch = {
            "Comment": f"DNS-AID: Create SVCB record for {name}",
            "Changes": [
                {
                    "Action": "UPSERT",
                    "ResourceRecordSet": {
                        "Name": fqdn,
                        "Type": "SVCB",
                        "TTL": ttl,
                        "ResourceRecords": [{"Value": svcb_value}],
                    },
                }
            ],
        }

        # Execute change
        response = client.change_resource_record_sets(
            HostedZoneId=zone_id,
            ChangeBatch=change_batch,  # type: ignore[arg-type]
        )

        change_id = response["ChangeInfo"]["Id"]
        logger.info("SVCB record created", fqdn=fqdn, change_id=change_id)

        return fqdn.rstrip(".")

    async def create_txt_record(
        self,
        zone: str,
        name: str,
        values: list[str],
        ttl: int = 3600,
    ) -> str:
        """Create TXT record in Route 53."""
        zone_id = await self._get_zone_id(zone)
        client = self._get_client()

        # Build FQDN
        fqdn = f"{name}.{zone}"
        if not fqdn.endswith("."):
            fqdn = f"{fqdn}."

        # TXT records need quoted values
        txt_values = [{"Value": f'"{v}"'} for v in values]

        logger.info(
            "Creating TXT record",
            zone=zone,
            zone_id=zone_id,
            name=fqdn,
            values=values,
            ttl=ttl,
        )

        # Create change batch
        change_batch = {
            "Comment": f"DNS-AID: Create TXT record for {name}",
            "Changes": [
                {
                    "Action": "UPSERT",
                    "ResourceRecordSet": {
                        "Name": fqdn,
                        "Type": "TXT",
                        "TTL": ttl,
                        "ResourceRecords": txt_values,
                    },
                }
            ],
        }

        # Execute change
        response = client.change_resource_record_sets(
            HostedZoneId=zone_id,
            ChangeBatch=change_batch,  # type: ignore[arg-type]
        )

        change_id = response["ChangeInfo"]["Id"]
        logger.info("TXT record created", fqdn=fqdn, change_id=change_id)

        return fqdn.rstrip(".")

    async def publish_agent(self, agent: AgentRecord) -> list[str]:
        """
        Publish an agent to DNS, demoting unsupported SVCB params to TXT.

        Route53 only accepts standard RFC 9460 SvcParamKeys. Custom BANDAID
        params (key65001–key65006) are automatically moved to the TXT record
        so the publish succeeds without data loss.
        """
        records: list[str] = []
        zone = agent.domain
        name = f"_{agent.name}._{agent.protocol.value}._agents"

        # Split params: standard → SVCB, custom → TXT fallback
        all_params = agent.to_svcb_params()
        standard_params: dict[str, str] = {}
        custom_params: dict[str, str] = {}

        for key, value in all_params.items():
            if key in _ROUTE53_SVCB_KEYS:
                standard_params[key] = value
            else:
                custom_params[key] = value

        if custom_params:
            logger.warning(
                "Route53 does not support custom SVCB params; demoting to TXT",
                demoted_keys=list(custom_params.keys()),
            )

        # Create SVCB record with standard params only
        svcb_fqdn = await self.create_svcb_record(
            zone=zone,
            name=name,
            priority=1,
            target=agent.svcb_target,
            params=standard_params,
            ttl=agent.ttl,
        )
        records.append(f"SVCB {svcb_fqdn}")

        # Build TXT values: capabilities/metadata + demoted BANDAID params
        txt_values = agent.to_txt_values()
        for key, value in custom_params.items():
            txt_values.append(f"bandaid_{key}={value}")

        if txt_values:
            txt_fqdn = await self.create_txt_record(
                zone=zone,
                name=name,
                values=txt_values,
                ttl=agent.ttl,
            )
            records.append(f"TXT {txt_fqdn}")

        return records

    async def delete_record(
        self,
        zone: str,
        name: str,
        record_type: str,
    ) -> bool:
        """Delete a DNS record from Route 53."""
        zone_id = await self._get_zone_id(zone)
        client = self._get_client()

        # Build FQDN
        fqdn = f"{name}.{zone}"
        if not fqdn.endswith("."):
            fqdn = f"{fqdn}."

        logger.info(
            "Deleting record",
            zone=zone,
            name=fqdn,
            type=record_type,
        )

        # First, get the existing record to know its value
        try:
            response = client.list_resource_record_sets(
                HostedZoneId=zone_id,
                StartRecordName=fqdn,
                StartRecordType=record_type,  # type: ignore[arg-type]
                MaxItems="1",
            )

            record_sets = response.get("ResourceRecordSets", [])
            if not record_sets:
                logger.warning("Record not found", fqdn=fqdn, type=record_type)
                return False

            record = record_sets[0]

            # Verify it's the exact record we want
            if record["Name"] != fqdn or record["Type"] != record_type:
                logger.warning("Record not found (mismatch)", fqdn=fqdn, type=record_type)
                return False

            # Delete the record
            change_batch = {
                "Comment": f"DNS-AID: Delete {record_type} record for {name}",
                "Changes": [
                    {
                        "Action": "DELETE",
                        "ResourceRecordSet": record,
                    }
                ],
            }

            client.change_resource_record_sets(
                HostedZoneId=zone_id,
                ChangeBatch=change_batch,  # type: ignore[arg-type]
            )

            logger.info("Record deleted", fqdn=fqdn, type=record_type)
            return True

        except Exception as e:
            logger.exception("Failed to delete record", error=str(e))
            return False

    async def list_records(
        self,
        zone: str,
        name_pattern: str | None = None,
        record_type: str | None = None,
    ) -> AsyncIterator[dict]:
        """List DNS records in Route 53 zone."""
        zone_id = await self._get_zone_id(zone)
        client = self._get_client()

        logger.debug(
            "Listing records",
            zone=zone,
            zone_id=zone_id,
            name_pattern=name_pattern,
            record_type=record_type,
        )

        paginator = client.get_paginator("list_resource_record_sets")

        for page in paginator.paginate(HostedZoneId=zone_id):
            for record in page["ResourceRecordSets"]:
                rname = record["Name"].rstrip(".")
                rtype = record["Type"]

                # Filter by name pattern (simple substring match)
                if name_pattern and name_pattern not in rname:
                    continue

                # Filter by record type
                if record_type and rtype != record_type:
                    continue

                # Extract values
                values = []
                for rr in record.get("ResourceRecords", []):
                    values.append(rr["Value"])

                yield {
                    "name": rname.replace(f".{zone}", ""),
                    "fqdn": rname,
                    "type": rtype,
                    "ttl": record.get("TTL", 0),
                    "values": values,
                }

    async def zone_exists(self, zone: str) -> bool:
        """Check if zone exists in Route 53."""
        try:
            await self._get_zone_id(zone)
            return True
        except ValueError:
            return False

    async def get_record(
        self,
        zone: str,
        name: str,
        record_type: str,
    ) -> dict | None:
        """
        Get a specific DNS record by querying Route 53 API directly.

        More efficient than list_records for single record lookup.
        """
        zone_id = await self._get_zone_id(zone)
        client = self._get_client()

        # Build FQDN
        fqdn = f"{name}.{zone}"
        if not fqdn.endswith("."):
            fqdn = f"{fqdn}."

        try:
            response = client.list_resource_record_sets(
                HostedZoneId=zone_id,
                StartRecordName=fqdn,
                StartRecordType=record_type,  # type: ignore[arg-type]
                MaxItems="1",
            )

            record_sets = response.get("ResourceRecordSets", [])
            if not record_sets:
                return None

            record = record_sets[0]

            # Verify it's the exact record we want
            if record["Name"] != fqdn or record["Type"] != record_type:
                return None

            # Extract values
            values = [rr["Value"] for rr in record.get("ResourceRecords", [])]

            return {
                "name": name,
                "fqdn": fqdn.rstrip("."),
                "type": record_type,
                "ttl": record.get("TTL", 0),
                "values": values,
            }

        except Exception as e:
            logger.debug("Record not found", fqdn=fqdn, type=record_type, error=str(e))
            return None

    async def list_zones(self) -> list[dict]:
        """
        List all hosted zones in the account.

        Returns:
            List of zone info dicts with id, name, record_count
        """
        client = self._get_client()
        zones = []

        paginator = client.get_paginator("list_hosted_zones")

        for page in paginator.paginate():
            for hz in page["HostedZones"]:
                zones.append(
                    {
                        "id": hz["Id"].replace("/hostedzone/", ""),
                        "name": hz["Name"].rstrip("."),
                        "record_count": hz["ResourceRecordSetCount"],
                        "private": hz["Config"].get("PrivateZone", False),
                    }
                )

        return zones

    async def get_change_status(self, change_id: str) -> str:
        """
        Get status of a change request.

        Args:
            change_id: Change ID from create/delete operation

        Returns:
            Status string: "PENDING" or "INSYNC"
        """
        client = self._get_client()

        response = client.get_change(Id=change_id)
        return response["ChangeInfo"]["Status"]

    async def wait_for_change(self, change_id: str, max_wait: int = 60) -> bool:
        """
        Wait for a change to propagate.

        Args:
            change_id: Change ID to wait for
            max_wait: Maximum seconds to wait

        Returns:
            True if change completed, False if timeout
        """
        import asyncio

        client = self._get_client()

        for _ in range(max_wait):
            response = client.get_change(Id=change_id)
            if response["ChangeInfo"]["Status"] == "INSYNC":
                return True
            await asyncio.sleep(1)

        return False
