import json
import re
from dataclasses import dataclass
from typing import Any
from urllib import error as urllib_error
from urllib import parse as urllib_parse
from urllib import request as urllib_request

from .utils import normalize_domain, validate_selector

CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4"
CLOUDFLARE_RESPONSE_MAX_BYTES = 1024 * 1024
DEFAULT_DNS_TTL = 3600
DNS_RECORD_LABEL_RE = r"[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?"
SUPPORTED_RECORD_TYPES = frozenset({"A", "AAAA", "CNAME", "MX", "TXT", "SRV", "NS", "CAA"})


class NoRedirectHandler(urllib_request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


CLOUDFLARE_OPENER = urllib_request.build_opener(NoRedirectHandler())


class DNSProviderError(RuntimeError):
    pass


@dataclass
class DNSRecordChange:
    provider: str
    action: str
    zone_id: str
    record_id: str
    name: str
    record_type: str
    content: str
    ttl: int
    priority: int | None = None

    def as_dict(self) -> dict[str, object]:
        payload: dict[str, object] = {
            "provider": self.provider,
            "action": self.action,
            "zone_id": self.zone_id,
            "record_id": self.record_id,
            "name": self.name,
            "type": self.record_type,
            "content": self.content,
            "ttl": self.ttl,
        }
        if self.priority is not None:
            payload["priority"] = self.priority
        return payload


class CloudflareDNSProvider:
    provider_name = "cloudflare"

    def __init__(self, *, api_token: str, zone_id: str, account_id: str, api_base: str = CLOUDFLARE_API_BASE) -> None:
        self.api_token = api_token.strip()
        self.zone_id = zone_id.strip()
        self.account_id = account_id.strip()
        self.api_base = api_base.rstrip("/")
        parsed_api_base = urllib_parse.urlparse(self.api_base)
        if (
            parsed_api_base.scheme != "https"
            or not parsed_api_base.hostname
            or parsed_api_base.username
            or parsed_api_base.password
            or parsed_api_base.query
            or parsed_api_base.fragment
        ):
            raise DNSProviderError("Cloudflare API base URL must be an HTTPS origin without credentials, query or fragment")
        if not self.api_token:
            raise DNSProviderError("Cloudflare API token is not configured for this domain")
        if not self.account_id:
            raise DNSProviderError("Cloudflare account ID is not configured for this domain")
        if not self.zone_id:
            raise DNSProviderError("Cloudflare zone ID is not configured for this domain")

    def _request(self, method: str, path: str, payload: dict[str, Any] | None = None, params: dict[str, str] | None = None) -> dict[str, Any]:
        query = f"?{urllib_parse.urlencode(params)}" if params else ""
        body = json.dumps(payload).encode("utf-8") if payload is not None else None
        request = urllib_request.Request(
            f"{self.api_base}{path}{query}",
            data=body,
            method=method,
            headers={
                "Authorization": f"Bearer {self.api_token}",
                "Content-Type": "application/json",
                "Accept": "application/json",
            },
        )
        try:
            with CLOUDFLARE_OPENER.open(request, timeout=30) as response:
                final_url = response.geturl() if hasattr(response, "geturl") else request.full_url
                final_parsed = urllib_parse.urlparse(final_url)
                if (
                    final_parsed.scheme != "https"
                    or not final_parsed.hostname
                    or final_parsed.username
                    or final_parsed.password
                ):
                    raise DNSProviderError("Cloudflare API redirected to an insecure URL")
                response_body = response.read(CLOUDFLARE_RESPONSE_MAX_BYTES + 1)
        except DNSProviderError:
            raise
        except urllib_error.HTTPError as exc:
            detail = self._error_detail(exc)
            raise DNSProviderError(f"Cloudflare API error: {detail}") from exc
        except urllib_error.URLError as exc:
            raise DNSProviderError(f"Cloudflare API connection failed: {exc.reason}") from exc
        if len(response_body) > CLOUDFLARE_RESPONSE_MAX_BYTES:
            raise DNSProviderError("Cloudflare API response is too large")
        try:
            data = json.loads(response_body.decode("utf-8"))
        except json.JSONDecodeError as exc:
            raise DNSProviderError("Cloudflare API returned invalid JSON") from exc
        if not data.get("success"):
            raise DNSProviderError(f"Cloudflare API error: {cloudflare_error_message(data)}")
        return data

    @staticmethod
    def _error_detail(exc: urllib_error.HTTPError) -> str:
        try:
            response_body = exc.read(CLOUDFLARE_RESPONSE_MAX_BYTES + 1)
            if len(response_body) <= CLOUDFLARE_RESPONSE_MAX_BYTES:
                payload = json.loads(response_body.decode("utf-8"))
                return cloudflare_error_message(payload)
        except Exception:
            pass
        return f"HTTP {exc.code}"

    def _validate_zone_account(self) -> None:
        result = self._request("GET", f"/zones/{urllib_parse.quote(self.zone_id, safe='')}").get("result") or {}
        zone_account = result.get("account") if isinstance(result, dict) else {}
        zone_account_id = ""
        if isinstance(zone_account, dict):
            zone_account_id = str(zone_account.get("id") or "").strip()
        if zone_account_id and zone_account_id != self.account_id:
            raise DNSProviderError("Cloudflare account ID does not match the selected zone")

    def list_records(self, *, record_type: str | None = None, name: str | None = None) -> list[dict[str, Any]]:
        params: dict[str, str] = {}
        if record_type:
            params["type"] = record_type.upper()
        if name:
            params["name"] = normalize_dns_record_name(name)
        result = self._request(
            "GET",
            f"/zones/{urllib_parse.quote(self.zone_id, safe='')}/dns_records",
            params=params or None,
        ).get("result", [])
        return result if isinstance(result, list) else []

    def upsert_record(
        self,
        *,
        record_type: str,
        name: str,
        content: str,
        ttl: int = DEFAULT_DNS_TTL,
        priority: int | None = None,
        proxied: bool = False,
    ) -> DNSRecordChange:
        record_type_value = str(record_type or "").strip().upper()
        if record_type_value not in SUPPORTED_RECORD_TYPES:
            raise DNSProviderError(f"Unsupported DNS record type: {record_type}")
        record_name = normalize_dns_record_name(name)
        record_content = normalize_record_content(record_type_value, content)
        ttl_value = validate_ttl(ttl)
        self._validate_zone_account()
        existing = self.list_records(record_type=record_type_value, name=record_name)
        # Prefer matching content/priority when multiple records share a name (e.g. MX).
        target = None
        for item in existing:
            if not isinstance(item, dict):
                continue
            same_content = str(item.get("content") or "").strip() == record_content
            same_priority = True
            if priority is not None:
                try:
                    same_priority = int(item.get("priority") or -1) == int(priority)
                except (TypeError, ValueError):
                    same_priority = False
            if same_content and same_priority:
                target = item
                break
        if target is None and existing and record_type_value in {"TXT", "CNAME", "A", "AAAA"} and len(existing) == 1:
            target = existing[0] if isinstance(existing[0], dict) else None

        payload: dict[str, Any] = {
            "type": record_type_value,
            "name": record_name,
            "content": record_content,
            "ttl": ttl_value,
            "proxied": bool(proxied) if record_type_value in {"A", "AAAA", "CNAME"} else False,
        }
        if priority is not None and record_type_value in {"MX", "SRV"}:
            payload["priority"] = int(priority)

        if target and target.get("id"):
            record_id = str(target["id"])
            result = self._request(
                "PUT",
                f"/zones/{urllib_parse.quote(self.zone_id, safe='')}/dns_records/{urllib_parse.quote(record_id, safe='')}",
                payload,
            )
            action = "updated"
        else:
            result = self._request("POST", f"/zones/{urllib_parse.quote(self.zone_id, safe='')}/dns_records", payload)
            action = "created"
        record = result.get("result") or {}
        record_id = str(record.get("id") or "")
        if not record_id:
            raise DNSProviderError("Cloudflare did not return the DNS record ID")
        return DNSRecordChange(
            provider=self.provider_name,
            action=action,
            zone_id=self.zone_id,
            record_id=record_id,
            name=record_name,
            record_type=record_type_value,
            content=record_content,
            ttl=ttl_value,
            priority=priority,
        )

    def upsert_txt_record(self, *, name: str, content: str, ttl: int = DEFAULT_DNS_TTL) -> DNSRecordChange:
        return self.upsert_record(record_type="TXT", name=name, content=content, ttl=ttl)

    def delete_records(self, *, record_type: str, name: str, content: str | None = None) -> list[DNSRecordChange]:
        record_type_value = str(record_type or "").strip().upper()
        record_name = normalize_dns_record_name(name)
        self._validate_zone_account()
        existing = self.list_records(record_type=record_type_value, name=record_name)
        changes: list[DNSRecordChange] = []
        expected_content = normalize_record_content(record_type_value, content) if content else None
        for item in existing:
            if not isinstance(item, dict):
                continue
            if expected_content is not None and str(item.get("content") or "").strip() != expected_content:
                continue
            record_id = str(item.get("id") or "")
            if not record_id:
                continue
            self._request(
                "DELETE",
                f"/zones/{urllib_parse.quote(self.zone_id, safe='')}/dns_records/{urllib_parse.quote(record_id, safe='')}",
            )
            changes.append(
                DNSRecordChange(
                    provider=self.provider_name,
                    action="deleted",
                    zone_id=self.zone_id,
                    record_id=record_id,
                    name=record_name,
                    record_type=record_type_value,
                    content=str(item.get("content") or ""),
                    ttl=int(item.get("ttl") or DEFAULT_DNS_TTL),
                    priority=int(item["priority"]) if item.get("priority") is not None else None,
                )
            )
        return changes


def cloudflare_error_message(payload: dict[str, Any]) -> str:
    errors = payload.get("errors")
    if isinstance(errors, list) and errors:
        messages = []
        for item in errors[:3]:
            if isinstance(item, dict):
                code = item.get("code")
                message = item.get("message") or "unknown error"
                messages.append(f"{code}: {message}" if code else str(message))
        if messages:
            return "; ".join(messages)
    return "request failed"


def normalize_dns_record_name(value: str) -> str:
    candidate = value.strip().rstrip(".").lower()
    try:
        candidate = candidate.encode("idna").decode("ascii")
    except UnicodeError as exc:
        raise DNSProviderError("Invalid DNS record name") from exc
    labels = candidate.split(".")
    if not labels or len(candidate) > 253:
        raise DNSProviderError("Invalid DNS record name")
    for label in labels:
        if not label or len(label) > 63:
            raise DNSProviderError("Invalid DNS record name")
        if not re.fullmatch(DNS_RECORD_LABEL_RE, label):
            raise DNSProviderError("Invalid DNS record name")
    return candidate


def normalize_txt_content(value: str) -> str:
    content = value.strip()
    if len(content) >= 2 and content[0] == content[-1] == '"':
        content = content[1:-1].strip()
    if not content:
        raise DNSProviderError("DNS TXT content is empty")
    return content


def normalize_record_content(record_type: str, value: str) -> str:
    content = value.strip()
    if not content:
        raise DNSProviderError("DNS record content is empty")
    if record_type == "TXT":
        return normalize_txt_content(content)
    if record_type in {"CNAME", "MX", "NS"}:
        return content.rstrip(".").lower() + "."
    return content


def validate_ttl(value: int) -> int:
    ttl = int(value)
    if ttl != 1 and ttl < 60:
        raise DNSProviderError("DNS TTL must be 1 for automatic or at least 60 seconds")
    return ttl


def zone_relative_record_name(domain_name: str, record_name: str) -> str:
    safe_domain = normalize_domain(domain_name)
    safe_record = normalize_dns_record_name(record_name)
    if safe_record == safe_domain:
        return "@"
    suffix = f".{safe_domain}"
    if safe_record.endswith(suffix):
        return safe_record[: -len(suffix)]
    return f"{safe_record}."


def dkim_record_name(domain_name: str, selector: str) -> str:
    safe_domain = normalize_domain(domain_name)
    safe_selector = validate_selector(selector)
    return f"{safe_selector}._domainkey.{safe_domain}"


def _cloudflare_for_domain(domain) -> CloudflareDNSProvider:
    provider = (getattr(domain, "dns_provider", "") or "").strip().lower()
    if provider != "cloudflare":
        raise DNSProviderError("Only the cloudflare DNS provider is currently supported")
    raw_token = getattr(domain, "dns_api_token", "") or ""
    from .crypto import decrypt_token

    api_token = decrypt_token(raw_token) or ""
    return CloudflareDNSProvider(
        api_token=api_token,
        account_id=getattr(domain, "dns_account_id", "") or "",
        zone_id=getattr(domain, "dns_zone_id", "") or "",
    )


def publish_dkim_record_for_domain(domain, *, ttl: int = DEFAULT_DNS_TTL) -> DNSRecordChange:
    public_key = (getattr(domain, "dkim_public_key", "") or "").strip()
    if not public_key:
        raise DNSProviderError("Generate DKIM before publishing the DNS record")
    cloudflare = _cloudflare_for_domain(domain)
    return cloudflare.upsert_txt_record(
        name=dkim_record_name(getattr(domain, "name", ""), getattr(domain, "dkim_selector", "default") or "default"),
        content=public_key,
        ttl=ttl,
    )


def publish_previous_dkim_record_for_domain(domain, *, ttl: int = DEFAULT_DNS_TTL) -> DNSRecordChange | None:
    previous_selector = (getattr(domain, "dkim_previous_selector", "") or "").strip()
    previous_public = (getattr(domain, "dkim_previous_public_key", "") or "").strip()
    if not previous_selector or not previous_public:
        return None
    cloudflare = _cloudflare_for_domain(domain)
    return cloudflare.upsert_txt_record(
        name=dkim_record_name(getattr(domain, "name", ""), previous_selector),
        content=previous_public,
        ttl=ttl,
    )


def delete_previous_dkim_record_for_domain(domain) -> list[DNSRecordChange]:
    previous_selector = (getattr(domain, "dkim_previous_selector", "") or "").strip()
    if not previous_selector:
        return []
    cloudflare = _cloudflare_for_domain(domain)
    return cloudflare.delete_records(
        record_type="TXT",
        name=dkim_record_name(getattr(domain, "name", ""), previous_selector),
    )


def build_mail_dns_plan(domain, settings) -> list[dict[str, Any]]:
    """Build the managed DNS plan for a domain (Cloudflare publish targets)."""
    domain_name = normalize_domain(getattr(domain, "name", ""))
    mx_host = (getattr(settings, "hostname", "") or domain_name).strip().rstrip(".").lower()
    mail_host_record = f"mail.{domain_name}"
    dmarc_policy = getattr(domain, "dmarc_policy", None) or "reject"
    dkim_selector = getattr(domain, "dkim_selector", None) or "default"
    dkim_public = (getattr(domain, "dkim_public_key", "") or "").strip()
    previous_selector = (getattr(domain, "dkim_previous_selector", "") or "").strip()
    previous_public = (getattr(domain, "dkim_previous_public_key", "") or "").strip()
    spf = f"v=spf1 mx a:{mx_host} -all"
    dmarc = f"v=DMARC1; p={dmarc_policy}; adkim=s; aspf=s; pct=100; rua=mailto:postmaster@{domain_name}"

    plan: list[dict[str, Any]] = [
        {"type": "MX", "name": domain_name, "content": f"{mx_host}.", "priority": 10, "ttl": DEFAULT_DNS_TTL},
        {"type": "TXT", "name": domain_name, "content": spf, "ttl": DEFAULT_DNS_TTL},
        {"type": "TXT", "name": f"_dmarc.{domain_name}", "content": dmarc, "ttl": DEFAULT_DNS_TTL},
    ]
    if mail_host_record != mx_host:
        plan.append({"type": "CNAME", "name": mail_host_record, "content": f"{mx_host}.", "ttl": DEFAULT_DNS_TTL})
    if dkim_public:
        plan.append(
            {
                "type": "TXT",
                "name": dkim_record_name(domain_name, dkim_selector),
                "content": dkim_public,
                "ttl": DEFAULT_DNS_TTL,
            }
        )
    if previous_selector and previous_public and previous_selector != dkim_selector:
        plan.append(
            {
                "type": "TXT",
                "name": dkim_record_name(domain_name, previous_selector),
                "content": previous_public,
                "ttl": DEFAULT_DNS_TTL,
            }
        )
    # Optional discovery / security records for the primary domain.
    if getattr(settings, "enable_mta_sts", False) and domain_name == getattr(settings, "primary_domain", ""):
        mta_sts_host = (getattr(settings, "mta_sts_host", "") or f"mta-sts.{domain_name}").strip().rstrip(".").lower()
        mta_sts_id = getattr(settings, "mta_sts_id", "enforce")
        if mta_sts_host != mx_host:
            plan.append({"type": "CNAME", "name": mta_sts_host, "content": f"{mx_host}.", "ttl": DEFAULT_DNS_TTL})
        plan.append(
            {
                "type": "TXT",
                "name": f"_mta-sts.{domain_name}",
                "content": f"v=STSv1; id={mta_sts_id}",
                "ttl": DEFAULT_DNS_TTL,
            }
        )
    if getattr(settings, "enable_tls_rpt", False) and domain_name == getattr(settings, "primary_domain", ""):
        mailbox = getattr(settings, "tls_rpt_mailbox", f"postmaster@{domain_name}")
        plan.append(
            {
                "type": "TXT",
                "name": f"_smtp._tls.{domain_name}",
                "content": f"v=TLSRPTv1; rua=mailto:{mailbox}",
                "ttl": DEFAULT_DNS_TTL,
            }
        )
    if getattr(settings, "enable_srs", False) and getattr(settings, "srs_domain", "") and getattr(settings, "srs_domain") != domain_name:
        if domain_name == getattr(settings, "primary_domain", ""):
            plan.append(
                {
                    "type": "TXT",
                    "name": settings.srs_domain,
                    "content": spf,
                    "ttl": DEFAULT_DNS_TTL,
                }
            )
    return plan


def publish_mail_dns_for_domain(domain, settings) -> list[dict[str, object]]:
    cloudflare = _cloudflare_for_domain(domain)
    cloudflare._validate_zone_account()
    changes: list[dict[str, object]] = []
    for item in build_mail_dns_plan(domain, settings):
        change = cloudflare.upsert_record(
            record_type=str(item["type"]),
            name=str(item["name"]),
            content=str(item["content"]),
            ttl=int(item.get("ttl") or DEFAULT_DNS_TTL),
            priority=item.get("priority"),
            proxied=False,
        )
        changes.append(change.as_dict())
    return changes
