"""Small, origin-safe urllib transport for atcwatch."""

from __future__ import annotations

import email.utils
import json
import socket
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from http.cookies import SimpleCookie
from typing import Any, Mapping


USER_AGENT = "atcwatch/1.0"
MAX_BODY_BYTES = 1024 * 1024
COOKIE_NAME = "atc_session"
COOKIE_REFRESH_SECONDS = 6 * 24 * 60 * 60


def origin(url: str) -> tuple[str, str, int]:
    parsed = urllib.parse.urlsplit(url)
    scheme = parsed.scheme.lower()
    host = (parsed.hostname or "").lower()
    if scheme not in {"http", "https"} or not host:
        raise ValueError("URL must use http or https and include a host")
    return scheme, host, parsed.port or (443 if scheme == "https" else 80)


class CrossOriginRedirectError(urllib.error.URLError):
    pass


class SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        target = urllib.parse.urljoin(req.full_url, newurl)
        if origin(req.full_url) != origin(target):
            raise CrossOriginRedirectError(f"cross-origin redirect rejected: {target}")
        redirected = super().redirect_request(req, fp, code, msg, headers, target)
        if redirected is not None:
            redirected.add_unredirected_header("User-Agent", USER_AGENT)
        return redirected


@dataclass(frozen=True)
class HttpResponse:
    status: int
    headers: Mapping[str, str]
    body: bytes
    url: str


@dataclass(frozen=True)
class FetchResult:
    payload: Any
    session_cookie: str | None
    session_cookie_changed: bool
    session_origin: tuple[str, str, int] | None = None


class FetchError(RuntimeError):
    def __init__(
        self, kind: str, detail: str, *, status: int | None = None,
        retry_after: str | None = None, session_cookie: str | None = None,
        session_cookie_changed: bool = False,
    ):
        super().__init__(detail)
        self.kind = kind
        self.detail = detail
        self.status = status
        self.retry_after = retry_after
        self.session_cookie = session_cookie
        self.session_cookie_changed = session_cookie_changed


DRAIN_MAX_BYTES = 16 * 1024 * 1024


def _drain(response, chunk_size: int = 64 * 1024,
           max_bytes: int = DRAIN_MAX_BYTES) -> None:
    """Read and discard a response body that is irrelevant to the caller.

    Bounded as a safety net: a server that streams forever must not wedge
    the poll cycle. Anything left beyond the cap dies with the connection.
    """

    remaining = max_bytes
    while remaining > 0:
        chunk = response.read(min(chunk_size, remaining))
        if not chunk:
            return
        remaining -= len(chunk)


def _read_limited(response, limit: int) -> bytes:
    raw_length = response.headers.get("Content-Length")
    if raw_length is not None:
        try:
            length = int(raw_length)
        except (TypeError, ValueError) as exc:
            raise FetchError("protocol", "invalid Content-Length") from exc
        if length < 0 or length > limit:
            raise FetchError("body_too_large", f"response exceeds {limit} bytes")
    body = response.read(limit + 1)
    if len(body) > limit:
        raise FetchError("body_too_large", f"response exceeds {limit} bytes")
    return body


def _extract_cookie(headers: Mapping[str, str]) -> str | None:
    if hasattr(headers, "get_all"):
        values = list(headers.get_all("Set-Cookie") or [])
    else:
        value = headers.get("Set-Cookie")
        values = [value] if value else []
    for value in values:
        parsed = SimpleCookie()
        try:
            parsed.load(value)
        except Exception:
            continue
        if COOKIE_NAME in parsed:
            cookie = parsed[COOKIE_NAME].value
            if cookie and all(char not in cookie for char in "\r\n;"):
                return cookie
    return None


def cookie_needs_refresh(cookie: str | None, now: datetime) -> bool:
    if not cookie:
        return True
    try:
        issued = datetime.fromtimestamp(int(cookie.split(".", 1)[0]), tz=timezone.utc)
    except (ValueError, OverflowError, OSError):
        return True
    return (
        issued > now + timedelta(minutes=5)
        or (now - issued).total_seconds() > COOKIE_REFRESH_SECONDS
    )


def parse_retry_after(value: str | None, now: datetime) -> int | None:
    if not value:
        return None
    try:
        return max(0, int(value.strip()))
    except ValueError:
        pass
    try:
        parsed = email.utils.parsedate_to_datetime(value)
        if parsed.tzinfo is None:
            parsed = parsed.replace(tzinfo=timezone.utc)
        return max(0, int((parsed.astimezone(timezone.utc) - now).total_seconds()))
    except (TypeError, ValueError, OverflowError):
        return None


