"""Email security record analysis (SPF, DKIM, DMARC)."""

import re
import subprocess
from dataclasses import dataclass, field
from logging import Logger
from typing import Any

import dns.resolver

from nadzoring.logger import get_logger

logger: Logger = get_logger(__name__)

_DKIM_SELECTORS: tuple[str, ...] = (
    "default",
    "google",
    "k1",
    "mail",
    "dkim",
    "selector1",
    "selector2",
    "s1",
    "s2",
    "smtp",
    "email",
    "mx",
    "key1",
)


@dataclass
class SpfResult:
    """
    SPF record analysis result.

    Attributes:
        found: Whether an SPF record was found.
        record: The raw SPF TXT record string.
        mechanisms: Extracted mechanisms (e.g. ``include:``, ``ip4:``).
        all_qualifier: The catch-all qualifier (``-``, ``~``, ``+``, ``?``).
        issues: List of detected configuration issues.

    """

    found: bool = False
    record: str | None = None
    mechanisms: list[str] = field(default_factory=list)
    all_qualifier: str | None = None
    issues: list[str] = field(default_factory=list)


@dataclass
class DkimResult:
    """
    DKIM record analysis result.

    Attributes:
        found: Whether at least one DKIM record was found.
        records: Mapping of selector to raw DKIM TXT record.
        selectors_checked: All selectors that were probed.
        issues: List of detected configuration issues.

    """

    found: bool = False
    records: dict[str, str] = field(default_factory=dict)
    selectors_checked: list[str] = field(default_factory=list)
    issues: list[str] = field(default_factory=list)


@dataclass
class DmarcResult:
    """
    DMARC record analysis result.

    Attributes:
        found: Whether a DMARC record was found.
        record: The raw DMARC TXT record string.
        policy: The ``p=`` policy value (``none``, ``quarantine``, ``reject``).
        subdomain_policy: The ``sp=`` subdomain policy value.
        pct: The ``pct=`` percentage value.
        rua: Aggregate report addresses.
        ruf: Forensic report addresses.
        issues: List of detected configuration issues.

    """

    found: bool = False
    record: str | None = None
    policy: str | None = None
    subdomain_policy: str | None = None
    pct: int | None = None
    rua: list[str] = field(default_factory=list)
    ruf: list[str] = field(default_factory=list)
    issues: list[str] = field(default_factory=list)


def _join_txt_chunks(line: str) -> str:
    """
    Join multiple quoted chunks from a single TXT record line.

    ``dig +short TXT`` represents long TXT records as multiple quoted
    strings on one line, e.g. ``"v=spf1 include:foo" "~all"``.
    This function extracts all quoted chunks and concatenates them.
    If no quoted chunks are found the raw line is returned stripped.

    Args:
        line: A single line of ``dig +short TXT`` output.

    Returns:
        The full TXT record value as a single string.

    """
    parts: list[str] = re.findall(r'"((?:[^"\\]|\\.)*)"', line)
    return "".join(parts) if parts else line.strip()


def _query_txt(name: str) -> list[str]:
    """
    Retrieve TXT records for a DNS name.

    Tries ``dnspython`` first, then falls back to the system ``dig``
    command, then ``nslookup``.  Multi-chunk TXT records (where a single
    record is split across several quoted strings) are joined into one
    value before being returned.

    Args:
        name: Fully qualified DNS name to query.

    Returns:
        List of complete TXT record strings, one entry per DNS record.

    """
    answers = dns.resolver.resolve(name, "TXT", lifetime=5)
    return [b"".join(r.strings).decode("utf-8", errors="replace") for r in answers]

    try:
        output: str = subprocess.check_output(  # noqa: S603
            ["dig", "+short", "TXT", name],  # noqa: S607
            stderr=subprocess.DEVNULL,
            timeout=10,
            text=True,
        )
        return [_join_txt_chunks(line) for line in output.splitlines() if line.strip()]
    except (
        subprocess.CalledProcessError,
        FileNotFoundError,
        subprocess.TimeoutExpired,
    ):
        pass

    try:
        output = subprocess.check_output(  # noqa: S603
            ["nslookup", "-type=TXT", name],  # noqa: S607
            stderr=subprocess.DEVNULL,
            timeout=10,
            text=True,
        )
        records: list[str] = []
        for line in output.splitlines():
            if "text =" in line.lower():
                raw: str = line.split("=", 1)[1].strip()
                records.append(_join_txt_chunks(raw))
    except (
        subprocess.CalledProcessError,
        FileNotFoundError,
        subprocess.TimeoutExpired,
    ):
        return []
    else:
        return records


def _analyse_spf(domain: str) -> SpfResult:
    """
    Analyse the SPF record for a domain.

    Args:
        domain: The domain to query.

    Returns:
        Populated :class:`SpfResult` instance.

    """
    result = SpfResult()
    records: list[str] = _query_txt(domain)
    spf_records: list[str] = [r for r in records if r.startswith("v=spf1")]

    if not spf_records:
        result.issues.append("No SPF record found")
        return result

    if len(spf_records) > 1:
        result.issues.append("Multiple SPF records found (RFC violation)")

    record: str = spf_records[0]
    result.found = True
    result.record = record

    parts: list[str] = record.split()
    result.mechanisms = [p for p in parts[1:] if not p.startswith("all")]

    for part in parts:
        if part in ("-all", "~all", "+all", "?all") or part == "all":
            result.all_qualifier = part[0] if len(part) > 3 else "+"
            if result.all_qualifier == "+":
                result.issues.append("+all allows any sender (insecure)")

    if result.all_qualifier is None:
        result.issues.append("Missing 'all' mechanism")

    lookup_count: int = sum(
        1
        for m in result.mechanisms
        if any(m.startswith(p) for p in ("include:", "a", "mx", "ptr", "exists:"))
    )
    if lookup_count > 10:
        result.issues.append(f"Exceeds 10 DNS lookup limit ({lookup_count} lookups)")

    return result


