"""AZ-489 — C12 ``FlightsApiClient`` unit tests.

Covers AC-1..AC-18 from ``_docs/02_tasks/todo/AZ-489_c12_flights_api_client.md``.

Online tests use ``httpx.MockTransport`` (httpx's native mock; no extra
HTTP-mocking dependency). Offline tests use ``tmp_path``-backed JSON
files. Bbox tests validate the FAC-INV-3 horizontal-distance buffer at
50 deg N against the canonical metres-per-degree expectations.
"""

from __future__ import annotations

import io
import json
import logging
import math
from collections.abc import Callable
from pathlib import Path
from uuid import UUID

import httpx
import pytest

from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
    EmptyWaypointsError,
    FlightDto,
    FlightFileNotFoundError,
    FlightNotFoundError,
    FlightsApiAuthError,
    FlightsApiClient,
    FlightsApiSchemaError,
    FlightsApiUnreachableError,
    HttpxFlightsApiClient,
    WaypointDto,
    WaypointObjective,
    WaypointSchemaError,
    WaypointSource,
    bbox_from_waypoints,
    load_flight_file,
    takeoff_origin_from_flight,
)
from gps_denied_onboard.runtime_root.c12_factory import build_flights_api_client

FLIGHT_ID = UUID("11111111-2222-3333-4444-555555555555")
BASE_URL = "https://flights.example/api"
AUTH_TOKEN = "bearer-secret-abc"  # fake token used only in tests


def _waypoint_payload(
    *,
    ordinal: int = 0,
    lat_deg: float = 50.0,
    lon_deg: float = 36.2,
    alt_m: float = 200.0,
    objective: str = "waypoint",
    source: str = "operator",
) -> dict[str, object]:
    return {
        "ordinal": ordinal,
        "lat_deg": lat_deg,
        "lon_deg": lon_deg,
        "alt_m": alt_m,
        "objective": objective,
        "source": source,
    }


def _three_waypoint_payload(*, flight_id: UUID = FLIGHT_ID) -> dict[str, object]:
    return {
        "flight_id": str(flight_id),
        "name": "derkachi-sweep",
        "waypoints": [
            _waypoint_payload(ordinal=0, lat_deg=50.0, lon_deg=36.2, alt_m=200.0,
                              objective="takeoff"),
            _waypoint_payload(ordinal=1, lat_deg=50.01, lon_deg=36.22, alt_m=210.0),
            _waypoint_payload(ordinal=2, lat_deg=50.02, lon_deg=36.24, alt_m=220.0,
                              objective="landing"),
        ],
    }


def _make_client_with_handler(
    handler: Callable[[httpx.Request], httpx.Response],
) -> tuple[HttpxFlightsApiClient, list[float]]:
    sleeps: list[float] = []

    def fake_sleep(seconds: float) -> None:
        sleeps.append(seconds)

    transport = httpx.MockTransport(handler)
    client = HttpxFlightsApiClient(transport=transport, sleep=fake_sleep)
    return client, sleeps


def _attach_capturing_handler() -> tuple[logging.Handler, io.StringIO]:
    buffer = io.StringIO()
    handler = logging.StreamHandler(buffer)
    handler.setLevel(logging.DEBUG)
    return handler, buffer


@pytest.fixture
def capture_flights_api_logs() -> tuple[logging.Handler, io.StringIO]:
    handler, buffer = _attach_capturing_handler()
    logger = logging.getLogger("c12.flights_api")
    logger.addHandler(handler)
    logger.setLevel(logging.DEBUG)
    yield handler, buffer
    logger.removeHandler(handler)


# -----------------------------------------------------------------------
# AC-1: Online happy path
# -----------------------------------------------------------------------


def test_ac1_online_happy_path_returns_three_waypoint_flight(
    capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
) -> None:
    # Arrange
    call_count = 0

    def handler(request: httpx.Request) -> httpx.Response:
        nonlocal call_count
        call_count += 1
        return httpx.Response(200, json=_three_waypoint_payload())

    client, sleeps = _make_client_with_handler(handler)
    _, buffer = capture_flights_api_logs

    # Act
    flight = client.fetch_flight(
        flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
    )

    # Assert
    assert isinstance(flight, FlightDto)
    assert flight.flight_id == FLIGHT_ID
    assert len(flight.waypoints) == 3
    assert tuple(w.ordinal for w in flight.waypoints) == (0, 1, 2)
    assert call_count == 1
    assert sleeps == []
    log_output = buffer.getvalue()
    assert AUTH_TOKEN not in log_output


