"""Preflight d'un domaine : DNS, page d'accueil, robots.txt, sitemap (spec 2026-08-31 §6).

Sans persistance, lecture seule. `resolver` et `transport` sont des variables de module
remplaçables en test (monkeypatch) — même approche que `set_jwks_client`.

Garde-fou SSRF : l'API accepte un domaine arbitraire venu du navigateur et va le chercher
depuis la Dedibox. Tout hôte est donc résolu *avant* le fetch et refusé si une seule des
adresses obtenues n'est pas publique (privée, loopback, link-local, réservée, multicast) —
ce qui couvre `*.localtest.me`, `*.nip.io` et le rebinding au premier ordre. Les
redirections ne sont pas déléguées à httpx mais suivies à la main, chaque saut repassant
par le même garde-fou, tout comme les URL de sitemap lues dans le robots.txt du site
(entrée contrôlée par un tiers).
"""

import asyncio
import ipaddress
import re
import socket
from collections.abc import Awaitable, Callable
from typing import Literal

import httpx
from pydantic import BaseModel

DNS_TIMEOUT = 2.0
FETCH_TIMEOUT = 5.0
TOTAL_BUDGET = 8.0  # < 10 s, le timeout d'apiFetch côté web
HOME_MAX_BYTES = 64 * 1024
ROBOTS_MAX_BYTES = 512 * 1024
MAX_REDIRECTS = 5
USER_AGENT = "seoinsight-preflight/1.0"
NOT_PUBLIC = "hôte non public (adresse privée ou réservée)"

# ccTLD → (pays ISO 3166-1 alpha-2, langue ISO 639-1 associée). Suggestion, pas décision.
CCTLD: dict[str, tuple[str, str]] = {
    "fr": ("FR", "fr"),
    "be": ("BE", "fr"),
    "ch": ("CH", "de"),
    "lu": ("LU", "fr"),
    "de": ("DE", "de"),
    "at": ("AT", "de"),
    "uk": ("GB", "en"),
    "ie": ("IE", "en"),
    "es": ("ES", "es"),
    "pt": ("PT", "pt"),
    "it": ("IT", "it"),
    "nl": ("NL", "nl"),
    "pl": ("PL", "pl"),
    "cz": ("CZ", "cs"),
    "se": ("SE", "sv"),
    "no": ("NO", "no"),
    "dk": ("DK", "da"),
    "fi": ("FI", "fi"),
    "us": ("US", "en"),
    "ca": ("CA", "en"),
}

CheckStatus = Literal["ok", "warning", "failed", "skipped"]


class CheckResult(BaseModel):
    status: CheckStatus
    detail: str | None = None


class PreflightChecks(BaseModel):
    dns: CheckResult
    home: CheckResult
    robots: CheckResult
    sitemap: CheckResult


class SuggestedTarget(BaseModel):
    country: str
    language: str


class PreflightReport(BaseModel):
    domain: str
    checks: PreflightChecks
    final_host: str | None
    suggested_target: SuggestedTarget


Resolver = Callable[[str], Awaitable[list[str]]]


async def _default_resolver(host: str) -> list[str]:
    loop = asyncio.get_running_loop()
    infos = await asyncio.wait_for(
        loop.getaddrinfo(host, 443, type=socket.SOCK_STREAM), DNS_TIMEOUT
    )
    return [str(info[4][0]) for info in infos]


# Points d'injection des tests (monkeypatch.setattr(preflight, "resolver", …)).
resolver: Resolver = _default_resolver
transport: httpx.AsyncBaseTransport | None = None


class FetchRefused(Exception):
    """Fetch refusé avant même de partir : hôte non résolu, non public, ou URL inacceptable."""


def _skipped() -> CheckResult:
    return CheckResult(status="skipped")


def _failed(detail: str) -> CheckResult:
    return CheckResult(status="failed", detail=detail)


def _is_public_address(raw: str) -> bool:
    try:
        address = ipaddress.ip_address(raw)
    except ValueError:
        # Adresse illisible (scope IPv6 « fe80::1%en0 », valeur inattendue) : on refuse.
        return False
    return address.is_global and not (
        address.is_private
        or address.is_loopback
        or address.is_link_local
        or address.is_reserved
        or address.is_multicast
        or address.is_unspecified
    )


async def _reason_not_reachable(host: str) -> str | None:
    """`None` si l'hôte est public et résolvable, sinon la raison du refus."""
    try:
        ipaddress.ip_address(host)
    except ValueError:
        pass
    else:
        # Hôte littéral : pas de DNS, l'adresse se juge directement.
        return None if _is_public_address(host) else NOT_PUBLIC
    try:
        addresses = await resolver(host)
    except (OSError, TimeoutError) as exc:
        return f"le domaine ne résout pas ({exc})"
    if not addresses:
        return "le domaine ne résout pas (aucune adresse)"
    # `all` et non `any` : un enregistrement mixte laisserait le résolveur système choisir
    # l'adresse privée au moment du fetch.
    if not all(_is_public_address(address) for address in addresses):
        return NOT_PUBLIC
    return None


