"""Cross-field egress-policy validators run at settings-save time.

Pure functions: each takes the about-to-be-saved ``form_data`` plus the
current ``all_db_settings`` (key -> setting row with a ``.value``) and returns
a validation-error dict ``{"key", "error"}`` to surface in the save response,
or ``None`` when the combination is fine.

The settings write routes (``web/routes/settings_routes.py``) orchestrate these
— they stay in the route layer because they also enforce non-egress concerns —
but the egress rules themselves live here next to the policy they encode.
"""

from .policy import (
    EgressContext,
    EgressScope,
    _classify_host,
    _resolve_with_timeout,
)


def validate_allowed_local_hostnames(form_data, all_db_settings):
    """Reject public hostnames being added to llm.allowed_local_hostnames.

    The default-settings description for this key claims "Public hostnames
    added here are rejected at save time", but until now no code actually
    did the rejection. This guard resolves each entry via the same host
    classifier the policy uses, and refuses any that resolve to public
    addresses. A hostname that fails to resolve (DNS down) is accepted —
    fail-open on transient lookup errors so the user can recover.
    """
    key = "llm.allowed_local_hostnames"
    if key not in form_data:
        return None
    value = form_data[key]
    # Setting is JSON-typed; the save pipeline may hand us a list or a
    # JSON string. Decode defensively.
    if isinstance(value, str):
        try:
            import json as _json

            decoded = _json.loads(value) if value.strip() else []
        except Exception:
            return {
                "key": key,
                "error": "allowed_local_hostnames must be a JSON array of hostnames",
            }
        value = decoded
    if not isinstance(value, list):
        return {
            "key": key,
            "error": "allowed_local_hostnames must be a list",
        }

    # Build a minimal real context just for the resolver. Use the
    # dataclass constructor — NOT EgressContext.__new__ + setattr, which
    # raised FrozenInstanceError on this frozen dataclass and (separately)
    # set a non-existent ``allowed_local_hostnames`` field instead of the
    # real ``local_hostnames`` the classifier reads. The constructor
    # initializes the init=False internals (_dns_cache, _lock as RLock)
    # correctly. Empty local_hostnames => classify purely on IP class.
    probe_ctx = EgressContext(
        scope=EgressScope.BOTH,
        primary_engine="searxng",
        require_local_llm=False,
        require_local_embeddings=False,
        local_hostnames=(),
    )

    rejected = []
    for entry in value:
        if not isinstance(entry, str) or not entry.strip():
            continue
        hostname = entry.strip().lower()
        try:
            # Distinguish "could not resolve" from "resolved to a public IP".
            # _classify_host collapses BOTH to False (its documented fail-safe
            # treats an unresolvable host as public), so relying on it here
            # would reject a legitimate intranet/VPN host on any DNS hiccup or
            # split-horizon DNS — the exact use case this setting exists for.
            # Only reject names that actually resolve to a public address;
            # accept unresolvable ones (fail-open on save, as documented).
            # _resolve_with_timeout returns the addrinfo for literal IPs too,
            # so literal public/private IPs still flow through _classify_host.
            if _resolve_with_timeout(hostname) is None:
                continue
            classification = _classify_host(hostname, probe_ctx)
        except Exception:
            # DNS or unknown error — allow (fail open) so the user can
            # save when networking is flaky. Runtime classification will
            # still gate egress.
            continue
        if classification is False:
            rejected.append(hostname)
    if rejected:
        return {
            "key": key,
            "error": (
                "These hostnames resolve to PUBLIC addresses and would "
                "let the policy treat external hosts as local: "
                f"{', '.join(rejected)}. Remove them, or use the SSRF "
                "allowlist instead."
            ),
        }
    return None
