"""Tests for web_fetch SSRF protection and untrusted content marking."""

from __future__ import annotations

import asyncio
import json
import socket
from unittest.mock import patch

import httpx
import pytest

from nanobot.agent.tools import web as web_module
from nanobot.agent.tools.web import WebFetchTool, _get_with_safe_redirects
from nanobot.config.schema import WebFetchConfig
from nanobot.security.network import PinnedDNSAsyncTransport
from nanobot.security.workspace_access import (
    bind_workspace_scope,
    build_workspace_scope,
    reset_workspace_scope,
)

_REAL_GETADDRINFO = socket.getaddrinfo
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")


@pytest.fixture(autouse=True)
def _clear_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None:
    for name in (*_PROXY_ENV_VARS, "NO_PROXY", "no_proxy"):
        monkeypatch.delenv(name, raising=False)


def _fake_resolve_private(hostname, port, family=0, type_=0):
    return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("169.254.169.254", 0))]


def _fake_resolve_public(hostname, port, family=0, type_=0):
    return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("93.184.216.34", 0))]


def _patch_web_fetch_fake_client(monkeypatch: pytest.MonkeyPatch) -> list[dict]:
    client_kwargs: list[dict] = []

    class FakeStreamResponse:
        status_code = 200
        headers = {"content-type": "text/html"}
        url = "https://example.com/page"

        async def __aenter__(self):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            return False

    class FakeJinaResponse:
        status_code = 200

        def raise_for_status(self):
            return None

        def json(self):
            return {"data": {"title": "Example", "content": "Hello", "url": "https://example.com/page"}}

    class FakeClient:
        def __init__(self, *args, **kwargs):
            client_kwargs.append(kwargs)

        async def __aenter__(self):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            return False

        def stream(self, method, url, headers=None, **kwargs):
            return FakeStreamResponse()

        async def get(self, url, headers=None, **kwargs):
            return FakeJinaResponse()

    monkeypatch.setattr(web_module.httpx, "AsyncClient", FakeClient)
    monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
    monkeypatch.setattr(
        "nanobot.security.network.httpx.AsyncHTTPTransport",
        lambda **_kwargs: object(),
    )
    return client_kwargs


@pytest.mark.asyncio
async def test_web_fetch_blocks_private_ip():
    tool = WebFetchTool()
    with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_private):
        result = await tool.execute(url="http://169.254.169.254/computeMetadata/v1/")
    data = json.loads(result)
    assert "error" in data
    assert "private" in data["error"].lower() or "blocked" in data["error"].lower()


@pytest.mark.asyncio
async def test_web_fetch_blocks_localhost():
    tool = WebFetchTool()
    def _resolve_localhost(hostname, port, family=0, type_=0):
        return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))]
    with patch("nanobot.security.network.socket.getaddrinfo", _resolve_localhost):
        result = await tool.execute(url="http://localhost/admin")
    data = json.loads(result)
    assert "error" in data


@pytest.mark.asyncio
async def test_web_fetch_blocks_localhost_even_in_full_workspace_scope(tmp_path):
    tool = WebFetchTool()
    scope = build_workspace_scope(tmp_path, "full")

    def _resolve_localhost(hostname, port, family=0, type_=0):
        return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))]

    token = bind_workspace_scope(scope)
    try:
        with patch("nanobot.security.network.socket.getaddrinfo", _resolve_localhost):
            result = await tool.execute(url="http://localhost/admin")
    finally:
        reset_workspace_scope(token)
    data = json.loads(result)
    assert "error" in data


@pytest.mark.asyncio
async def test_web_fetch_result_contains_untrusted_flag(monkeypatch: pytest.MonkeyPatch):
    """When fetch succeeds, result JSON must include untrusted=True and the banner."""
    tool = WebFetchTool()
    _patch_web_fetch_fake_client(monkeypatch)

    with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
        result = await tool.execute(url="https://example.com/page")

    data = json.loads(result)
    assert data.get("untrusted") is True
    assert "[External content" in data.get("text", "")