def _analyse_dkim(domain: str) -> DkimResult:
    """
    Probe common DKIM selectors for a domain.

    Args:
        domain: The domain to check.

    Returns:
        Populated :class:`DkimResult` instance.

    """
    result = DkimResult(selectors_checked=list(_DKIM_SELECTORS))

    for selector in _DKIM_SELECTORS:
        name: str = f"{selector}._domainkey.{domain}"
        records: list[str] = _query_txt(name)
        dkim_records: list[str] = [r for r in records if "v=DKIM1" in r or "p=" in r]
        if dkim_records:
            result.found = True
            result.records[selector] = dkim_records[0]

    if not result.found:
        result.issues.append("No DKIM records found for common selectors")

    return result


def _analyse_dmarc(domain: str) -> DmarcResult:  # noqa: C901
    """
    Analyse the DMARC record for a domain.

    Args:
        domain: The domain to check.

    Returns:
        Populated :class:`DmarcResult` instance.

    """
    result = DmarcResult()
    name: str = f"_dmarc.{domain}"
    records: list[str] = _query_txt(name)
    dmarc_records: list[str] = [r for r in records if r.startswith("v=DMARC1")]

    if not dmarc_records:
        result.issues.append("No DMARC record found")
        return result

    result.found = True
    result.record = dmarc_records[0]

    tags: dict[str, str] = {}
    for token in result.record.split(";"):
        token = token.strip()  # noqa: PLW2901
        if "=" in token:
            k, _, v = token.partition("=")
            tags[k.strip().lower()] = v.strip()

    result.policy = tags.get("p")
    result.subdomain_policy = tags.get("sp")

    pct_raw: str | None = tags.get("pct")
    if pct_raw is not None:
        try:
            result.pct = int(pct_raw)
        except ValueError:
            result.issues.append(f"Invalid pct value: {pct_raw!r}")

    rua_raw: str | None = tags.get("rua")
    if rua_raw:
        result.rua = [addr.strip() for addr in rua_raw.split(",")]

    ruf_raw: str | None = tags.get("ruf")
    if ruf_raw:
        result.ruf = [addr.strip() for addr in ruf_raw.split(",")]

    if result.policy is None:
        result.issues.append("Missing policy tag (p=)")
    elif result.policy == "none":
        result.issues.append("Policy p=none does not protect against spoofing")

    if not result.rua:
        result.issues.append("No aggregate report address (rua=) configured")

    if result.pct is not None and result.pct < 100:
        result.issues.append(
            f"Policy applies to only {result.pct}% of messages (pct={result.pct})"
        )

    return result


def check_email_security(domain: str) -> dict[str, Any]:
    """
    Check email security configuration for a domain.

    Analyses SPF, DKIM (probing common selectors), and DMARC records and
    returns a structured summary of findings and issues.

    Args:
        domain: The domain to evaluate (e.g. ``"example.com"``).

    Returns:
        Dictionary with the following keys:

        - ``domain`` (str): The queried domain.
        - ``spf`` (dict): SPF analysis with keys ``found``, ``record``,
          ``mechanisms``, ``all_qualifier``, ``issues``.
        - ``dkim`` (dict): DKIM analysis with keys ``found``, ``records``,
          ``selectors_checked``, ``issues``.
        - ``dmarc`` (dict): DMARC analysis with keys ``found``, ``record``,
          ``policy``, ``subdomain_policy``, ``pct``, ``rua``, ``ruf``,
          ``issues``.
        - ``overall_score`` (int): Simple 0-3 score counting how many of
          SPF/DKIM/DMARC were found.
        - ``all_issues`` (list[str]): Concatenation of issues from all
          three checks.

    Examples:
        >>> result = check_email_security("example.com")
        >>> result["spf"]["found"]
        True
        >>> result["dmarc"]["policy"]
        'reject'

    """
    spf: SpfResult = _analyse_spf(domain)
    dkim: DkimResult = _analyse_dkim(domain)
    dmarc: DmarcResult = _analyse_dmarc(domain)

    all_issues: list[str] = spf.issues + dkim.issues + dmarc.issues
    score: int = sum([spf.found, dkim.found, dmarc.found])

    return {
        "domain": domain,
        "spf": {
            "found": spf.found,
            "record": spf.record,
            "mechanisms": spf.mechanisms,
            "all_qualifier": spf.all_qualifier,
            "issues": spf.issues,
        },
        "dkim": {
            "found": dkim.found,
            "records": dkim.records,
            "selectors_checked": dkim.selectors_checked,
            "issues": dkim.issues,
        },
        "dmarc": {
            "found": dmarc.found,
            "record": dmarc.record,
            "policy": dmarc.policy,
            "subdomain_policy": dmarc.subdomain_policy,
            "pct": dmarc.pct,
            "rua": dmarc.rua,
            "ruf": dmarc.ruf,
            "issues": dmarc.issues,
        },
        "overall_score": score,
        "all_issues": all_issues,
    }
