"""``HttpxFlightsApiClient`` — concrete :class:`FlightsApiClient` (AZ-489).

Online path uses ``httpx`` and is unit-tested via ``httpx.MockTransport``.
Offline path delegates to :func:`load_flight_file`. The auth token is never
emitted to logs (FAC-INV-7); structured log records carry the redacted
``"<redacted>"`` marker.

Retry policy (FAC-INV-5):
    * Connection errors and 5xx → one retry with 1 s backoff.
    * 401 / 403 / 404 / schema failures → never retried.
"""

from __future__ import annotations

from collections.abc import Callable
from pathlib import Path
from typing import Final
from uuid import UUID

import httpx

from gps_denied_onboard.clock.wall_clock import WallClock

from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
from gps_denied_onboard.components.c12_operator_tooling.flights_api._parser import (
    parse_flight_payload,
)
from gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox import (
    bbox_from_waypoints,
    takeoff_origin_from_flight,
)
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
    FlightNotFoundError,
    FlightsApiAuthError,
    FlightsApiSchemaError,
    FlightsApiUnreachableError,
)
from gps_denied_onboard.components.c12_operator_tooling.flights_api.file_loader import (
    load_flight_file,
)
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
    FlightDto,
    WaypointDto,
)
from gps_denied_onboard.logging import get_logger

__all__ = ["HttpxFlightsApiClient"]


_REDACTED: Final[str] = "<redacted>"
_RETRY_BACKOFF_S: Final[float] = 1.0


def _wall_clock_sleep(seconds: float) -> None:
    """Default sleep hook — routes through :class:`WallClock`.

    Kept as a module-level function (not a lambda or closure) so the
    AC-4 AST scan over ``components/`` never sees a bare stdlib
    ``time``-module sleep reference. Tests inject their own ``sleep``
    callable to skip the backoff.
    """
    clock = WallClock()
    clock.sleep_until_ns(clock.monotonic_ns() + int(seconds * 1_000_000_000))