@pytest.mark.asyncio
async def test_safe_redirect_requests_use_independent_pinned_dns_concurrently(monkeypatch):
    public_ips = {
        "a.example": "93.184.216.34",
        "b.example": "93.184.216.35",
    }
    calls: dict[str, int] = {host: 0 for host in public_ips}
    seen: dict[str, str] = {}

    def _rebinding_resolver(hostname, port, family=0, type_=0, proto=0, flags=0):
        host = str(hostname).rstrip(".").lower()
        calls[host] += 1
        ip = public_ips[host] if calls[host] <= 2 else "169.254.169.254"
        return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", (ip, 0))]

    class ResolvingTransport(httpx.AsyncBaseTransport):
        async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
            await asyncio.sleep(0)
            infos = socket.getaddrinfo(
                request.url.host,
                request.url.port or 443,
                socket.AF_UNSPEC,
                socket.SOCK_STREAM,
            )
            seen[str(request.url)] = infos[0][4][0]
            return httpx.Response(200, request=request)

    async def _fetch(url: str) -> tuple[httpx.Response | None, str | None]:
        async with httpx.AsyncClient(
            transport=PinnedDNSAsyncTransport(inner=ResolvingTransport())
        ) as client:
            return await _get_with_safe_redirects(client, url)

    monkeypatch.setattr("nanobot.security.network.socket.getaddrinfo", _rebinding_resolver)

    results = await asyncio.gather(
        _fetch("https://a.example/"),
        _fetch("https://b.example/"),
    )

    assert all(error is None and response is not None for response, error in results)
    assert seen == {
        "https://a.example/": "93.184.216.34",
        "https://b.example/": "93.184.216.35",
    }
    assert calls == {"a.example": 2, "b.example": 2}


@pytest.mark.asyncio
async def test_web_fetch_proxy_remains_supported(monkeypatch):
    tool = WebFetchTool(proxy="http://config-proxy.example:7890")
    client_kwargs = _patch_web_fetch_fake_client(monkeypatch)

    monkeypatch.setenv("HTTPS_PROXY", "http://env-proxy.example:8080")
    monkeypatch.setenv("NO_PROXY", "example.com")

    with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
        result = await tool.execute(url="https://example.com/page")

    data = json.loads(result)
    assert data["extractor"] == "jina"
    assert all(kwargs["proxy"] == "http://config-proxy.example:7890" for kwargs in client_kwargs)
    assert all("mounts" not in kwargs for kwargs in client_kwargs)
    assert all("transport" not in kwargs for kwargs in client_kwargs)


@pytest.mark.asyncio
async def test_web_fetch_env_proxy_adds_proxy_mounts_and_keeps_pinned_transport(monkeypatch):
    tool = WebFetchTool()
    client_kwargs = _patch_web_fetch_fake_client(monkeypatch)

    monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
    monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1")

    with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
        result = await tool.execute(url="https://example.com/page")

    data = json.loads(result)
    assert data["extractor"] == "jina"
    fetch_kwargs = [kwargs for kwargs in client_kwargs if kwargs.get("timeout") == 15.0]
    assert fetch_kwargs
    assert all("transport" in kwargs for kwargs in fetch_kwargs)
    assert all("mounts" in kwargs for kwargs in fetch_kwargs)


def test_web_fetch_no_proxy_env_keeps_pinned_direct_route(monkeypatch):
    monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
    monkeypatch.setenv("NO_PROXY", "example.com")
    monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
    monkeypatch.setattr(
        "nanobot.security.network.httpx.AsyncHTTPTransport",
        lambda **_kwargs: object(),
    )

    kwargs = web_module._fetch_client_kwargs(None, 15.0)

    assert "transport" in kwargs
    assert any(transport is None for transport in kwargs["mounts"].values())


