"""
scrylo/email_tools/mx_validator.py
------------------------------------
MX record validator for email address candidates.

Strategy:
  1. Extract domain from candidate emails
  2. DNS MX record lookup (single lookup per domain — all candidates share it)
  3. Detect catch-all domains (accepts any address) — flag, don't reject
  4. Score confidence based on MX presence + mail provider
  5. Return best candidate with confidence score

FIXES vs previous version:
  - verify_candidates now respects a known_pattern argument: when the
    domain pattern was already observed from a scraped email, that format
    is returned directly rather than always returning candidates[0].
  - Catch-all detection added: a catch-all domain (accepts *@domain.com)
    still has MX records, but pattern-guessing against it gives false
    confidence. We now detect and flag it so the caller can downgrade
    the confidence score appropriately.
  - Resolver lifetime/timeout made configurable via module constants.

Confidence scoring:
  1.0  — MX found, known mail provider (Google WS, Microsoft 365, etc.)
  0.8  — MX found, unknown/self-hosted provider
  0.5  — MX found but domain is catch-all (any address accepted)
  0.0  — No MX records (domain cannot receive email)
  None — DNS timeout / unresolvable (treat as unknown, not failed)
"""

from __future__ import annotations

import logging
import smtplib
import socket
from dataclasses import dataclass

import dns.resolver
import dns.exception

log = logging.getLogger("scrylo.mx_validator")

# ── Tunable constants ──────────────────────────────────────────────────────

_RESOLVER_LIFETIME = 5.0   # total time per DNS query (seconds)
_RESOLVER_TIMEOUT  = 3.0   # per-nameserver timeout (seconds)
_SMTP_TIMEOUT      = 6     # seconds for catch-all probe connection

# ── Known mail providers → higher confidence ──────────────────────────────

_KNOWN_PROVIDERS = {
    "google.com", "googlemail.com",
    "outlook.com", "hotmail.com",
    "protection.outlook.com",
    "mail.protection.outlook.com",
    "mimecast.com",
    "pphosted.com",
    "messagelabs.com",
    "mailgun.org",
    "sendgrid.net",
    "amazonses.com",
    "zoho.com",
    "fastmail.com",
    "protonmail.ch",
}

# ── DNS resolver singleton ─────────────────────────────────────────────────

_RESOLVER = dns.resolver.Resolver()
_RESOLVER.lifetime = _RESOLVER_LIFETIME
_RESOLVER.timeout  = _RESOLVER_TIMEOUT


# ── Result type ────────────────────────────────────────────────────────────

@dataclass
class MxResult:
    email:      str
    domain:     str
    has_mx:     bool
    confidence: float         # 0.0–1.0
    mx_host:    str           # primary MX hostname (for debugging/logging)
    is_catchall: bool = False # True if domain accepts mail to any address
    error:      str | None = None


# ── Core MX lookup ─────────────────────────────────────────────────────────

def check_mx(email: str) -> MxResult:
    """
    Check whether the domain of an email address has valid MX records.
    Does NOT attempt to verify the specific mailbox.

    Args:
        email: Full email address e.g. "jane.smith@stackly.com"

    Returns:
        MxResult with has_mx, confidence, mx_host, and is_catchall.
    """
    email = email.strip().lower()
    if "@" not in email:
        return MxResult(email=email, domain="", has_mx=False,
                        confidence=0.0, mx_host="", error="invalid email format")

    domain = email.split("@", 1)[1]

    try:
        records = _RESOLVER.resolve(domain, "MX")
    except dns.resolver.NXDOMAIN:
        return MxResult(email=email, domain=domain, has_mx=False,
                        confidence=0.0, mx_host="", error="domain does not exist")
    except dns.resolver.NoAnswer:
        return MxResult(email=email, domain=domain, has_mx=False,
                        confidence=0.0, mx_host="", error="no MX records")
    except dns.exception.Timeout:
        # Timeout ≠ no MX — treat as unknown rather than failed
        return MxResult(email=email, domain=domain, has_mx=False,
                        confidence=0.0, mx_host="", error="DNS timeout")
    except Exception as e:
        return MxResult(email=email, domain=domain, has_mx=False,
                        confidence=0.0, mx_host="", error=str(e))

    sorted_records = sorted(records, key=lambda r: r.preference)
    primary_mx = str(sorted_records[0].exchange).rstrip(".").lower()

    known = any(provider in primary_mx for provider in _KNOWN_PROVIDERS)
    base_confidence = 1.0 if known else 0.8

    # Catch-all detection — probe with a provably non-existent address.
    # If accepted, this domain accepts *everything* — confidence drops.
    is_catchall = _is_catchall_domain(domain, primary_mx)
    if is_catchall:
        base_confidence = 0.5
        log.debug("Catch-all domain detected: %s", domain)

    return MxResult(
        email=email,
        domain=domain,
        has_mx=True,
        confidence=base_confidence,
        mx_host=primary_mx,
        is_catchall=is_catchall,
    )