async def _guard_host(host: str, cache: dict[str, str | None]) -> None:
    if host not in cache:
        cache[host] = await _reason_not_reachable(host)
    reason = cache[host]
    if reason is not None:
        raise FetchRefused(reason)


async def _read_capped(resp: httpx.Response, cap: int) -> bytes:
    chunks: list[bytes] = []
    size = 0
    async for chunk in resp.aiter_bytes():
        chunks.append(chunk)
        size += len(chunk)
        if size >= cap:
            break
    return b"".join(chunks)[:cap]


async def _fetch(
    client: httpx.AsyncClient, url: str, cap: int, cache: dict[str, str | None]
) -> tuple[httpx.Response, bytes, httpx.URL]:
    """GET avec redirections suivies à la main, l'hôte de chaque saut étant revalidé."""
    try:
        target = httpx.URL(url)
    except httpx.InvalidURL as exc:
        raise FetchRefused("URL illisible") from exc
    for hop in range(MAX_REDIRECTS + 1):
        if target.scheme not in ("http", "https"):
            raise FetchRefused(f"schéma « {target.scheme} » refusé")
        if not target.host:
            raise FetchRefused("URL sans hôte")
        try:
            await _guard_host(target.host, cache)
        except FetchRefused as exc:
            raise FetchRefused(f"redirection refusée : {exc}" if hop else str(exc)) from exc
        async with client.stream("GET", target) as resp:
            if resp.is_redirect and resp.has_redirect_location:
                try:
                    target = target.join(resp.headers["location"])
                except httpx.InvalidURL as exc:
                    raise FetchRefused("redirection refusée : Location illisible") from exc
                continue
            return resp, await _read_capped(resp, cap), target
    raise FetchRefused(f"trop de redirections (> {MAX_REDIRECTS})")


_LANG_RE = re.compile(rb"<html[^>]*?\blang\s*=\s*[\"']?([A-Za-z-]+)", re.IGNORECASE)


def _extract_lang(body: bytes) -> str | None:
    # Extraction tolérante par expression régulière : pas de dépendance de parsing
    # pour un seul attribut (spec §6.2).
    match = _LANG_RE.search(body)
    return match.group(1).decode("ascii") if match else None


def suggest_target(host: str, lang_attr: str | None) -> SuggestedTarget:
    tld = host.rsplit(".", 1)[-1]
    country, tld_language = CCTLD.get(tld, ("US", ""))
    language = ""
    if lang_attr:
        primary = lang_attr.split("-", 1)[0].lower()
        if re.fullmatch(r"[a-z]{2}", primary):
            language = primary
    return SuggestedTarget(country=country, language=language or tld_language or "en")


def _budget_exceeded() -> CheckResult:
    return _failed(f"délai dépassé (budget global {TOTAL_BUDGET:g} s)")


async def _check_dns(host: str, cache: dict[str, str | None], deadline: float) -> CheckResult:
    try:
        async with asyncio.timeout_at(deadline):
            await _guard_host(host, cache)
    except FetchRefused as exc:
        return _failed(str(exc))
    except TimeoutError:
        return _budget_exceeded()
    return CheckResult(status="ok")


async def _check_home(
    client: httpx.AsyncClient, host: str, cache: dict[str, str | None]
) -> tuple[CheckResult, str | None, str | None]:
    for scheme in ("https", "http"):
        try:
            resp, body, final_url = await _fetch(
                client, f"{scheme}://{host}/", HOME_MAX_BYTES, cache
            )
        except FetchRefused as exc:
            return _failed(str(exc)), None, None
        except httpx.TimeoutException:
            return _failed("délai dépassé"), None, None
        except httpx.ConnectError:
            continue  # repli http si la connexion (TLS incluse) échoue
        except httpx.HTTPError as exc:
            return _failed(str(exc) or exc.__class__.__name__), None, None
        status: CheckStatus = "ok" if resp.status_code < 400 else "warning"
        return (
            CheckResult(status=status, detail=f"{resp.status_code} — {final_url}"),
            final_url.host,
            _extract_lang(body),
        )
    return _failed("connexion impossible (https puis http)"), None, None


def _sitemap_directives(body: bytes) -> list[str]:
    """URL des directives `Sitemap:` du robots.txt, dans l'ordre du fichier.

    Parsing ligne à ligne, pas de lib (spec §6.3). La ligne est déshabillée avant le test de
    préfixe : la RFC 9309 §2.2 autorise des espaces ou tabulations en tête, et beaucoup de
    sites indentent leurs directives.
    """
    directives: list[str] = []
    for raw_line in body.decode("utf-8", errors="replace").splitlines():
        line = raw_line.strip()
        if not line.lower().startswith("sitemap:"):
            continue
        url = line.split(":", 1)[1].strip()
        if url:
            directives.append(url)
    return directives