@pytest.mark.asyncio
async def test_web_fetch_does_not_fallback_after_pinned_dns_rebind_rejection(monkeypatch):
    calls = {"evil.example": 0}

    def _rebinding_resolver(hostname, port, family=0, type_=0, proto=0, flags=0):
        host = str(hostname).rstrip(".").lower()
        calls[host] += 1
        ip = "93.184.216.34" if calls[host] <= 2 else "169.254.169.254"
        return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", (ip, 0))]

    tool = WebFetchTool()

    async def _unexpected_jina(*args, **kwargs):
        raise AssertionError("Jina fallback should not run after an SSRF rejection")

    async def _unexpected_readability(*args, **kwargs):
        raise AssertionError("Readability fallback should not run after an SSRF rejection")

    monkeypatch.setattr(tool, "_fetch_jina", _unexpected_jina)
    monkeypatch.setattr(tool, "_fetch_readability", _unexpected_readability)

    class FailTransport(httpx.AsyncBaseTransport):
        async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
            raise AssertionError("rebound target must be rejected before transport")

    monkeypatch.setattr(
        web_module,
        "_pinned_dns_transport",
        lambda: PinnedDNSAsyncTransport(inner=FailTransport()),
    )

    with patch("nanobot.security.network.socket.getaddrinfo", _rebinding_resolver):
        result = await tool.execute(url="http://evil.example/page")

    data = json.loads(result)
    assert "error" in data
    assert "blocked" in data["error"].lower()
    assert calls["evil.example"] == 3


@pytest.mark.asyncio
async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
    tool = WebFetchTool(
        config=WebFetchConfig(use_jina_reader=False),
        user_agent="nanobot-test-agent",
    )
    seen_headers: list[dict] = []

    async def _fail_jina(*args, **kwargs):
        raise AssertionError("Jina Reader should be skipped when disabled")

    class FakeStreamResponse:
        status_code = 200
        headers = {"content-type": "text/html"}
        url = "https://example.com/page"

        async def __aenter__(self):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            return False

        async def aread(self):
            raise AssertionError("non-image prefetch body should not be read")

    class FakeResponse:
        status_code = 200
        url = "https://example.com/page"
        text = "<html><head><title>Test</title></head><body><p>Hello world</p></body></html>"
        headers = {"content-type": "text/html"}
        is_redirect = False

        def raise_for_status(self):
            return None

    class FakeClient:
        def __init__(self, *args, **kwargs):
            pass

        async def __aenter__(self):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            return False

        def stream(self, method, url, headers=None, **kwargs):
            seen_headers.append(headers or {})
            return FakeStreamResponse()

        async def get(self, url, headers=None, **kwargs):
            seen_headers.append(headers or {})
            return FakeResponse()

    monkeypatch.setattr(tool, "_fetch_jina", _fail_jina)
    monkeypatch.setattr(tool, "_extract_readable_html", lambda html, mode: "Hello world")
    monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
    monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())

    with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
        result = await tool.execute(url="https://example.com/page")

    data = json.loads(result)
    assert data["extractor"] == "readability"
    assert [headers["User-Agent"] for headers in seen_headers] == [
        "nanobot-test-agent",
        "nanobot-test-agent",
    ]


@pytest.mark.asyncio
async def test_web_fetch_falls_back_when_readability_dependency_is_missing(monkeypatch):
    tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))

    class FakeResponse:
        status_code = 200
        url = "https://example.com/page"
        text = "<html><head><title>Test</title></head><body><p>Hello world</p></body></html>"
        headers = {"content-type": "text/html"}

        def raise_for_status(self):
            return None

    class FakeClient:
        def __init__(self, *args, **kwargs):
            pass

        async def __aenter__(self):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            return False

        async def get(self, url, headers=None, follow_redirects=False, **kwargs):
            return FakeResponse()

    def _missing_readability(*args, **kwargs):
        raise ModuleNotFoundError("No module named 'lxml_html_clean'")

    monkeypatch.setattr(tool, "_extract_readable_html", _missing_readability)
    monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
    monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())

    with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
        result = await tool._fetch_readability("https://example.com/page", "markdown", 5000)

    data = json.loads(result)
    assert data["extractor"] == "html"
    assert data["untrusted"] is True
    assert "Hello world" in data["text"]