# -----------------------------------------------------------------------
# AC-2: 404 - FlightNotFoundError, no retry
# -----------------------------------------------------------------------


def test_ac2_online_404_raises_flight_not_found_without_retry() -> None:
    # Arrange
    call_count = 0

    def handler(request: httpx.Request) -> httpx.Response:
        nonlocal call_count
        call_count += 1
        return httpx.Response(404, json={"error": "not found"})

    client, sleeps = _make_client_with_handler(handler)

    # Act / Assert
    with pytest.raises(FlightNotFoundError) as exc_info:
        client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)

    assert str(FLIGHT_ID) in str(exc_info.value)
    assert call_count == 1
    assert sleeps == []


# -----------------------------------------------------------------------
# AC-3: 401 - FlightsApiAuthError, no retry, no token in logs
# -----------------------------------------------------------------------


def test_ac3_online_401_raises_auth_error_without_logging_token(
    capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
) -> None:
    # Arrange
    call_count = 0

    def handler(request: httpx.Request) -> httpx.Response:
        nonlocal call_count
        call_count += 1
        return httpx.Response(401, json={"error": "unauthorized"})

    client, sleeps = _make_client_with_handler(handler)
    _, buffer = capture_flights_api_logs

    # Act / Assert
    with pytest.raises(FlightsApiAuthError) as exc_info:
        client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)

    assert AUTH_TOKEN not in str(exc_info.value)
    assert call_count == 1
    assert sleeps == []
    assert AUTH_TOKEN not in buffer.getvalue()


# -----------------------------------------------------------------------
# AC-4: 503 transient -> retry once -> success
# -----------------------------------------------------------------------


def test_ac4_online_503_then_200_retries_once_and_succeeds(
    capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
) -> None:
    # Arrange
    call_count = 0

    def handler(request: httpx.Request) -> httpx.Response:
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            return httpx.Response(503, json={"error": "transient"})
        return httpx.Response(200, json=_three_waypoint_payload())

    client, sleeps = _make_client_with_handler(handler)
    _, buffer = capture_flights_api_logs

    # Act
    flight = client.fetch_flight(
        flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
    )

    # Assert
    assert isinstance(flight, FlightDto)
    assert call_count == 2
    assert sleeps == [1.0]
    log_output = buffer.getvalue()
    assert "c12.flights.fetch.retry" in log_output
    assert AUTH_TOKEN not in log_output


# -----------------------------------------------------------------------
# AC-5: 503 persistent -> Unreachable after one retry
# -----------------------------------------------------------------------


def test_ac5_online_503_always_raises_unreachable_after_one_retry(
    capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
) -> None:
    # Arrange
    call_count = 0

    def handler(request: httpx.Request) -> httpx.Response:
        nonlocal call_count
        call_count += 1
        return httpx.Response(503, json={"error": "down"})

    client, sleeps = _make_client_with_handler(handler)

    # Act / Assert
    with pytest.raises(FlightsApiUnreachableError):
        client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)

    assert call_count == 2
    assert sleeps == [1.0]


# -----------------------------------------------------------------------
# AC-6: Schema drift (missing lat) raises FlightsApiSchemaError or WaypointSchemaError
# -----------------------------------------------------------------------


def test_ac6_online_schema_drift_raises_with_field_reference() -> None:
    # Arrange
    payload = _three_waypoint_payload()
    waypoints = payload["waypoints"]
    assert isinstance(waypoints, list)
    del waypoints[1]["lat_deg"]  # type: ignore[index]

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, json=payload)

    client, _ = _make_client_with_handler(handler)

    # Act / Assert
    with pytest.raises(WaypointSchemaError) as exc_info:
        client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)

    assert "lat_deg" in str(exc_info.value)


# -----------------------------------------------------------------------
# AC-7: Offline happy path
# -----------------------------------------------------------------------


def test_ac7_offline_happy_path_returns_equivalent_flight_dto(tmp_path: Path) -> None:
    # Arrange
    payload = _three_waypoint_payload()
    flight_file = tmp_path / "flight.json"
    flight_file.write_bytes(json.dumps(payload).encode())

    # Act
    flight = load_flight_file(path=flight_file)

    # Assert
    assert flight.flight_id == FLIGHT_ID
    assert len(flight.waypoints) == 3
    assert flight.waypoints[0].objective == WaypointObjective.TAKEOFF