async def _check_robots(
    client: httpx.AsyncClient, host: str, cache: dict[str, str | None]
) -> tuple[CheckResult, list[str]]:
    try:
        resp, body, _ = await _fetch(client, f"https://{host}/robots.txt", ROBOTS_MAX_BYTES, cache)
    except FetchRefused as exc:
        return _failed(str(exc)), []
    except httpx.TimeoutException:
        return _failed("délai dépassé"), []
    except httpx.HTTPError as exc:
        return _failed(str(exc) or exc.__class__.__name__), []
    if resp.status_code != 200:
        return CheckResult(status="warning", detail=f"absent ({resp.status_code})"), []
    directives = _sitemap_directives(body)
    plural = "s" if len(directives) > 1 else ""
    detail = f"trouvé ({len(directives)} directive{plural} Sitemap)"
    return CheckResult(status="ok", detail=detail), directives


async def _check_sitemap(
    client: httpx.AsyncClient, host: str, urls: list[str], cache: dict[str, str | None]
) -> CheckResult:
    # Travail borné : seule la première directive est vérifiée, sinon /sitemap.xml.
    root = httpx.URL(f"https://{host}/")
    if not urls:
        url = str(root.join("/sitemap.xml"))
    else:
        # La directive devrait être absolue (RFC 9309 §2.2.3) ; beaucoup de sites écrivent
        # un chemin relatif, résolu ici contre la racine du site.
        try:
            url = str(root.join(urls[0]))
        except httpx.InvalidURL:
            return CheckResult(status="warning", detail="directive Sitemap illisible")
    try:
        resp, _, _ = await _fetch(client, url, 1024, cache)  # statut seul, corps non parsé
    except FetchRefused as exc:
        # Une directive Sitemap pointant hors du web public est un refus, pas un « absent ».
        return _failed(f"sitemap refusé : {exc}")
    except httpx.TimeoutException:
        return _failed("délai dépassé")
    except httpx.HTTPError:
        return CheckResult(status="warning", detail="aucun sitemap joignable")
    if resp.status_code == 200:
        return CheckResult(status="ok", detail=url)
    return CheckResult(status="warning", detail=f"aucun sitemap trouvé ({resp.status_code})")


async def _check_robots_then_sitemap(
    client: httpx.AsyncClient,
    host: str,
    cache: dict[str, str | None],
    resolved: dict[str, CheckResult],
) -> None:
    # Le check sitemap dépend des directives du robots.txt : les deux s'enchaînent,
    # en parallèle du check de la page d'accueil. Chaque résultat est publié dans `resolved`
    # dès qu'il est acquis : si le budget global expire pendant le sitemap, le robots.txt
    # déjà lu est conservé.
    robots, sitemap_urls = await _check_robots(client, host, cache)
    resolved["robots"] = robots
    resolved["sitemap"] = await _check_sitemap(client, host, sitemap_urls, cache)


async def run_preflight(host: str) -> PreflightReport:
    # Résultats de résolution partagés par tous les fetchs du run : une seule vérité par
    # hôte, et une passe DNS de moins par requête.
    cache: dict[str, str | None] = {}
    # Échéance unique, DNS compris : la requête entière tient sous TOTAL_BUDGET, alors
    # qu'un budget appliqué aux seuls fetchs laisserait DNS_TIMEOUT s'y ajouter et
    # atteindrait le timeout de 10 s d'apiFetch côté web.
    deadline = asyncio.get_running_loop().time() + TOTAL_BUDGET
    dns = await _check_dns(host, cache, deadline)
    if dns.status != "ok":
        return PreflightReport(
            domain=host,
            checks=PreflightChecks(dns=dns, home=_skipped(), robots=_skipped(), sitemap=_skipped()),
            final_host=None,
            suggested_target=suggest_target(host, None),
        )
    final_host: str | None = None
    lang: str | None = None
    # Chaque check s'y publie dès qu'il a répondu : au dépassement du budget global, seuls
    # les checks encore absents sont marqués — un résultat déjà acquis n'est pas écrasé.
    resolved: dict[str, CheckResult] = {}
    async with httpx.AsyncClient(
        transport=transport,
        follow_redirects=False,  # suivies à la main, avec revalidation de l'hôte
        timeout=FETCH_TIMEOUT,
        headers={"User-Agent": USER_AGENT},
    ) as client:

        async def check_home() -> None:
            nonlocal final_host, lang
            resolved["home"], final_host, lang = await _check_home(client, host, cache)

        try:
            async with asyncio.timeout_at(deadline):
                await asyncio.gather(
                    check_home(),
                    _check_robots_then_sitemap(client, host, cache, resolved),
                )
        except TimeoutError:
            # Un dépassement du budget global n'est jamais un 5xx de l'API.
            pass
    return PreflightReport(
        domain=host,
        checks=PreflightChecks(
            dns=dns,
            home=resolved.get("home", _budget_exceeded()),
            robots=resolved.get("robots", _budget_exceeded()),
            sitemap=resolved.get("sitemap", _budget_exceeded()),
        ),
        final_host=final_host if final_host and final_host != host else None,
        suggested_target=suggest_target(host, lang),
    )
