import asyncio
import socket

import httpx
import pytest
from httpx import AsyncClient

import app.sites.preflight as preflight
from tests.test_me import make_token
from tests.test_sites_crud import Headers, new_org

HOME_HTML = b'<!doctype html><html lang="fr-FR"><head><title>ok</title></head><body></body></html>'


@pytest.fixture
def public_dns(monkeypatch: pytest.MonkeyPatch) -> None:
    async def _resolve(_host: str) -> list[str]:
        return ["93.184.216.34"]

    monkeypatch.setattr(preflight, "resolver", _resolve)


def _happy_handler(request: httpx.Request) -> httpx.Response:
    url = str(request.url)
    if url == "https://exemple.fr/":
        return httpx.Response(301, headers={"location": "https://www.exemple.fr/"})
    if url == "https://www.exemple.fr/":
        return httpx.Response(200, content=HOME_HTML)
    if url == "https://exemple.fr/robots.txt":
        return httpx.Response(200, text="User-agent: *\nSitemap: https://exemple.fr/sitemap.xml\n")
    if url == "https://exemple.fr/sitemap.xml":
        return httpx.Response(
            200, content=b"<urlset/>", headers={"content-type": "application/xml"}
        )
    return httpx.Response(404)


async def test_preflight_all_ok(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_happy_handler))
    resp = await client.post(
        "/sites/preflight",
        json={"domain": "https://exemple.fr/"},
        headers=auth_headers(new_org()),
    )
    assert resp.status_code == 200
    body = resp.json()
    assert body["domain"] == "exemple.fr"
    assert {name: check["status"] for name, check in body["checks"].items()} == {
        "dns": "ok",
        "home": "ok",
        "robots": "ok",
        "sitemap": "ok",
    }
    assert "1 directive Sitemap" in body["checks"]["robots"]["detail"]
    assert body["final_host"] == "www.exemple.fr"  # redirection détectée (spec §10.4)
    assert body["suggested_target"] == {"country": "FR", "language": "fr"}


async def test_preflight_dns_failure_skips_fetches(
    client: AsyncClient, auth_headers: Headers, monkeypatch: pytest.MonkeyPatch
) -> None:
    async def _resolve(_host: str) -> list[str]:
        raise socket.gaierror(8, "nodename nor servname provided")

    monkeypatch.setattr(preflight, "resolver", _resolve)

    def _no_fetch(request: httpx.Request) -> httpx.Response:
        raise AssertionError("aucun fetch ne doit partir quand le DNS échoue")

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_no_fetch))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 200
    body = resp.json()
    assert body["checks"]["dns"]["status"] == "failed"
    statuses = {body["checks"][name]["status"] for name in ("home", "robots", "sitemap")}
    assert statuses == {"skipped"}


async def test_preflight_private_ip_is_failed(
    client: AsyncClient, auth_headers: Headers, monkeypatch: pytest.MonkeyPatch
) -> None:
    async def _resolve(_host: str) -> list[str]:
        return ["192.168.1.10", "10.0.0.4"]

    monkeypatch.setattr(preflight, "resolver", _resolve)

    def _no_fetch(request: httpx.Request) -> httpx.Response:
        raise AssertionError("garde-fou SSRF : aucun fetch vers un hôte privé")

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_no_fetch))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    body = resp.json()
    assert body["checks"]["dns"]["status"] == "failed"
    assert "non public" in body["checks"]["dns"]["detail"]
    assert body["checks"]["home"]["status"] == "skipped"