# -----------------------------------------------------------------------
# AC-8: Offline missing file
# -----------------------------------------------------------------------


def test_ac8_offline_missing_file_raises_with_path_in_message(tmp_path: Path) -> None:
    # Arrange
    missing = tmp_path / "does-not-exist.json"

    # Act / Assert
    with pytest.raises(FlightFileNotFoundError) as exc_info:
        load_flight_file(path=missing)

    assert str(missing) in str(exc_info.value)


# -----------------------------------------------------------------------
# AC-9: Empty waypoints -> bbox raises EmptyWaypointsError
# -----------------------------------------------------------------------


def test_ac9_empty_waypoints_into_bbox_raises_empty_waypoints_error() -> None:
    # Act / Assert
    with pytest.raises(EmptyWaypointsError):
        bbox_from_waypoints((), buffer_m=1000.0)


# -----------------------------------------------------------------------
# AC-10: Bbox 1 km buffer at 50N stays within 5% of horizontal-distance target
# -----------------------------------------------------------------------


def _make_corner_waypoints(centre: LatLonAlt, half_extent_m: float) -> tuple[WaypointDto, ...]:
    metres_per_deg_lat = 111_320.0
    metres_per_deg_lon = 111_320.0 * math.cos(math.radians(centre.lat_deg))
    d_lat = half_extent_m / metres_per_deg_lat
    d_lon = half_extent_m / metres_per_deg_lon
    return (
        WaypointDto(
            ordinal=0,
            lat_deg=centre.lat_deg - d_lat,
            lon_deg=centre.lon_deg - d_lon,
            alt_m=centre.alt_m,
            objective=WaypointObjective.TAKEOFF,
            source=WaypointSource.OPERATOR,
        ),
        WaypointDto(
            ordinal=1,
            lat_deg=centre.lat_deg - d_lat,
            lon_deg=centre.lon_deg + d_lon,
            alt_m=centre.alt_m,
            objective=WaypointObjective.WAYPOINT,
            source=WaypointSource.OPERATOR,
        ),
        WaypointDto(
            ordinal=2,
            lat_deg=centre.lat_deg + d_lat,
            lon_deg=centre.lon_deg + d_lon,
            alt_m=centre.alt_m,
            objective=WaypointObjective.WAYPOINT,
            source=WaypointSource.OPERATOR,
        ),
        WaypointDto(
            ordinal=3,
            lat_deg=centre.lat_deg + d_lat,
            lon_deg=centre.lon_deg - d_lon,
            alt_m=centre.alt_m,
            objective=WaypointObjective.LANDING,
            source=WaypointSource.OPERATOR,
        ),
    )


def test_ac10_bbox_buffer_is_horizontal_distance_within_five_percent_at_50n() -> None:
    # Arrange
    centre = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=0.0)
    half_extent_m = 500.0  # 1 km box overall
    waypoints = _make_corner_waypoints(centre, half_extent_m)
    buffer_m = 1000.0

    # Act
    bbox = bbox_from_waypoints(waypoints, buffer_m=buffer_m)

    # Assert
    metres_per_deg_lat = 111_320.0
    metres_per_deg_lon = 111_320.0 * math.cos(math.radians(centre.lat_deg))
    expected_lat_half_extent = half_extent_m + buffer_m
    expected_lon_half_extent = half_extent_m + buffer_m
    expected_min_lat = centre.lat_deg - expected_lat_half_extent / metres_per_deg_lat
    expected_max_lat = centre.lat_deg + expected_lat_half_extent / metres_per_deg_lat
    expected_min_lon = centre.lon_deg - expected_lon_half_extent / metres_per_deg_lon
    expected_max_lon = centre.lon_deg + expected_lon_half_extent / metres_per_deg_lon

    assert bbox.min_lat_deg == pytest.approx(expected_min_lat, rel=0.05)
    assert bbox.max_lat_deg == pytest.approx(expected_max_lat, rel=0.05)
    assert bbox.min_lon_deg == pytest.approx(expected_min_lon, rel=0.05)
    assert bbox.max_lon_deg == pytest.approx(expected_max_lon, rel=0.05)


# -----------------------------------------------------------------------
# AC-11: Takeoff origin pass-through
# -----------------------------------------------------------------------


