"""Pre-call argument validation for high-risk outbound tools.

A separate ``ToolHook`` from ``ContentInspectionHook`` — that hook is an
after-output adapter; argument policy is a distinct concern.

Scope: editor freeform egress tools only (``fetch_reference_content``,
``search_claim``).  These are the highest-risk outbound surfaces because
an injected LLM can be tricked into fetching attacker-controlled URLs
or crafting exfiltration queries.
"""

from __future__ import annotations

import ipaddress
import re
from typing import Literal
from urllib.parse import urlparse

import structlog

from src.tooling.runtime import ToolCallBlocked, ToolInvocation, ToolResult

logger = structlog.get_logger(__name__)

# Maximum URL query-string length before flagging.
_MAX_QUERY_STRING_LENGTH = 500

# Maximum search query text length.
_MAX_SEARCH_QUERY_LENGTH = 500

# Allowed URL schemes.
_ALLOWED_SCHEMES = frozenset({"http", "https"})

# Suspicious patterns in URLs (data exfiltration signals).
_SUSPICIOUS_URL_PATTERNS: list[re.Pattern[str]] = [
    re.compile(r"webhook\.site", re.I),
    re.compile(r"requestbin\.com", re.I),
    re.compile(r"pipedream\.net", re.I),
    re.compile(r"ngrok\.io", re.I),
    re.compile(r"burpcollaborator\.net", re.I),
]

_PRIVATE_NETWORKS = (
    ipaddress.ip_network("10.0.0.0/8"),
    ipaddress.ip_network("172.16.0.0/12"),
    ipaddress.ip_network("192.168.0.0/16"),
    ipaddress.ip_network("169.254.0.0/16"),
    ipaddress.ip_network("127.0.0.0/8"),
    ipaddress.ip_network("::1/128"),
    ipaddress.ip_network("fc00::/7"),
    ipaddress.ip_network("fe80::/10"),
)

_BLOCKED_HOSTNAMES = frozenset(
    {
        "localhost",
        "metadata.google.internal",
        "metadata.google",
        "instance-data",
    }
)


def _hostname_is_blocked(hostname: str) -> bool:
    normalized = hostname.strip().lower().rstrip(".")
    if normalized in _BLOCKED_HOSTNAMES:
        return True

    try:
        addr = ipaddress.ip_address(normalized)
    except ValueError:
        return False

    return any(addr in network for network in _PRIVATE_NETWORKS)


def _hostname_matches_allowed_domain(hostname: str, allowed_domain: str) -> bool:
    normalized_host = hostname.strip().lower().rstrip(".")
    normalized_domain = allowed_domain.strip().lower().rstrip(".")
    return normalized_host == normalized_domain or normalized_host.endswith(
        f".{normalized_domain}"
    )


def _is_reasonable_reference_url(
    url: str,
    *,
    allowed_reference_domains: frozenset[str] | None = None,
) -> bool:
    """Return True if the URL looks like a legitimate reference."""
    try:
        parsed = urlparse(url)
    except Exception:
        return False

    if parsed.scheme not in _ALLOWED_SCHEMES:
        return False

    if not parsed.hostname:
        return False

    if _hostname_is_blocked(parsed.hostname):
        return False

    if allowed_reference_domains is not None and not any(
        _hostname_matches_allowed_domain(parsed.hostname, domain)
        for domain in allowed_reference_domains
    ):
        return False

    # Flag excessively long query strings (exfiltration signal).
    if parsed.query and len(parsed.query) > _MAX_QUERY_STRING_LENGTH:
        return False

    # Flag known exfiltration endpoints.
    for pattern in _SUSPICIOUS_URL_PATTERNS:
        if pattern.search(url):
            return False

    return True


def _looks_like_pasted_payload(query: str) -> bool:
    """Return True if the search query looks like pasted content rather than a query."""
    # Multiple newlines suggest pasted block content.
    if query.count("\n") > 3:
        return True
    # High ratio of special characters.
    special = sum(1 for c in query if c in "{}[]<>/\\|=")
    if len(query) > 50 and special / len(query) > 0.15:
        return True
    return False


class ToolArgumentPolicyHook:
    """Pre-call argument validation for high-risk outbound tools.

    Implements ``ToolHook`` protocol.
    """

    def __init__(
        self,
        mode: Literal["warn", "block"] = "warn",
        *,
        allowed_reference_domains: frozenset[str] | None = None,
    ) -> None:
        self._mode = mode
        self._allowed_reference_domains = allowed_reference_domains

    async def before(self, call: ToolInvocation) -> ToolInvocation:
        if call.source != "editor":
            return call

        if call.name == "fetch_reference_content":
            url = str(call.args.get("url", ""))
            if not _is_reasonable_reference_url(
                url,
                allowed_reference_domains=self._allowed_reference_domains,
            ):
                if self._mode == "block":
                    raise ToolCallBlocked("editor reference URL rejected by policy")
                logger.warning(
                    "outbound_url_suspicious",
                    url=url[:120],
                    tool=call.name,
                    agent_key=call.agent_key,
                )

        if call.name == "search_claim":
            query = str(call.args.get("query", ""))
            if len(query) > _MAX_SEARCH_QUERY_LENGTH or _looks_like_pasted_payload(
                query
            ):
                if self._mode == "block":
                    raise ToolCallBlocked("editor search query rejected by policy")
                logger.warning(
                    "outbound_query_suspicious",
                    query=query[:80],
                    tool=call.name,
                    agent_key=call.agent_key,
                )

        return call

    async def after(self, call: ToolInvocation, result: ToolResult) -> ToolResult:
        return result  # pass-through
