"""Favicon сайта проекта: SSRF-барьер, снафф типа, кэш на диске, эндпоинт."""

import asyncio
import time
from pathlib import Path

import httpx
import pytest
from fastapi.testclient import TestClient

from app.services import favicon

PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 32


@pytest.fixture(autouse=True)
def clean_cache() -> None:
    """Кэш-каталог между тестами чистим: путь один на процесс."""
    for entry in favicon._cache_dir().glob("*"):
        entry.unlink()


@pytest.fixture
def public_dns(monkeypatch: pytest.MonkeyPatch) -> None:
    """Резолв «всё публичное»: сеть в тестах не трогаем."""

    def fake_getaddrinfo(*args: object, **kwargs: object) -> list[tuple]:
        return [(2, 1, 6, "", ("93.184.216.34", 0))]

    monkeypatch.setattr(favicon.socket, "getaddrinfo", fake_getaddrinfo)


def test_private_hosts_rejected() -> None:
    # приватные/loopback/link-local адреса — отказ (в т.ч. метаданные облака)
    for host in ("127.0.0.1", "10.0.0.1", "192.168.1.5", "169.254.169.254", "::1"):
        assert favicon.is_public_host(host) is False, host


def test_public_host_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(
        favicon.socket, "getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 0))]
    )
    assert favicon.is_public_host("example.com") is True


def test_unresolvable_host_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
    def boom(*args: object, **kwargs: object) -> list[tuple]:
        raise favicon.socket.gaierror("no such host")

    monkeypatch.setattr(favicon.socket, "getaddrinfo", boom)
    assert favicon.is_public_host("nope.invalid") is False


def test_validated_urls(public_dns: None) -> None:
    assert favicon._validated("https://example.com") is True
    assert favicon._validated("ftp://example.com") is False
    assert favicon._validated("https://") is False


def test_sniff_supported_formats() -> None:
    assert favicon._sniff_ext(PNG) == ".png"
    assert favicon._sniff_ext(b"\x00\x00\x01\x00" + b"x" * 8) == ".ico"
    assert favicon._sniff_ext(b"GIF89a" + b"x" * 8) == ".gif"
    assert favicon._sniff_ext(b"\xff\xd8\xff" + b"x" * 8) == ".jpg"
    assert favicon._sniff_ext(b"RIFF\x00\x00\x00\x00WEBPVP8 ") == ".webp"
    # SVG не принимаем (скриптуемый формат), произвольный мусор — тоже
    assert favicon._sniff_ext(b"<svg xmlns=\"http://www.w3.org/2000/svg\">") is None
    assert favicon._sniff_ext(b"<html>") is None


def _client(handler: object) -> httpx.AsyncClient:
    return httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=5.0)  # type: ignore[arg-type]


def test_download_rejects_non_image(public_dns: None) -> None:
    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, headers={"content-type": "text/html"}, content=b"<html>")

    async def run() -> tuple[bytes, str] | None:
        async with _client(handler) as client:
            return await favicon._download(client, "https://example.com/favicon.ico")

    assert asyncio.run(run()) is None


def test_download_rejects_svg(public_dns: None) -> None:
    svg = b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, headers={"content-type": "image/svg+xml"}, content=svg)

    async def run() -> tuple[bytes, str] | None:
        async with _client(handler) as client:
            return await favicon._download(client, "https://example.com/icon.svg")

    # content-type картинки, но magic-bytes чужие — иконкой не считаем
    assert asyncio.run(run()) is None


def test_download_stops_on_oversize(public_dns: None) -> None:
    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(
            200,
            headers={"content-type": "image/png"},
            content=PNG + b"0" * favicon.MAX_BYTES,
        )

    async def run() -> tuple[bytes, str] | None:
        async with _client(handler) as client:
            return await favicon._download(client, "https://example.com/favicon.png")

    assert asyncio.run(run()) is None


def test_download_follows_redirect_and_checks_host(public_dns: None) -> None:
    seen: list[str] = []

    def handler(request: httpx.Request) -> httpx.Response:
        seen.append(request.url.path)
        if request.url.path == "/favicon.ico":
            return httpx.Response(302, headers={"location": "/icon.png"})
        return httpx.Response(200, headers={"content-type": "image/png"}, content=PNG)

    async def run() -> tuple[bytes, str] | None:
        async with _client(handler) as client:
            return await favicon._download(client, "https://example.com/favicon.ico")

    assert asyncio.run(run()) == (PNG, ".png")
    assert seen == ["/favicon.ico", "/icon.png"]