def test_ac11_takeoff_origin_is_first_waypoint_with_no_rounding() -> None:
    # Arrange
    flight = FlightDto(
        flight_id=FLIGHT_ID,
        name="derkachi",
        waypoints=(
            WaypointDto(
                ordinal=0,
                lat_deg=50.000000001,
                lon_deg=36.200000001,
                alt_m=200.000000001,
                objective=WaypointObjective.TAKEOFF,
                source=WaypointSource.OPERATOR,
            ),
            WaypointDto(
                ordinal=1,
                lat_deg=51.0,
                lon_deg=37.0,
                alt_m=210.0,
                objective=WaypointObjective.WAYPOINT,
                source=WaypointSource.OPERATOR,
            ),
        ),
    )

    # Act
    origin = takeoff_origin_from_flight(flight)

    # Assert
    assert origin == LatLonAlt(50.000000001, 36.200000001, 200.000000001)


# -----------------------------------------------------------------------
# AC-12: Conformance
# -----------------------------------------------------------------------


def test_ac12_httpx_flights_api_client_satisfies_protocol() -> None:
    # Assert
    assert isinstance(HttpxFlightsApiClient(), FlightsApiClient)


def test_ac12_runtime_root_factory_returns_protocol_conforming_instance() -> None:
    # Arrange
    config = object()  # factory ignores config in v1

    # Act
    client = build_flights_api_client(config)  # type: ignore[arg-type]

    # Assert
    assert isinstance(client, FlightsApiClient)


# -----------------------------------------------------------------------
# AC-13: Online + Offline equality
# -----------------------------------------------------------------------


def test_ac13_online_and_offline_produce_equal_dtos(tmp_path: Path) -> None:
    # Arrange
    payload = _three_waypoint_payload()

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, json=payload)

    client, _ = _make_client_with_handler(handler)
    flight_file = tmp_path / "flight.json"
    flight_file.write_bytes(json.dumps(payload).encode())

    # Act
    online_dto = client.fetch_flight(
        flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
    )
    offline_dto = load_flight_file(path=flight_file)

    # Assert
    assert online_dto == offline_dto


# -----------------------------------------------------------------------
# AC-14: Shuffled ordinals -> sorted output
# -----------------------------------------------------------------------


def test_ac14_shuffled_ordinals_are_returned_in_sorted_order() -> None:
    # Arrange
    payload = _three_waypoint_payload()
    waypoints = payload["waypoints"]
    assert isinstance(waypoints, list)
    waypoints[0], waypoints[2] = waypoints[2], waypoints[0]

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, json=payload)

    client, _ = _make_client_with_handler(handler)

    # Act
    flight = client.fetch_flight(
        flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
    )

    # Assert
    assert tuple(w.ordinal for w in flight.waypoints) == (0, 1, 2)


# -----------------------------------------------------------------------
# AC-15: Ordinal gap raises WaypointSchemaError
# -----------------------------------------------------------------------


def test_ac15_ordinal_gap_raises_waypoint_schema_error() -> None:
    # Arrange
    payload = _three_waypoint_payload()
    waypoints = payload["waypoints"]
    assert isinstance(waypoints, list)
    waypoints[2]["ordinal"] = 5  # type: ignore[index]

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, json=payload)

    client, _ = _make_client_with_handler(handler)

    # Act / Assert
    with pytest.raises(WaypointSchemaError) as exc_info:
        client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
    assert "ordinal" in str(exc_info.value)


# -----------------------------------------------------------------------
# AC-16: Out-of-range lat raises WaypointSchemaError
# -----------------------------------------------------------------------


def test_ac16_lat_200_raises_waypoint_schema_error() -> None:
    # Arrange
    payload = _three_waypoint_payload()
    waypoints = payload["waypoints"]
    assert isinstance(waypoints, list)
    waypoints[0]["lat_deg"] = 200.0  # type: ignore[index]

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, json=payload)

    client, _ = _make_client_with_handler(handler)

    # Act / Assert
    with pytest.raises(WaypointSchemaError) as exc_info:
        client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
    assert "lat_deg" in str(exc_info.value)
    assert "200" in str(exc_info.value)


# -----------------------------------------------------------------------
# AC-17: Token redaction across all paths
# -----------------------------------------------------------------------


