"""Snapshot tests that replay captured live responses through the parser.

These tests give us deterministic, offline coverage of the response-side
parsing pipeline (no Google requests). Captured responses live under
``tests/search/fixtures/`` and are re-generated by running
``uv run python scripts/capture_fixtures.py``.

## Drift policy

These fixtures are real Google Flights responses, so they age out as
prices move, carriers add/drop routes, or Google reshapes the payload.
The assertions in this file are written to be *durable* against that
drift — they target filter enforcement (e.g. "no Delta when DL is
excluded") and parser structure (CO₂ populated on at least one row),
not specific prices or flight numbers.

If a snapshot test starts failing:

1. Re-run the live integration tests in
   ``test_search_flights_new_filters_live.py``. They issue the same
   queries against Google's API; if those pass and the snapshot fails,
   the fixture is stale.
2. Re-capture: ``uv run python scripts/capture_fixtures.py``.
3. Re-run the snapshot suite to confirm green.

If the live tests *also* fail with the same shape, Google has changed
the response and the parser needs updating — start with
``.reverse-eng/notes/response_map.md`` for position references.

Route-specific notes:
- ``flight_search_jfk_lax_oneway_usd`` — pure positional sanity checks
  (parser populates basic fields).
- ``flight_search_jfk_fra_oneworld`` — alliance filter enforcement.
- ``flight_search_buf_ath_min_layover_120`` — multi-stop trip; layover
  derivation + min-duration enforcement.
- ``flight_search_jfk_lax_exclude_dl`` — airline exclude enforcement.
- ``flight_search_jfk_lax_eur`` — currency URL param effective.
- ``flight_search_lax_lhr_rt_biz_2a1c`` — premium-cabin round-trip with
  family pax (2 adults + 1 child). Pinned regression for issue #165:
  Google emits these rows with an empty price head, and the parser must
  surface them with ``price=None`` rather than dropping the entire
  result set as malformed.
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest

from fli.search._decoders import parse_flight_row

FIXTURE_DIR = Path(__file__).parent / "fixtures"


def _replay(name: str) -> list:
    """Replay a captured GetShoppingResults body through the row-level parser.

    Mirrors the exact path used by :meth:`SearchFlights.search` so the
    snapshot exercises the same decoder code in production.
    """
    body = (FIXTURE_DIR / name).read_text()
    # Same stripping logic as SearchFlights.search:
    outer = json.loads(body.lstrip(")]}'"))
    inner_str = outer[0][2]
    if not inner_str:
        return []
    inner = json.loads(inner_str)
    rows = [item for i in (2, 3) if isinstance(inner[i], list) for item in inner[i][0]]
    flights = []
    for row in rows:
        try:
            flights.append(parse_flight_row(row))
        except (AttributeError, KeyError, ValueError, TypeError):
            continue
    return flights


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture(scope="module")
def jfk_lax_oneway():
    return _replay("flight_search_jfk_lax_oneway_usd.bin")


@pytest.fixture(scope="module")
def jfk_fra_oneworld():
    return _replay("flight_search_jfk_fra_oneworld.bin")


@pytest.fixture(scope="module")
def buf_ath_min_layover():
    return _replay("flight_search_buf_ath_min_layover_120.bin")


@pytest.fixture(scope="module")
def jfk_lax_exclude_dl():
    return _replay("flight_search_jfk_lax_exclude_dl.bin")


@pytest.fixture(scope="module")
def jfk_lax_eur():
    return _replay("flight_search_jfk_lax_eur.bin")


@pytest.fixture(scope="module")
def lax_lhr_rt_biz_2a1c():
    """Premium-cabin round-trip with family pax — issue #165 regression."""
    return _replay("flight_search_lax_lhr_rt_biz_2a1c.bin")


# ---------------------------------------------------------------------------
# Generic parser-health assertions
# ---------------------------------------------------------------------------


