"""Validation utilities for image sources (URLs and local file paths).

Prevents SSRF (Server-Side Request Forgery) by:
- Restricting URL schemes to http/https
- Resolving the hostname and rejecting private/loopback/link-local/reserved/
  multicast IPs
- Restricting file path access to known image extensions

Also exposes a redirect-aware fetcher (``safe_fetch``) that re-validates each
intermediate URL hop, so an attacker cannot use an open redirect to reach
internal addresses.
"""

import ipaddress
import socket
from pathlib import Path
from typing import Iterable
from urllib.parse import urlparse

import httpx
from fastapi import HTTPException

from ledgrab.utils.net_classify import is_blocked_for_ssrf

# Image file extensions considered safe to serve
_IMAGE_EXTENSIONS = frozenset(
    {
        ".jpg",
        ".jpeg",
        ".png",
        ".bmp",
        ".gif",
        ".webp",
        ".tiff",
        ".tif",
        ".ico",
    }
)


def _is_blocked_ip(ip: str) -> bool:
    """Return True when *ip* belongs to a category we refuse to fetch from.

    Thin wrapper preserved for the local test suite. The actual policy now
    lives in :func:`ledgrab.utils.net_classify.is_blocked_for_ssrf` so it
    can't drift away from the related ``url_scheme`` / ``auth`` modules.
    """
    return is_blocked_for_ssrf(ip)


def _resolve_hostname(hostname: str) -> Iterable[str]:
    """Resolve *hostname* to all IPv4/IPv6 addresses."""
    try:
        infos = socket.getaddrinfo(hostname, None)
    except socket.gaierror as exc:
        raise HTTPException(
            status_code=400, detail=f"Cannot resolve hostname: {hostname} ({exc})"
        ) from exc
    return {info[4][0] for info in infos}


def validate_image_url(url: str) -> None:
    """Validate that *url* is safe to fetch.

    Strict SSRF policy — blocks all private / loopback / link-local /
    reserved / multicast / unspecified IPs. Intended for fetches of
    user-supplied URLs that should reach the *public* internet only
    (image sources, weather APIs, etc.).

    Checks:
    - Scheme is http/https
    - Hostname is present
    - Hostname is not a literal blocked IP
    - DNS resolves to non-private, non-loopback, non-link-local, non-reserved,
      non-multicast addresses (SSRF protection)
    """
    _validate_url(url, allow_private=False)


def validate_polling_url(url: str) -> None:
    """Validate that *url* is safe to poll from an automation trigger.

    Relaxed SSRF policy — allows PRIVATE (LAN) IPs so users can target
    devices on their own network (Plex on 192.168.x.x, Home Assistant
    on 10.x.x.x). Still blocks LOOPBACK (protects the server's own
    admin ports), LINK_LOCAL (blocks 169.254.169.254 cloud-metadata
    services), MULTICAST, RESERVED, and UNSPECIFIED.

    Use this instead of :func:`validate_image_url` for any user-configured
    HTTP polling target.
    """
    _validate_url(url, allow_private=True)


def _validate_url(url: str, *, allow_private: bool) -> None:
    parsed = urlparse(url)
    if parsed.scheme not in ("http", "https"):
        raise HTTPException(
            status_code=400,
            detail=f"Unsupported URL scheme: {parsed.scheme!r}. Only http and https are allowed.",
        )

    hostname = parsed.hostname
    if not hostname:
        raise HTTPException(status_code=400, detail="URL has no hostname")

    # If hostname IS an IP literal, skip DNS but still check the category.
    try:
        ipaddress.ip_address(hostname)
        ips: Iterable[str] = [hostname]
    except ValueError:
        ips = _resolve_hostname(hostname)

    for ip in ips:
        if _ip_is_blocked(ip, allow_private=allow_private):
            policy = (
                "loopback / link-local / reserved / multicast"
                if allow_private
                else "private / loopback / link-local / reserved / multicast"
            )
            raise HTTPException(
                status_code=400,
                detail=(
                    f"Refusing to fetch URL: hostname {hostname!r} resolves to "
                    f"blocked address {ip} ({policy})"
                ),
            )


def _ip_is_blocked(ip: str, *, allow_private: bool) -> bool:
    """Block-list predicate parameterised by whether PRIVATE is permitted."""
    from ledgrab.utils.net_classify import HostCategory, classify_ip

    category = classify_ip(ip)
    always_block = {
        HostCategory.LOOPBACK,
        HostCategory.LINK_LOCAL,
        HostCategory.MULTICAST,
        HostCategory.RESERVED,
        HostCategory.UNSPECIFIED,
        HostCategory.UNPARSEABLE,
    }
    if category in always_block:
        return True
    if not allow_private and category == HostCategory.PRIVATE:
        return True
    return False