def _is_catchall_domain(domain: str, mx_host: str) -> bool:
    """
    Probe with a fake address at this domain.
    If the MX server accepts it, the domain is a catch-all.

    Returns False on any error (timeout, connection refused, etc.)
    so that network failures never falsely downgrade real domains.
    """
    fake = f"zzz_no_such_user_xyz_probe@{domain}"
    try:
        mx = mx_host.rstrip(".")
        with smtplib.SMTP(timeout=_SMTP_TIMEOUT) as smtp:
            smtp.connect(mx)
            smtp.helo("check.local")
            smtp.mail("probe@check.local")
            code, _ = smtp.rcpt(fake)
            return code == 250
    except (socket.timeout, ConnectionRefusedError, smtplib.SMTPConnectError,
            smtplib.SMTPServerDisconnected, OSError):
        # Server unreachable or refused — don't penalise the domain
        return False
    except Exception:
        return False


# ── Batch candidate verifier ───────────────────────────────────────────────

def verify_candidates(
    candidates: list[str],
    known_pattern: str = "",
) -> MxResult | None:
    """
    Check all candidate emails for the same domain — one DNS lookup total.
    Returns an MxResult pointing at the best candidate to try, or None if
    the domain has no MX records at all.

    Args:
        candidates:    List of email candidates (must all share one domain).
        known_pattern: If a pattern was already observed from a scraped email
                       on the domain (e.g. "first.last"), prefer that format.
                       Pass as "first.last", "flast", "first", etc.
                       If empty, candidates[0] (most common pattern) is used.

    Returns:
        MxResult where .email is the best candidate to try first.
        Returns None only if candidates is empty.
    """
    if not candidates:
        return None

    domain = candidates[0].split("@", 1)[1] if "@" in candidates[0] else ""
    if not domain:
        return None

    # Single MX lookup for the shared domain
    probe_result = check_mx(candidates[0])

    if not probe_result.has_mx:
        return probe_result   # Domain can't receive mail — surface the error

    # Choose best candidate
    best = candidates[0]   # default: highest-frequency pattern

    if known_pattern:
        # Match observed pattern against our candidates
        for c in candidates:
            local = c.split("@")[0]
            if _matches_pattern(local, known_pattern):
                best = c
                break

    return MxResult(
        email=best,
        domain=domain,
        has_mx=True,
        confidence=probe_result.confidence,
        mx_host=probe_result.mx_host,
        is_catchall=probe_result.is_catchall,
    )


def _matches_pattern(local: str, pattern: str) -> bool:
    """
    Check if an email local part matches a pattern description.
    Pattern strings match what you'd see in Hunter.io style output.

    "jane.smith"  → matches "first.last"
    "jsmith"      → matches "flast"
    "jane"        → matches "first"
    """
    parts = local.split(".")
    p = pattern.lower().replace(" ", "").replace("_", "")

    if p in ("firstlast", "first.last") and "." in local:
        return True
    if p in ("flast",) and len(parts) == 1 and len(local) > 2:
        # No dot, starts with a single letter — heuristic for flast
        return len(local[0]) == 1 or not local[0].isalpha()
    if p in ("first",) and "." not in local:
        return True
    return False