class TestSnapshotParsing:
    """Every fixture should yield at least one parseable flight."""

    def test_jfk_lax_oneway_has_results(self, jfk_lax_oneway):
        assert len(jfk_lax_oneway) > 0

    def test_jfk_fra_oneworld_has_results(self, jfk_fra_oneworld):
        assert len(jfk_fra_oneworld) > 0

    def test_buf_ath_min_layover_has_results(self, buf_ath_min_layover):
        assert len(buf_ath_min_layover) > 0

    def test_jfk_lax_exclude_dl_has_results(self, jfk_lax_exclude_dl):
        assert len(jfk_lax_exclude_dl) > 0

    def test_jfk_lax_eur_has_results(self, jfk_lax_eur):
        assert len(jfk_lax_eur) > 0

    def test_premium_rt_family_yields_results(self, lax_lhr_rt_biz_2a1c):
        """Premium-cabin RT rows must parse even when Google omits prices.

        Issue #165: prior to the fix, every row in this fixture had an
        empty price head (``[[], "<token>"]``) and the parser raised
        ``SearchParseError`` for the whole batch. After the fix, rows
        surface with ``price=None`` and the search returns the routing
        options. Real fares come from a follow-up ``get_booking_options``
        call using the per-row ``booking_token``.
        """
        assert len(lax_lhr_rt_biz_2a1c) > 0


class TestSnapshotFlightShape:
    """Sanity-check that parsed flights have the expected fields populated."""

    def test_basic_fields_present(self, jfk_lax_oneway):
        f = jfk_lax_oneway[0]
        # One-way responses always carry a per-row price — None here
        # would indicate a regression in the price-head parsing.
        assert f.price is not None and f.price > 0
        assert f.duration > 0
        assert f.stops >= 0
        assert len(f.legs) >= 1
        assert f.legs[0].departure_airport.name == "JFK"
        assert f.legs[0].arrival_airport.name == "LAX"

    def test_currency_decoded_to_usd(self, jfk_lax_oneway):
        # All flights captured with curr=USD should report USD.
        currencies = {f.currency for f in jfk_lax_oneway if f.currency}
        assert currencies == {"USD"}

    def test_currency_decoded_to_eur(self, jfk_lax_eur):
        currencies = {f.currency for f in jfk_lax_eur if f.currency}
        assert "EUR" in currencies

    def test_co2_emissions_populated_on_some_flights(self, jfk_lax_oneway):
        with_co2 = [f for f in jfk_lax_oneway if f.co2_emissions_g]
        assert len(with_co2) >= 1, "Expected CO2 g on at least one flight"

    def test_primary_airline_populated(self, jfk_lax_oneway):
        with_pa = [f for f in jfk_lax_oneway if f.primary_airline]
        assert with_pa, "Expected primary_airline on at least one flight"

    def test_aircraft_populated_on_some_legs(self, jfk_lax_oneway):
        with_ac = [leg for f in jfk_lax_oneway for leg in f.legs if leg.aircraft]
        assert with_ac, "Expected aircraft set on at least one leg"


class TestSnapshotFilterEnforcement:
    """The captured filter responses should reflect the filter applied."""

    _ONEWORLD = {
        "AA",
        "AS",
        "AT",
        "AY",
        "BA",
        "CX",
        "FJ",
        "IB",
        "JL",
        "MH",
        "QF",
        "QR",
        "RJ",
        "S7",
        "UL",
        "DE",  # Condor is a Oneworld-connect partner
    }

    def test_oneworld_filter_only_oneworld(self, jfk_fra_oneworld):
        carriers = {f.legs[0].airline.name.lstrip("_") for f in jfk_fra_oneworld}
        # Ignore "multi" pseudo-code for codeshares.
        carriers.discard("multi")
        outside = carriers - self._ONEWORLD
        assert not outside, f"Non-Oneworld carriers found in fixture: {outside}"

    def test_exclude_dl_drops_delta(self, jfk_lax_exclude_dl):
        carriers = {f.legs[0].airline.name.lstrip("_") for f in jfk_lax_exclude_dl}
        assert "DL" not in carriers

    def test_min_layover_120_minutes_all_satisfy(self, buf_ath_min_layover):
        # Every layover on multi-leg flights must be ≥ 120 minutes.
        for f in buf_ath_min_layover:
            if not f.layovers:
                continue
            for lo in f.layovers:
                assert lo.duration >= 120, f"Found {lo.duration}-min layover at {lo.airport.name}"