class UrllibTransport:
    def __init__(self, *, opener=None, body_limit: int = MAX_BODY_BYTES):
        self.opener = opener or urllib.request.build_opener(SameOriginRedirectHandler())
        self.body_limit = body_limit
        self.request_count = 0

    def _request(
        self, method: str, url: str, *, data: bytes | None = None,
        cookie: str | None = None, timeout: float = 15,
        discard_body: bool = False,
    ) -> HttpResponse:
        self.request_count += 1
        headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
        if data is not None:
            headers["Content-Type"] = "application/json"
        if cookie:
            headers["Cookie"] = f"{COOKIE_NAME}={cookie}"
        request = urllib.request.Request(url, data=data, headers=headers, method=method)
        try:
            response = self.opener.open(request, timeout=timeout)
            try:
                if discard_body:
                    # Bootstrap only needs the Set-Cookie header; the index
                    # page body is drained without counting it against the
                    # JSON body limit.
                    _drain(response)
                    body = b""
                else:
                    body = _read_limited(response, self.body_limit)
                final_url = response.geturl()
                if origin(url) != origin(final_url):
                    raise FetchError("redirect", "cross-origin final URL rejected")
                return HttpResponse(response.status, response.headers, body, final_url)
            finally:
                response.close()
        except urllib.error.HTTPError as exc:
            if discard_body:
                _drain(exc)
                body = b""
            else:
                body = _read_limited(exc, self.body_limit)
            return HttpResponse(exc.code, exc.headers, body, exc.geturl())
        except CrossOriginRedirectError as exc:
            raise FetchError("redirect", str(exc)) from exc
        except (TimeoutError, socket.timeout) as exc:
            raise FetchError("timeout", str(exc) or "request timed out") from exc
        except urllib.error.URLError as exc:
            if isinstance(exc.reason, (TimeoutError, socket.timeout)):
                raise FetchError("timeout", str(exc.reason)) from exc
            raise FetchError("network", str(exc.reason)) from exc
        except OSError as exc:
            raise FetchError("network", str(exc)) from exc

    @staticmethod
    def _with_cookie(
        response: HttpResponse, cookie: str | None, changed: bool,
    ) -> tuple[str | None, bool]:
        received = _extract_cookie(response.headers)
        if received is not None and received != cookie:
            return received, True
        return cookie, changed

    def _bootstrap(
        self, base_url: str, cookie: str | None, changed: bool, timeout: float,
    ) -> tuple[str | None, bool]:
        response = self._request(
            "GET", base_url + "/", timeout=timeout, discard_body=True)
        if not 200 <= response.status < 300:
            raise FetchError(
                "bootstrap", f"bootstrap returned HTTP {response.status}",
                status=response.status, retry_after=response.headers.get("Retry-After"),
                session_cookie=cookie, session_cookie_changed=changed,
            )
        received = _extract_cookie(response.headers)
        if received is None:
            # A successful bootstrap without Set-Cookie is the explicit
            # keyless development mode; never carry an old rejected cookie
            # into that mode.
            return None, changed or cookie is not None
        return received, changed or received != cookie

    def fetch_status(
        self, *, base_url: str, icaos: list[str], hours: int,
        event_horizon_days: int, session_cookie: str | None,
        now: datetime, timeout: float,
        session_origin: tuple[str, str, int] | None = None,
    ) -> FetchResult:
        base = base_url.rstrip("/")
        configured_origin = origin(base)
        cookie = session_cookie if session_origin in {None, configured_origin} else None
        changed = cookie != session_cookie
        try:
            if cookie_needs_refresh(cookie, now):
                cookie, changed = self._bootstrap(base, cookie, changed, timeout)
            body = json.dumps({
                "icaos": icaos,
                "hours": hours,
                "event_horizon_days": event_horizon_days,
            }, separators=(",", ":")).encode("utf-8")

            def post() -> HttpResponse:
                endpoint = base + "/api/watchlist-status"
                if origin(endpoint) != configured_origin:
                    raise FetchError("origin", "API endpoint origin changed")
                return self._request("POST", endpoint, data=body, cookie=cookie, timeout=timeout)

            response = post()
            cookie, changed = self._with_cookie(response, cookie, changed)
            if response.status == 401:
                cookie, changed = self._bootstrap(base, cookie, changed, timeout)
                response = post()
                cookie, changed = self._with_cookie(response, cookie, changed)
            if not 200 <= response.status < 300:
                raise FetchError(
                    "http", f"HTTP {response.status}", status=response.status,
                    retry_after=response.headers.get("Retry-After"),
                    session_cookie=cookie, session_cookie_changed=changed,
                )
            try:
                payload = json.loads(response.body.decode("utf-8", errors="strict"))
            except (UnicodeDecodeError, json.JSONDecodeError) as exc:
                raise FetchError(
                    "payload", "response is not valid UTF-8 JSON",
                    session_cookie=cookie, session_cookie_changed=changed,
                ) from exc
            return FetchResult(payload, cookie, changed, configured_origin)
        except FetchError as exc:
            if exc.session_cookie is None:
                exc.session_cookie = cookie
                exc.session_cookie_changed = changed
            raise


class TransportConfigError(ValueError):
    """The configured base URL is unsafe or not a server origin."""


def normalize_base_url(value: Any) -> tuple[str, str]:
    """Return a canonical base URL and its origin string.

    atcwatch accepts a server origin, not an arbitrary endpoint.  Keeping the
    path empty also makes later URL construction unambiguous.
    """

    if not isinstance(value, str) or not value.strip():
        raise TransportConfigError("url must be a non-empty string")
    raw = value.strip()
    try:
        scheme, host, port = origin(raw)
    except ValueError as exc:
        raise TransportConfigError(str(exc)) from exc
    parsed = urllib.parse.urlsplit(raw)
    if parsed.username or parsed.password:
        raise TransportConfigError("url must not contain credentials")
    if parsed.query or parsed.fragment:
        raise TransportConfigError("url must not contain query or fragment")
    if parsed.path not in {"", "/"}:
        raise TransportConfigError("url must contain only the server origin")
    display_host = f"[{host}]" if ":" in host else host
    default_port = 443 if scheme == "https" else 80
    authority = display_host if port == default_port else f"{display_host}:{port}"
    base = f"{scheme}://{authority}"
    return base, base