@pytest.mark.asyncio
async def test_web_fetch_blocks_private_redirect_before_readability_request(monkeypatch):
    tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
    requested: list[str] = []

    class FakeStreamResponse:
        status_code = 200
        headers = {"content-type": "text/html"}
        url = "https://attacker.example/start"

        async def __aenter__(self):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            return False

        async def aread(self):
            raise AssertionError("non-image prefetch body should not be read")

    class FakeRedirectResponse:
        status_code = 302
        headers = {"location": "http://127.0.0.1:8765/metadata"}
        url = "https://attacker.example/start"

        async def aclose(self):
            return None

    class FakeClient:
        def __init__(self, *args, **kwargs):
            pass

        async def __aenter__(self):
            return self

        async def __aexit__(self, exc_type, exc, tb):
            return False

        def stream(self, method, url, headers=None, **kwargs):
            return FakeStreamResponse()

        async def get(self, url, headers=None, **kwargs):
            requested.append(url)
            if url == "http://127.0.0.1:8765/metadata":
                raise AssertionError("private redirect target should not be requested")
            return FakeRedirectResponse()

    monkeypatch.setattr(web_module.httpx, "AsyncClient", FakeClient)
    monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())

    def resolve_public_start_only(hostname, port, family=0, type_=0):
        if hostname == "attacker.example":
            return _fake_resolve_public(hostname, port, family, type_)
        return _REAL_GETADDRINFO(hostname, port, family, type_)

    with patch("nanobot.security.network.socket.getaddrinfo", resolve_public_start_only):
        result = await tool.execute(url="https://attacker.example/start")

    data = json.loads(result)
    assert "error" in data
    assert "redirect blocked" in data["error"].lower()
    assert requested == ["https://attacker.example/start"]


@pytest.mark.asyncio
async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypatch):
    tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))

    def handler(request: httpx.Request) -> httpx.Response:
        if str(request.url) == "https://example.com/image.png":
            return httpx.Response(
                302,
                headers={"Location": "http://127.0.0.1/secret.png"},
                request=request,
            )
        if str(request.url) == "http://127.0.0.1/secret.png":
            return httpx.Response(
                200,
                headers={"content-type": "image/png"},
                content=b"\x89PNG\r\n\x1a\n",
                request=request,
            )
        return httpx.Response(404, request=request)

    transport = httpx.MockTransport(handler)
    real_async_client = httpx.AsyncClient

    class TransportAsyncClient(real_async_client):
        def __init__(self, *args, **kwargs):
            kwargs.pop("proxy", None)
            kwargs.pop("transport", None)
            super().__init__(*args, transport=transport, **kwargs)

    monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", TransportAsyncClient)
    monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())

    def resolve_public_start_only(hostname, port, family=0, type_=0):
        if hostname == "example.com":
            return _fake_resolve_public(hostname, port, family, type_)
        return _REAL_GETADDRINFO(hostname, port, family, type_)

    with patch("nanobot.security.network.socket.getaddrinfo", resolve_public_start_only):
        result = await tool.execute(url="https://example.com/image.png")

    data = json.loads(result)
    assert "error" in data
    assert "redirect blocked" in data["error"].lower()


@pytest.mark.asyncio
async def test_web_fetch_does_not_request_private_redirect_target(monkeypatch):
    tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
    requested: list[str] = []

    def handler(request: httpx.Request) -> httpx.Response:
        requested.append(str(request.url))
        if str(request.url) == "https://attacker.example/start":
            return httpx.Response(
                302,
                headers={"Location": "http://127.0.0.1:8765/metadata"},
                request=request,
            )
        if str(request.url) == "http://127.0.0.1:8765/metadata":
            return httpx.Response(200, content=b"internal secret", request=request)
        return httpx.Response(404, request=request)

    transport = httpx.MockTransport(handler)
    real_async_client = httpx.AsyncClient

    class TransportAsyncClient(real_async_client):
        def __init__(self, *args, **kwargs):
            kwargs["transport"] = transport
            super().__init__(*args, **kwargs)

    monkeypatch.setattr(web_module.httpx, "AsyncClient", TransportAsyncClient)
    monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())

    def resolve_public_start_only(hostname, port, family=0, type_=0):
        if hostname == "attacker.example":
            return _fake_resolve_public(hostname, port, family, type_)
        return _REAL_GETADDRINFO(hostname, port, family, type_)

    with patch("nanobot.security.network.socket.getaddrinfo", resolve_public_start_only):
        result = await tool.execute(url="https://attacker.example/start")

    data = json.loads(result)
    assert "error" in data
    assert "redirect blocked" in data["error"].lower()
    assert requested == ["https://attacker.example/start"]
