#
#  Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.
#
"""Shared SSRF-guard utilities.

Uses only the standard library so it can be imported from both ``api/`` and
``common/`` without pulling in any heavyweight dependencies.
"""

import ipaddress
import logging
import socket
import threading
from contextlib import contextmanager
from urllib.parse import urlparse

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# DNS pinning — closes the TOCTOU / rebinding window between SSRF validation
# and the actual TCP connection.  The monkey-patch is a no-op for any host
# that has no active pin, so it cannot affect unrelated code.
# ---------------------------------------------------------------------------

_tl = threading.local()
_global_dns_pins: dict[str, str] = {}
_global_pin_lock = threading.Lock()
_orig_getaddrinfo = socket.getaddrinfo


def _getaddrinfo_with_pins(host, port, *args, **kwargs):
    # Thread-local pins (synchronous callers: requests.get in the same thread)
    local_pins: dict = getattr(_tl, "dns_pins", {})
    if host in local_pins:
        ip = local_pins[host]
        family = socket.AF_INET6 if ":" in ip else socket.AF_INET
        return [(family, socket.SOCK_STREAM, 6, "", (ip, port or 0))]
    # Process-global pins (async callers whose DNS resolves in executor threads)
    with _global_pin_lock:
        ip = _global_dns_pins.get(host)
    if ip is not None:
        family = socket.AF_INET6 if ":" in ip else socket.AF_INET
        return [(family, socket.SOCK_STREAM, 6, "", (ip, port or 0))]
    return _orig_getaddrinfo(host, port, *args, **kwargs)


socket.getaddrinfo = _getaddrinfo_with_pins


@contextmanager
def pin_dns(hostname: str, ip: str):
    """Pin *hostname* → *ip* in the current thread for the duration of this context.

    Use for synchronous ``requests.get()`` callers to prevent DNS rebinding
    between SSRF validation and the actual TCP connection.
    """
    pins = _tl.__dict__.setdefault("dns_pins", {})
    pins[hostname] = ip
    try:
        yield
    finally:
        pins.pop(hostname, None)


@contextmanager
def pin_dns_global(hostname: str, ip: str):
    """Pin *hostname* → *ip* across all threads for the duration of this context.

    Use for async callers (e.g. asyncio-based crawlers) where DNS resolution
    may happen in thread-pool executor threads rather than the calling thread.
    """
    with _global_pin_lock:
        _global_dns_pins[hostname] = ip
    try:
        yield
    finally:
        with _global_pin_lock:
            _global_dns_pins.pop(hostname, None)


_DEFAULT_ALLOWED_SCHEMES: frozenset[str] = frozenset({"http", "https"})


def _effective_ip(
    ip: ipaddress.IPv4Address | ipaddress.IPv6Address,
) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
    """Return the IPv4 equivalent for IPv4-mapped IPv6 addresses, unchanged otherwise.

    Without this normalization ``::ffff:127.0.0.1`` would pass ``is_global``
    as an IPv6Address in some Python versions, bypassing the loopback check.
    """
    if isinstance(ip, ipaddress.IPv6Address):
        mapped = ip.ipv4_mapped
        if mapped is not None:
            return mapped
    return ip


def assert_url_is_safe(
    url: str,
    *,
    allowed_schemes: frozenset[str] = _DEFAULT_ALLOWED_SCHEMES,
) -> tuple[str, str]:
    """Raise ``ValueError`` if *url* is not safe to fetch (SSRF guard).

    Checks performed in order:

    1. Scheme is in *allowed_schemes*.
    2. Hostname is present.
    3. **Every** address returned by ``getaddrinfo`` is globally routable
       (``ip.is_global``).  This is an allowlist approach: it catches private,
       loopback, link-local, reserved, multicast, and all other
       special-purpose ranges rather than individual deny-list flags.
       IPv4-mapped IPv6 addresses (e.g. ``::ffff:127.0.0.1``) are normalised
       to their IPv4 form via :func:`_effective_ip` before the check.

    Returns ``(hostname, resolved_ip)`` — the first validated public IP string
    — so the caller can **pin** that address in its HTTP client and prevent
    DNS-rebinding attacks (the hostname is resolved exactly once).
    """
    parsed = urlparse(url)
    scheme = parsed.scheme
    if scheme not in allowed_schemes:
        logger.warning(
            "SSRF guard blocked URL with disallowed scheme: scheme=%r url=%r",
            scheme,
            url,
        )
        raise ValueError(f"Disallowed URL scheme: {scheme!r}. Only {sorted(allowed_schemes)} are allowed.")

    hostname = parsed.hostname
    if not hostname:
        logger.warning("SSRF guard blocked URL with missing host: url=%r", url)
        raise ValueError("URL is missing a host.")

    try:
        addr_infos = socket.getaddrinfo(hostname, None)
    except socket.gaierror as exc:
        logger.warning("SSRF guard could not resolve hostname=%r reason=%s", hostname, exc)
        raise ValueError(f"Could not resolve hostname {hostname!r}: {exc}") from exc

    resolved_ip: str | None = None
    for _family, _type, _proto, _canonname, sockaddr in addr_infos:
        raw_ip = ipaddress.ip_address(sockaddr[0])
        eff_ip = _effective_ip(raw_ip)
        if not eff_ip.is_global:
            logger.warning(
                "SSRF guard blocked URL: hostname=%r resolved to non-public address=%s",
                hostname,
                raw_ip,
            )
            raise ValueError(f"URL resolves to a non-public address ({raw_ip}), which is not allowed.")
        if resolved_ip is None:
            resolved_ip = str(raw_ip)

    if resolved_ip is None:
        logger.warning("SSRF guard blocked URL: hostname=%r resolved to no addresses", hostname)
        raise ValueError(f"Hostname {hostname!r} resolved to no addresses.")

    return hostname, resolved_ip


def assert_host_is_safe(host: str) -> str:
    """Raise ``ValueError`` if *host* resolves to a non-public IP (SSRF guard for raw host/port connections).

    This is the host-level counterpart of :func:`assert_url_is_safe`, intended
    for callers that connect via database drivers or other non-HTTP protocols
    where there is no URL to parse.

    Returns the first validated public IP string so the caller can pin it if needed.
    """
    if not host:
        raise ValueError("Host must not be empty.")

    try:
        addr_infos = socket.getaddrinfo(host, None)
    except socket.gaierror as exc:
        logger.warning("SSRF guard could not resolve host=%r reason=%s", host, exc)
        raise ValueError(f"Could not resolve host {host!r}: {exc}") from exc

    resolved_ip: str | None = None
    for _family, _type, _proto, _canonname, sockaddr in addr_infos:
        raw_ip = ipaddress.ip_address(sockaddr[0])
        eff_ip = _effective_ip(raw_ip)
        if not eff_ip.is_global:
            logger.warning(
                "SSRF guard blocked host: host=%r resolved to non-public address=%s",
                host,
                raw_ip,
            )
            raise ValueError(f"Host resolves to a non-public address ({raw_ip}), which is not allowed.")
        if resolved_ip is None:
            resolved_ip = str(raw_ip)

    if resolved_ip is None:
        logger.warning("SSRF guard blocked host: host=%r resolved to no addresses", host)
        raise ValueError(f"Host {host!r} resolved to no addresses.")

    return resolved_ip