class TestSnapshotLayoversAndOvernight:
    """Layover derivation works correctly across a multi-stop fixture."""

    def test_layovers_present_when_multi_leg(self, buf_ath_min_layover):
        # Most BUF→ATH results are 2-stop and should have layovers.
        with_layovers = [f for f in buf_ath_min_layover if f.layovers]
        assert with_layovers, "Expected layovers on at least one BUF→ATH flight"

    def test_layover_airport_matches_inter_leg_airports(self, buf_ath_min_layover):
        for f in buf_ath_min_layover:
            if not f.layovers:
                continue
            for i, lo in enumerate(f.layovers):
                assert lo.airport == f.legs[i].arrival_airport, (
                    "Layover airport should match the previous leg's arrival"
                )

    def test_overnight_flag_consistent_with_dates(self, buf_ath_min_layover):
        # When the flag is True, the leg actually crosses midnight.
        for f in buf_ath_min_layover:
            for leg in f.legs:
                if leg.overnight:
                    # Arrival on a later date than departure (or arrival
                    # time < departure time within the same day).
                    assert (
                        leg.arrival_datetime.date() > leg.departure_datetime.date()
                        or leg.arrival_datetime < leg.departure_datetime
                    )

    def test_layover_city_populated_when_detail_block_present(self, buf_ath_min_layover):
        # Google's detail[13] block carries layover city names; at least one
        # multi-stop flight should have city populated on a layover.
        for f in buf_ath_min_layover:
            if not f.layovers:
                continue
            if any(lo.city for lo in f.layovers):
                return
        pytest.fail("Expected at least one layover with a city name from detail[13]")


class TestPremiumRoundTripPriceless:
    """Regression coverage for issue #165.

    The fixture captures a Google response where every outbound row has
    an empty price head — that is, ``row[1] == [[], "<token>"]``. The
    parser must treat this as "Google declined to surface a price for
    this row" rather than "malformed row", because the per-row booking
    token at ``row[8]`` is still valid and resolves to real fares via
    ``GetBookingResults``.
    """

    def test_all_rows_have_none_price(self, lax_lhr_rt_biz_2a1c):
        """Uniformly empty price heads land as ``price=None`` on every row."""
        assert lax_lhr_rt_biz_2a1c
        assert all(f.price is None for f in lax_lhr_rt_biz_2a1c), (
            "Expected every premium-RT row to surface with price=None; "
            "got prices: " + str([f.price for f in lax_lhr_rt_biz_2a1c])
        )
        # The convenience property mirrors the None check exactly.
        assert all(f.price_unknown for f in lax_lhr_rt_biz_2a1c)

    def test_currency_decode_does_not_raise(self, lax_lhr_rt_biz_2a1c):
        """Empty-head price blocks may omit the currency hint without raising."""
        for f in lax_lhr_rt_biz_2a1c:
            # ``str | None`` — accept either; just confirm no exception.
            assert f.currency is None or isinstance(f.currency, str)

    def test_booking_tokens_populated(self, lax_lhr_rt_biz_2a1c):
        """Per-row booking_token (row[8]) is still set on priceless rows."""
        with_token = [f for f in lax_lhr_rt_biz_2a1c if f.booking_token]
        assert with_token == lax_lhr_rt_biz_2a1c, (
            "Every priceless premium-RT row must carry a booking_token"
        )

    def test_routing_info_populated(self, lax_lhr_rt_biz_2a1c):
        """Routing fields (legs, airports) populate even when price is unknown."""
        for f in lax_lhr_rt_biz_2a1c:
            assert len(f.legs) >= 1
            assert f.duration > 0
            assert f.legs[0].departure_airport.name == "LAX"
            assert f.legs[-1].arrival_airport.name == "LHR"