def validate_image_path(file_path: str | Path) -> Path:
    """Validate a local file path points to a real image file.

    Checks:
    - The extension is a known image format
    - The resolved path does not escape via symlinks to unexpected locations

    Returns the resolved Path on success, raises HTTPException on violation.
    """
    resolved = Path(file_path).resolve()

    suffix = resolved.suffix.lower()
    if suffix not in _IMAGE_EXTENSIONS:
        raise HTTPException(
            status_code=400,
            detail=f"Unsupported file type: {suffix!r}. Only image files are allowed.",
        )

    return resolved


# ---------------------------------------------------------------------------
# Safe HTTP fetch (manual redirect handling with per-hop validation)
# ---------------------------------------------------------------------------


_DEFAULT_MAX_HOPS = 3


async def safe_fetch(
    url: str,
    *,
    timeout: float = 15.0,
    max_hops: int = _DEFAULT_MAX_HOPS,
) -> httpx.Response:
    """Fetch *url* with SSRF protection and manual redirect handling.

    Each redirect target is re-validated via :func:`validate_image_url` so
    an attacker cannot use an open redirect at a public host to pivot to
    internal addresses.

    Raises:
        HTTPException: on validation failure or too many redirects.
        httpx.HTTPStatusError / httpx.RequestError: for transport errors.
    """
    current = url
    async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
        for _ in range(max_hops + 1):
            validate_image_url(current)
            response = await client.get(current)
            if response.is_redirect:
                location = response.headers.get("location")
                if not location:
                    raise HTTPException(status_code=400, detail="Redirect without Location header")
                # Resolve relative redirects against the current URL
                next_url = str(httpx.URL(current).join(location))
                current = next_url
                continue
            response.raise_for_status()
            return response
    raise HTTPException(status_code=400, detail=f"Too many redirects (max {max_hops})")


# ---------------------------------------------------------------------------
# Bounded safe request — for polling-style fetches that need streaming
# + size cap + arbitrary method/headers. Used by the HTTP poll runtime.
# ---------------------------------------------------------------------------


# Hard cap to avoid OOM from polled endpoints returning huge bodies.
# 1 MiB covers every realistic JSON status endpoint by orders of magnitude.
_MAX_RESPONSE_BYTES = 1 * 1024 * 1024


async def safe_request_bounded(
    method: str,
    url: str,
    *,
    headers: dict[str, str] | None = None,
    timeout: float = 10.0,
    max_bytes: int = _MAX_RESPONSE_BYTES,
    allow_private: bool = True,
) -> tuple[int, bytes, str | None]:
    """Make a single HTTP request with SSRF validation and a body-size cap.

    Validates the URL up-front (scheme + IP-block) and streams the response
    body, aborting as soon as ``max_bytes`` is exceeded so a malicious or
    misconfigured endpoint can't OOM the server.

    ``allow_private`` selects the SSRF policy: ``True`` (default — for HTTP
    polling against user-owned LAN devices) routes through
    :func:`validate_polling_url`; ``False`` routes through
    :func:`validate_image_url`'s strict public-only policy.

    Note on DNS rebinding: this validates the URL hostname's resolved IPs
    once, then httpx independently re-resolves to make the actual request.
    The window between the two resolutions is short but not zero. True
    IP-pinning (rewriting the URL to the literal IP + Host header) is a
    project-wide concern that would also need to update ``safe_fetch`` and
    every other outbound caller; tracked as a follow-up.

    Returns:
        ``(status_code, body_bytes, error_message)``. ``error_message`` is
        ``None`` on success; on transport failure ``status_code`` is 0 and
        ``body_bytes`` is empty.

    Raises:
        HTTPException: when the URL fails validation (4xx-style error
        intended for callers that surface this directly to the user).
    """
    if allow_private:
        validate_polling_url(url)
    else:
        validate_image_url(url)
    method = method.upper()
    headers = dict(headers or {})

    try:
        async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
            async with client.stream(method, url, headers=headers) as response:
                chunks: list[bytes] = []
                total = 0
                truncated = False
                async for chunk in response.aiter_bytes():
                    if total + len(chunk) > max_bytes:
                        chunks.append(chunk[: max_bytes - total])
                        total = max_bytes
                        truncated = True
                        break
                    chunks.append(chunk)
                    total += len(chunk)
                body = b"".join(chunks)
                # Truncation is NOT an error — the partial body is returned
                # for the caller to use (e.g. evaluate a JSON path against the
                # first 1 MiB). Callers that care can detect it by comparing
                # ``len(body) == max_bytes``. We only flag actual transport
                # failures in the error slot.
                _ = truncated
                return response.status_code, body, None
    except httpx.RequestError as exc:
        # Never include `exc` repr in the message — some httpx errors include
        # request URL + headers, which could leak auth tokens supplied by the
        # caller. Type name is sufficient diagnostic.
        return 0, b"", f"Request failed: {type(exc).__name__}"
    except Exception as exc:
        return 0, b"", f"Unexpected error: {type(exc).__name__}"