def test_download_redirect_to_private_host_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
    def fake_getaddrinfo(*args: object, **kwargs: object) -> list[tuple]:
        host = str(args[0]) if args else ""
        ip = "127.0.0.1" if host == "internal.corp" else "93.184.216.34"
        return [(2, 1, 6, "", (ip, 0))]

    monkeypatch.setattr(favicon.socket, "getaddrinfo", fake_getaddrinfo)

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(302, headers={"location": "http://internal.corp/secret"})

    async def run() -> tuple[bytes, str] | None:
        async with _client(handler) as client:
            return await favicon._download(client, "https://example.com/favicon.ico")

    # первый хоп публичный, второй — приватный: редирект не проходим
    assert asyncio.run(run()) is None


def test_fetch_caches_and_reuses(public_dns: None, monkeypatch: pytest.MonkeyPatch) -> None:
    calls: list[str] = []

    async def fake_download(client: object, url: str) -> tuple[bytes, str] | None:
        calls.append(url)
        return (PNG, ".png")

    monkeypatch.setattr(favicon, "_download", fake_download)

    async def run() -> tuple[bytes, str] | None:
        return await favicon.fetch_favicon("https://example.com")

    first = asyncio.run(run())
    assert first == (PNG, "image/png")
    fetched = len(calls)
    # второй запрос отдаётся с диска — сеть не трогаем
    assert asyncio.run(run()) == (PNG, "image/png")
    assert len(calls) == fetched
    # кэш переживает рестарт процесса (файл на диске)
    files = [p for p in favicon._cache_dir().glob("*") if p.suffix == ".png"]
    assert files and files[0].read_bytes() == PNG


def test_fetch_negative_cached(public_dns: None, monkeypatch: pytest.MonkeyPatch) -> None:
    calls: list[str] = []

    async def fake_download(client: object, url: str) -> tuple[bytes, str] | None:
        calls.append(url)
        return None

    monkeypatch.setattr(favicon, "_download", fake_download)

    async def run() -> tuple[bytes, str] | None:
        return await favicon.fetch_favicon("https://no-icon.example")

    assert asyncio.run(run()) is None
    tried = len(calls)
    assert tried > 0
    # негативный маркер: повторно сайт не долбим
    assert asyncio.run(run()) is None
    assert len(calls) == tried
    # маркер протух — перепроверяем
    real_time = time.time
    monkeypatch.setattr(favicon.time, "time", lambda: real_time() + favicon.NEGATIVE_TTL + 1)
    assert asyncio.run(run()) is None
    assert len(calls) > tried


def test_cache_dir_created(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    target = tmp_path / "favicons"
    settings = favicon.get_settings()
    monkeypatch.setattr(settings, "favicons_path", str(target))
    assert favicon._cache_dir() == target
    assert target.is_dir()


def test_endpoint_404_without_site_url(client: TestClient) -> None:
    project_id = client.post("/api/projects", json={"name": "Без сайта"}).json()["id"]
    assert client.get(f"/api/projects/{project_id}/favicon").status_code == 404


def test_endpoint_404_when_not_found(
    client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
    project_id = client.post(
        "/api/projects", json={"name": "Сайт", "site_url": "https://example.com"}
    ).json()["id"]

    async def none_found(url: str) -> tuple[bytes, str] | None:
        return None

    monkeypatch.setattr(favicon, "fetch_favicon", none_found)
    assert client.get(f"/api/projects/{project_id}/favicon").status_code == 404


def test_endpoint_serves_favicon(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
    project_id = client.post(
        "/api/projects", json={"name": "Сайт", "site_url": "https://example.com"}
    ).json()["id"]

    async def found(url: str) -> tuple[bytes, str] | None:
        return PNG, "image/png"

    monkeypatch.setattr(favicon, "fetch_favicon", found)
    resp = client.get(f"/api/projects/{project_id}/favicon")
    assert resp.status_code == 200
    assert resp.headers["content-type"] == "image/png"
    assert resp.content == PNG
    assert resp.headers["cache-control"] == "private, max-age=86400"
    # иконки чужого сайта не должны исполняться как документ
    assert resp.headers["x-content-type-options"] == "nosniff"


def test_endpoint_scoped_to_user(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
    project_id = client.post(
        "/api/projects", json={"name": "Сайт", "site_url": "https://example.com"}
    ).json()["id"]

    async def found(url: str) -> tuple[bytes, str] | None:
        return PNG, "image/png"

    monkeypatch.setattr(favicon, "fetch_favicon", found)
    # чужой пользователь не должен получить иконку чужого проекта
    from app.dependencies import require_user
    from app.main import app as fastapi_app

    original = fastapi_app.dependency_overrides[require_user]
    fastapi_app.dependency_overrides[require_user] = lambda: {
        "user_id": "999",
        "email": "other@example.com",
        "locale": "ru",
    }
    try:
        assert client.get(f"/api/projects/{project_id}/favicon").status_code == 404
    finally:
        # возвращаем тот же объект: conftest-овский lambda закрыт на общий
        # AUTH_USER, его подменяют тесты мультиюзера
        fastapi_app.dependency_overrides[require_user] = original