@pytest.mark.parametrize(
    "addresses",
    [
        ["93.184.216.34", "10.0.0.4"],  # une seule adresse privée suffit à refuser
        ["93.184.216.34", "127.0.0.1"],  # *.localtest.me
        ["93.184.216.34", "169.254.169.254"],  # métadonnées cloud (link-local)
        ["2001:db8::1"],  # réservée (documentation)
        ["::1"],  # loopback IPv6
    ],
)
async def test_preflight_any_private_address_is_failed(
    client: AsyncClient,
    auth_headers: Headers,
    monkeypatch: pytest.MonkeyPatch,
    addresses: list[str],
) -> None:
    # Le rendez-vous DNS doit être *entièrement* public : un enregistrement mixte
    # (public + privé) laisse le choix de l'adresse au résolveur système au moment du
    # fetch — c'est du DNS rebinding au premier ordre.
    async def _resolve(_host: str) -> list[str]:
        return addresses

    monkeypatch.setattr(preflight, "resolver", _resolve)

    def _no_fetch(request: httpx.Request) -> httpx.Response:
        raise AssertionError("garde-fou SSRF : aucun fetch quand une adresse n'est pas publique")

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_no_fetch))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 200
    assert resp.json()["checks"]["dns"]["status"] == "failed"


async def test_preflight_redirect_to_private_host_is_refused(
    client: AsyncClient, auth_headers: Headers, monkeypatch: pytest.MonkeyPatch
) -> None:
    async def _resolve(host: str) -> list[str]:
        return ["10.0.0.4"] if host == "interne.exemple.fr" else ["93.184.216.34"]

    monkeypatch.setattr(preflight, "resolver", _resolve)

    def _handler(request: httpx.Request) -> httpx.Response:
        assert request.url.host != "interne.exemple.fr", "redirection vers un hôte privé suivie"
        if str(request.url) == "https://exemple.fr/":
            return httpx.Response(302, headers={"location": "https://interne.exemple.fr/"})
        return httpx.Response(404)

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 200
    body = resp.json()
    assert body["checks"]["dns"]["status"] == "ok"
    assert body["checks"]["home"]["status"] == "failed"
    assert "redirection" in body["checks"]["home"]["detail"]
    assert "non public" in body["checks"]["home"]["detail"]
    assert body["final_host"] is None