class HttpxFlightsApiClient:
    """Concrete :class:`FlightsApiClient` against the parent-suite ``flights`` REST API.

    ``transport`` is an optional ``httpx.BaseTransport`` injected by tests
    (``httpx.MockTransport``). Production code omits it; the default
    transport opens a real HTTPS connection with the system trust store.

    ``sleep`` is the retry-backoff hook; tests inject a no-op or a stub so
    they don't wait 1 s on the retry path.
    """

    def __init__(
        self,
        *,
        transport: httpx.BaseTransport | None = None,
        sleep: Callable[[float], None] | None = None,
    ) -> None:
        self._transport = transport
        self._sleep: Callable[[float], None] = (
            sleep if sleep is not None else _wall_clock_sleep
        )
        self._log = get_logger("c12.flights_api")

    def fetch_flight(
        self,
        *,
        flight_id: UUID,
        base_url: str,
        auth_token: str,
        timeout_s: float = 10.0,
    ) -> FlightDto:
        url = self._build_url(base_url, flight_id)
        headers = {"Authorization": f"Bearer {auth_token}", "Accept": "application/json"}
        client_kwargs: dict[str, object] = {
            "timeout": httpx.Timeout(timeout_s),
        }
        if self._transport is not None:
            client_kwargs["transport"] = self._transport
        with httpx.Client(**client_kwargs) as client:  # type: ignore[arg-type]
            response = self._request_with_one_retry(
                client=client, url=url, headers=headers, flight_id=flight_id
            )
        return self._parse_response(response, flight_id=flight_id)

    def load_flight_file(self, *, path: Path) -> FlightDto:
        return load_flight_file(path=path)

    def bbox_from_waypoints(
        self,
        waypoints: tuple[WaypointDto, ...],
        *,
        buffer_m: float = 1000.0,
    ) -> BoundingBox:
        return bbox_from_waypoints(waypoints, buffer_m=buffer_m)

    def takeoff_origin_from_flight(self, flight: FlightDto) -> LatLonAlt:
        return takeoff_origin_from_flight(flight)

    def _request_with_one_retry(
        self,
        *,
        client: httpx.Client,
        url: str,
        headers: dict[str, str],
        flight_id: UUID,
    ) -> httpx.Response:
        try:
            response = client.get(url, headers=headers)
        except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
            return self._retry_after_transient(
                client=client, url=url, headers=headers, flight_id=flight_id, reason=str(exc)
            )

        if response.status_code in (401, 403):
            self._log_failure("c12.flights.fetch.failed", flight_id, response.status_code, "auth")
            raise FlightsApiAuthError(
                f"flights service rejected auth_token={_REDACTED} for flight_id={flight_id} "
                f"(http_status={response.status_code})"
            )
        if response.status_code == 404:
            self._log_failure("c12.flights.fetch.failed", flight_id, 404, "not_found")
            raise FlightNotFoundError(
                f"flights service has no flight with flight_id={flight_id} (http 404)"
            )
        if response.status_code >= 500:
            return self._retry_after_transient(
                client=client,
                url=url,
                headers=headers,
                flight_id=flight_id,
                reason=f"http_status={response.status_code}",
            )
        return response

    def _retry_after_transient(
        self,
        *,
        client: httpx.Client,
        url: str,
        headers: dict[str, str],
        flight_id: UUID,
        reason: str,
    ) -> httpx.Response:
        self._log.warning(
            "c12.flights.fetch.retry",
            extra={
                "kind": "c12.flights.fetch.retry",
                "kv": {
                    "flight_id": str(flight_id),
                    "reason": reason,
                    "backoff_s": _RETRY_BACKOFF_S,
                    "auth_token": _REDACTED,
                },
            },
        )
        self._sleep(_RETRY_BACKOFF_S)
        try:
            response = client.get(url, headers=headers)
        except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
            self._log_failure("c12.flights.fetch.failed", flight_id, None, f"connect:{exc}")
            raise FlightsApiUnreachableError(
                f"flights service unreachable for flight_id={flight_id} after one retry: {exc}"
            ) from exc

        if response.status_code in (401, 403):
            self._log_failure("c12.flights.fetch.failed", flight_id, response.status_code, "auth")
            raise FlightsApiAuthError(
                f"flights service rejected auth_token={_REDACTED} for flight_id={flight_id} "
                f"(http_status={response.status_code})"
            )
        if response.status_code == 404:
            self._log_failure("c12.flights.fetch.failed", flight_id, 404, "not_found")
            raise FlightNotFoundError(
                f"flights service has no flight with flight_id={flight_id} (http 404)"
            )
        if response.status_code >= 500:
            self._log_failure(
                "c12.flights.fetch.failed", flight_id, response.status_code, "unreachable"
            )
            raise FlightsApiUnreachableError(
                f"flights service returned {response.status_code} for flight_id={flight_id} "
                f"after one retry"
            )
        return response

    def _parse_response(self, response: httpx.Response, *, flight_id: UUID) -> FlightDto:
        try:
            payload = response.json()
        except ValueError as exc:
            self._log_failure(
                "c12.flights.fetch.failed", flight_id, response.status_code, "schema:not_json"
            )
            raise FlightsApiSchemaError(
                f"flights service returned non-JSON body for flight_id={flight_id}: {exc}"
            ) from exc
        try:
            flight = parse_flight_payload(payload, source_label="flights service")
        except FlightsApiSchemaError:
            self._log_failure(
                "c12.flights.fetch.failed", flight_id, response.status_code, "schema:flight"
            )
            raise
        except Exception:
            self._log_failure(
                "c12.flights.fetch.failed", flight_id, response.status_code, "schema:waypoint"
            )
            raise
        self._log.info(
            "c12.flights.fetch.success",
            extra={
                "kind": "c12.flights.fetch.success",
                "kv": {
                    "flight_id": str(flight.flight_id),
                    "name": flight.name,
                    "waypoint_count": len(flight.waypoints),
                    "auth_token": _REDACTED,
                },
            },
        )
        return flight

    def _log_failure(
        self,
        kind: str,
        flight_id: UUID,
        http_status: int | None,
        reason: str,
    ) -> None:
        self._log.error(
            kind,
            extra={
                "kind": kind,
                "kv": {
                    "flight_id": str(flight_id),
                    "http_status": http_status,
                    "reason": reason,
                    "auth_token": _REDACTED,
                },
            },
        )

    @staticmethod
    def _build_url(base_url: str, flight_id: UUID) -> str:
        return base_url.rstrip("/") + f"/flights/{flight_id}"