@pytest.mark.parametrize(
    "status_code,first_payload,second_payload",
    [
        (200, _three_waypoint_payload(), None),
        (401, {"error": "unauthorized"}, None),
        (404, {"error": "not found"}, None),
        (500, {"error": "server"}, None),
    ],
)
def test_ac17_auth_token_never_appears_in_logs(
    capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
    status_code: int,
    first_payload: dict[str, object],
    second_payload: dict[str, object] | None,
) -> None:
    # Arrange
    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(status_code, json=first_payload)

    client, _ = _make_client_with_handler(handler)
    _, buffer = capture_flights_api_logs

    # Act
    try:
        client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
    except Exception:
        pass

    # Assert
    log_output = buffer.getvalue()
    assert AUTH_TOKEN not in log_output


# -----------------------------------------------------------------------
# AC-18: Timeout (connect error) -> Unreachable after one retry
# -----------------------------------------------------------------------


def test_ac18_persistent_connect_error_raises_unreachable_after_one_retry(
    capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
) -> None:
    # Arrange
    call_count = 0

    def handler(request: httpx.Request) -> httpx.Response:
        nonlocal call_count
        call_count += 1
        raise httpx.ConnectError("simulated tcp reset")

    client, sleeps = _make_client_with_handler(handler)
    _, buffer = capture_flights_api_logs

    # Act / Assert
    with pytest.raises(FlightsApiUnreachableError):
        client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)

    assert call_count == 2
    assert sleeps == [1.0]
    assert "c12.flights.fetch.retry" in buffer.getvalue()


# -----------------------------------------------------------------------
# Extra coverage: file with malformed JSON, bbox negative buffer
# -----------------------------------------------------------------------


def test_offline_malformed_json_raises_schema_error(tmp_path: Path) -> None:
    # Arrange
    flight_file = tmp_path / "broken.json"
    flight_file.write_bytes(b"{not-json")

    # Act / Assert
    with pytest.raises(FlightsApiSchemaError):
        load_flight_file(path=flight_file)


def test_bbox_negative_buffer_raises_value_error() -> None:
    # Arrange
    centre = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=0.0)
    waypoints = _make_corner_waypoints(centre, 500.0)

    # Act / Assert
    with pytest.raises(ValueError):
        bbox_from_waypoints(waypoints, buffer_m=-1.0)


def test_bbox_zero_buffer_returns_envelope() -> None:
    # Arrange
    centre = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=0.0)
    waypoints = _make_corner_waypoints(centre, 500.0)

    # Act
    bbox = bbox_from_waypoints(waypoints, buffer_m=0.0)

    # Assert
    metres_per_deg_lat = 111_320.0
    metres_per_deg_lon = 111_320.0 * math.cos(math.radians(centre.lat_deg))
    expected_min_lat = centre.lat_deg - 500.0 / metres_per_deg_lat
    expected_max_lat = centre.lat_deg + 500.0 / metres_per_deg_lat
    expected_min_lon = centre.lon_deg - 500.0 / metres_per_deg_lon
    expected_max_lon = centre.lon_deg + 500.0 / metres_per_deg_lon
    assert isinstance(bbox, BoundingBox)
    assert bbox.min_lat_deg == pytest.approx(expected_min_lat, rel=0.01)
    assert bbox.max_lat_deg == pytest.approx(expected_max_lat, rel=0.01)
    assert bbox.min_lon_deg == pytest.approx(expected_min_lon, rel=0.01)
    assert bbox.max_lon_deg == pytest.approx(expected_max_lon, rel=0.01)


def test_parser_rejects_missing_top_level_fields(tmp_path: Path) -> None:
    # Arrange
    flight_file = tmp_path / "no-flight-id.json"
    payload = _three_waypoint_payload()
    del payload["flight_id"]
    flight_file.write_bytes(json.dumps(payload).encode())

    # Act / Assert
    with pytest.raises(FlightsApiSchemaError):
        load_flight_file(path=flight_file)


def test_parser_rejects_negative_ordinal(tmp_path: Path) -> None:
    # Arrange
    payload = _three_waypoint_payload()
    waypoints = payload["waypoints"]
    assert isinstance(waypoints, list)
    waypoints[0]["ordinal"] = -1  # type: ignore[index]
    flight_file = tmp_path / "neg.json"
    flight_file.write_bytes(json.dumps(payload).encode())

    # Act / Assert
    with pytest.raises(WaypointSchemaError):
        load_flight_file(path=flight_file)


def test_takeoff_origin_on_empty_flight_raises_empty_waypoints_error() -> None:
    # Arrange
    flight = FlightDto(flight_id=FLIGHT_ID, name="empty", waypoints=())

    # Act / Assert
    with pytest.raises(EmptyWaypointsError):
        takeoff_origin_from_flight(flight)