async def test_preflight_sitemap_directive_to_private_host_is_refused(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Le robots.txt du site cible est une entrée contrôlée par un tiers : la directive
    # Sitemap doit repasser par le même garde-fou que l'hôte de départ.
    def _handler(request: httpx.Request) -> httpx.Response:
        assert request.url.host != "169.254.169.254", "directive Sitemap privée suivie"
        if str(request.url) == "https://exemple.fr/robots.txt":
            return httpx.Response(200, text="Sitemap: http://169.254.169.254/latest/meta-data/\n")
        if str(request.url) == "https://exemple.fr/":
            return httpx.Response(200, content=HOME_HTML)
        return httpx.Response(404)

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    body = resp.json()
    assert body["checks"]["robots"]["status"] == "ok"
    assert body["checks"]["sitemap"]["status"] == "failed"
    assert "non public" in body["checks"]["sitemap"]["detail"]


async def test_preflight_relative_sitemap_directive_is_resolved(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Directive relative (fréquente, quoique hors RFC 9309) : résolue contre la racine.
    def _handler(request: httpx.Request) -> httpx.Response:
        url = str(request.url)
        if url == "https://exemple.fr/robots.txt":
            return httpx.Response(200, text="Sitemap: /sitemaps/index.xml\n")
        if url == "https://exemple.fr/sitemaps/index.xml":
            return httpx.Response(200, content=b"<urlset/>")
        if url == "https://exemple.fr/":
            return httpx.Response(200, content=HOME_HTML)
        return httpx.Response(404)

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    sitemap = resp.json()["checks"]["sitemap"]
    assert sitemap["status"] == "ok"
    assert sitemap["detail"] == "https://exemple.fr/sitemaps/index.xml"


async def test_preflight_indented_sitemap_directive_is_read(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # RFC 9309 §2.2 : une ligne peut être précédée d'espaces ou de tabulations.
    def _handler(request: httpx.Request) -> httpx.Response:
        url = str(request.url)
        if url == "https://exemple.fr/robots.txt":
            return httpx.Response(
                200, text="User-agent: *\n\t  Sitemap: https://exemple.fr/plan.xml\n"
            )
        if url == "https://exemple.fr/plan.xml":
            return httpx.Response(200, content=b"<urlset/>")
        if url == "https://exemple.fr/":
            return httpx.Response(200, content=HOME_HTML)
        return httpx.Response(404)

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    body = resp.json()
    assert "1 directive Sitemap" in body["checks"]["robots"]["detail"]
    assert body["checks"]["sitemap"]["status"] == "ok"
    assert body["checks"]["sitemap"]["detail"] == "https://exemple.fr/plan.xml"


async def test_preflight_only_the_first_sitemap_directive_is_fetched(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Travail borné (spec §6.3) : les directives suivantes sont comptées, jamais suivies.
    fetched: list[str] = []

    def _handler(request: httpx.Request) -> httpx.Response:
        url = str(request.url)
        fetched.append(url)
        if url == "https://exemple.fr/robots.txt":
            return httpx.Response(
                200,
                text=("Sitemap: https://exemple.fr/un.xml\nSitemap: https://exemple.fr/deux.xml\n"),
            )
        if url == "https://exemple.fr/":
            return httpx.Response(200, content=HOME_HTML)
        return httpx.Response(200, content=b"<urlset/>")

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    body = resp.json()
    assert "2 directives Sitemap" in body["checks"]["robots"]["detail"]
    assert body["checks"]["sitemap"]["detail"] == "https://exemple.fr/un.xml"
    assert "https://exemple.fr/deux.xml" not in fetched


async def test_preflight_robots_body_is_capped(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Le corps du robots.txt est borné à ROBOTS_MAX_BYTES : la directive placée au-delà
    # n'est jamais lue (un robots.txt géant ne doit pas remplir la mémoire de l'API).
    padding = b"# " + b"a" * preflight.ROBOTS_MAX_BYTES + b"\n"
    body_bytes = b"Sitemap: https://exemple.fr/tot.xml\n" + padding + b"Sitemap: /tard.xml\n"

    def _handler(request: httpx.Request) -> httpx.Response:
        url = str(request.url)
        if url == "https://exemple.fr/robots.txt":
            return httpx.Response(200, content=body_bytes)
        if url == "https://exemple.fr/":
            return httpx.Response(200, content=HOME_HTML)
        return httpx.Response(200, content=b"<urlset/>")

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    body = resp.json()
    assert "1 directive Sitemap" in body["checks"]["robots"]["detail"]
    assert body["checks"]["sitemap"]["detail"] == "https://exemple.fr/tot.xml"


async def test_preflight_too_many_redirects_is_failed(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    def _handler(request: httpx.Request) -> httpx.Response:
        if request.url.path == "/robots.txt":
            return httpx.Response(404)
        return httpx.Response(302, headers={"location": f"https://exemple.fr{request.url.path}x"})

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    body = resp.json()
    assert body["checks"]["home"]["status"] == "failed"
    assert f"trop de redirections (> {preflight.MAX_REDIRECTS})" in body["checks"]["home"]["detail"]
    assert body["final_host"] is None


async def test_preflight_home_4xx_is_warning(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Une page d'accueil en 403/404 n'empêche pas d'ajouter le site : avertissement, pas échec.
    def _handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(403)

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    home = resp.json()["checks"]["home"]
    assert home["status"] == "warning"
    assert home["detail"].startswith("403")


def _no_robots_handler(request: httpx.Request) -> httpx.Response:
    url = str(request.url)
    if url == "https://exemple.fr/":
        return httpx.Response(200, content=HOME_HTML)
    if url == "https://exemple.fr/sitemap.xml":
        return httpx.Response(200, content=b"<urlset/>")
    return httpx.Response(404)


async def test_preflight_robots_absent_is_warning(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_no_robots_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    body = resp.json()
    assert body["checks"]["robots"]["status"] == "warning"
    assert body["checks"]["sitemap"]["status"] == "ok"  # repli /sitemap.xml
    assert body["final_host"] is None  # pas de redirection


async def test_preflight_sitemap_missing_is_warning(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    def _handler(request: httpx.Request) -> httpx.Response:
        if str(request.url) == "https://exemple.fr/":
            return httpx.Response(200, content=HOME_HTML)
        return httpx.Response(404)

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    assert resp.json()["checks"]["sitemap"]["status"] == "warning"  # jamais failed


async def test_preflight_lang_attribute_wins(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    def _handler(request: httpx.Request) -> httpx.Response:
        if str(request.url) == "https://exemple.com/":
            return httpx.Response(200, content=b'<html lang="de-CH"><body></body></html>')
        return httpx.Response(404)

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.com"}, headers=auth_headers(new_org())
    )
    # lang="de-CH" → sous-étiquette primaire ; .com hors table ccTLD → US.
    assert resp.json()["suggested_target"] == {"country": "US", "language": "de"}


async def test_preflight_timeouts_fail_without_500(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    def _slow(request: httpx.Request) -> httpx.Response:
        raise httpx.ConnectTimeout("délai simulé", request=request)

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_slow))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 200  # le site cible lent n'est pas une erreur serveur
    body = resp.json()
    for name in ("home", "robots", "sitemap"):
        assert body["checks"][name]["status"] == "failed"
        assert "délai" in body["checks"][name]["detail"]


async def test_preflight_global_budget_is_enforced(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Budget global réduit pour le test ; en production il vaut TOTAL_BUDGET (8 s < 10 s
    # du timeout d'apiFetch côté web).
    monkeypatch.setattr(preflight, "TOTAL_BUDGET", 0.05)

    async def _never(request: httpx.Request) -> httpx.Response:
        await asyncio.sleep(5)
        return httpx.Response(200)

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_never))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 200
    body = resp.json()
    assert body["checks"]["dns"]["status"] == "ok"
    for name in ("home", "robots", "sitemap"):
        assert body["checks"][name]["status"] == "failed"
        assert "budget global" in body["checks"][name]["detail"]


async def test_preflight_budget_keeps_the_checks_already_resolved(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Le budget dépassé ne doit rejeter que ce qui n'a pas encore répondu : une page
    # d'accueil déjà lue reste `ok`, seuls robots et sitemap tombent sur le budget.
    monkeypatch.setattr(preflight, "TOTAL_BUDGET", 0.3)

    async def _handler(request: httpx.Request) -> httpx.Response:
        if str(request.url) == "https://exemple.fr/":
            return httpx.Response(200, content=HOME_HTML)
        await asyncio.sleep(5)
        return httpx.Response(200)

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 200
    body = resp.json()
    assert body["checks"]["home"]["status"] == "ok"
    assert body["checks"]["home"]["detail"].startswith("200")
    for name in ("robots", "sitemap"):
        assert body["checks"][name]["status"] == "failed"
        assert "budget global" in body["checks"][name]["detail"]


async def test_preflight_slow_dns_stays_in_the_global_budget(
    client: AsyncClient, auth_headers: Headers, monkeypatch: pytest.MonkeyPatch
) -> None:
    # Le budget couvre le DNS : sinon DNS_TIMEOUT s'ajouterait aux 8 s et la requête
    # atteindrait le timeout de 10 s d'apiFetch côté web.
    monkeypatch.setattr(preflight, "TOTAL_BUDGET", 0.05)

    async def _slow_resolve(_host: str) -> list[str]:
        await asyncio.sleep(5)
        return ["93.184.216.34"]

    monkeypatch.setattr(preflight, "resolver", _slow_resolve)

    def _no_fetch(request: httpx.Request) -> httpx.Response:
        raise AssertionError("aucun fetch sans DNS résolu")

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_no_fetch))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 200
    body = resp.json()
    assert body["checks"]["dns"]["status"] == "failed"
    assert "budget global" in body["checks"]["dns"]["detail"]
    assert body["checks"]["home"]["status"] == "skipped"


async def test_preflight_invalid_domain_is_422(client: AsyncClient, auth_headers: Headers) -> None:
    resp = await client.post(
        "/sites/preflight", json={"domain": "192.168.1.10"}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 422
    assert resp.headers["content-type"].startswith("application/problem+json")


async def test_preflight_without_active_org_is_404(client: AsyncClient, test_private_key) -> None:
    token = make_token(test_private_key, org_id=None)
    resp = await client.post(
        "/sites/preflight",
        json={"domain": "exemple.fr"},
        headers={"Authorization": f"Bearer {token}"},
    )
    assert resp.status_code == 404
