"""Schema parity tests — proves historical (API) and live (AWC) produce identical Observations."""

from __future__ import annotations

import json
import math
from pathlib import Path

import pytest

from therminal.models.observation import Observation

# ---------------------------------------------------------------------------
# Synthetic matching fixtures — same METAR report as both AWC and API JSON
# ---------------------------------------------------------------------------

AWC_FIXTURE: dict = {
    "icaoId": "KNYC",
    "obsTime": 1774821060,
    "temp": 10.6,
    "dewp": -1.1,
    "wdir": 140,
    "wspd": 6,
    "wgst": 15,
    "visib": "10+",
    "altim": 1029.9,
    "slp": 1029,
    "metarType": "METAR",
    "rawOb": (
        "METAR KNYC 292151Z AUTO 14006G15KT 10SM FEW120 "
        "11/M01 A3041 RMK AO2 SLP290 T01061011 $"
    ),
    "clouds": [{"cover": "FEW", "base": 12000}],
}

# Compute the values the Go API would return for the same METAR report.
# Go converts °C -> °F and hPa -> inHg with math.Round (round-half-away-from-zero).
_temp_f = math.floor((10.6 * 9 / 5 + 32) * 10 + 0.5) / 10
_dewp_f = math.floor((-1.1 * 9 / 5 + 32) * 10 + 0.5) / 10
_altim_inhg = math.floor(1029.9 * 0.02953 * 100 + 0.5) / 100
_observed_at = "2026-03-29T21:51:00Z"

API_FIXTURE: dict = {
    "station_code": "NYC",
    "observed_at": _observed_at,
    "observation_type": "METAR",
    "source": "awc",
    "temp_f": _temp_f,
    "dewpoint_f": _dewp_f,
    "wind_dir_degrees": 140,
    "wind_speed_kt": 6,
    "wind_gust_kt": 15,
    "altimeter_inhg": _altim_inhg,
    "sea_level_pressure_mb": 1029,
    "sky_cover_1": "FEW",
    "sky_base_1_ft": 12000,
    "visibility_miles": 10.0,
    "raw_metar": (
        "METAR KNYC 292151Z AUTO 14006G15KT 10SM FEW120 "
        "11/M01 A3041 RMK AO2 SLP290 T01061011 $"
    ),
}

FIXTURES_DIR = Path(__file__).parent

# Fields that are expected to differ between sources
# Fields that intentionally differ between API and AWC paths:
# - source: "iem"/"noaa_isd" (API) vs "awc" (live)
# - relative_humidity: None from API (AWC-sourced obs don't store RH in DB),
#   but computed by SDK from temp+dewpoint via Magnus formula for live data
_EXCLUDE_FIELDS = frozenset({"source", "relative_humidity"})

# Total number of fields on an Observation (including source)
_EXPECTED_FIELD_COUNT = 29


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _float_eq(a: float | None, b: float | None, tol: float = 1e-6) -> bool:
    """Compare two optional floats within tolerance."""
    if a is None and b is None:
        return True
    if a is None or b is None:
        return False
    return abs(a - b) < tol


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


class TestSchemaParity:
    """Verify that from_api_dict and from_awc_json yield identical Observations."""

    @pytest.fixture()
    def obs_from_api(self) -> Observation:
        return Observation.from_api_dict(API_FIXTURE)

    @pytest.fixture()
    def obs_from_awc(self) -> Observation:
        obs = Observation.from_awc_json(AWC_FIXTURE)
        assert obs is not None, "from_awc_json returned None for valid fixture"
        return obs

    def test_schema_parity(
        self, obs_from_api: Observation, obs_from_awc: Observation
    ) -> None:
        """Field values match (excluding source) with float tolerance."""
        api_dict = obs_from_api.to_dict()
        awc_dict = obs_from_awc.to_dict()

        for key in api_dict:
            if key in _EXCLUDE_FIELDS:
                continue
            a_val = api_dict[key]
            b_val = awc_dict[key]

            if isinstance(a_val, float) or isinstance(b_val, float):
                assert _float_eq(a_val, b_val), (
                    f"Field {key!r} mismatch: api={a_val!r}, awc={b_val!r}"
                )
            else:
                assert a_val == b_val, (
                    f"Field {key!r} mismatch: api={a_val!r}, awc={b_val!r}"
                )

    def test_field_names_match(
        self, obs_from_api: Observation, obs_from_awc: Observation
    ) -> None:
        """Both Observation objects expose the same set of field names."""
        api_fields = set(obs_from_api.to_dict().keys())
        awc_fields = set(obs_from_awc.to_dict().keys())
        assert api_fields == awc_fields

    def test_field_types_match(
        self, obs_from_api: Observation, obs_from_awc: Observation
    ) -> None:
        """For each field, both objects have the same type (or both None).

        int and float are treated as compatible numeric types since JSON
        parsers may return either depending on whether a decimal is present
        (e.g. ``1029`` vs ``1029.0``).
        """
        api_dict = obs_from_api.to_dict()
        awc_dict = obs_from_awc.to_dict()

        for key in api_dict:
            if key in _EXCLUDE_FIELDS:
                continue
            a_val = api_dict[key]
            b_val = awc_dict[key]
            if a_val is None and b_val is None:
                continue
            a_numeric = isinstance(a_val, (int, float))
            b_numeric = isinstance(b_val, (int, float))
            if a_numeric and b_numeric:
                continue
            assert type(a_val) is type(b_val), (
                f"Field {key!r} type mismatch: "
                f"api={type(a_val).__name__}, awc={type(b_val).__name__}"
            )

    def test_to_dict_field_count(
        self, obs_from_api: Observation, obs_from_awc: Observation
    ) -> None:
        """Both .to_dict() return dicts with exactly 29 keys."""
        assert len(obs_from_api.to_dict()) == _EXPECTED_FIELD_COUNT
        assert len(obs_from_awc.to_dict()) == _EXPECTED_FIELD_COUNT


class TestRealFixtures:
    """Load the real on-disk fixtures and verify structural compatibility."""

    @pytest.fixture()
    def real_awc(self) -> dict:
        path = FIXTURES_DIR / "fixtures_awc.json"
        return json.loads(path.read_text())

    @pytest.fixture()
    def real_api(self) -> dict:
        path = FIXTURES_DIR / "fixtures_api.json"
        return json.loads(path.read_text())

    def test_real_fixtures_structure(self, real_awc: dict, real_api: dict) -> None:
        """Both real fixtures produce valid Observations with matching schema."""
        obs_awc = Observation.from_awc_json(real_awc)
        assert obs_awc is not None, "from_awc_json returned None for real fixture"
        assert isinstance(obs_awc, Observation)

        obs_api = Observation.from_api_dict(real_api)
        assert isinstance(obs_api, Observation)

        # Field names must match
        assert set(obs_awc.to_dict().keys()) == set(obs_api.to_dict().keys())

        # Field types must match (or both None)
        awc_dict = obs_awc.to_dict()
        api_dict = obs_api.to_dict()
        for key in awc_dict:
            a_val = awc_dict[key]
            b_val = api_dict[key]
            if a_val is None and b_val is None:
                continue
            # One may be None if a field is present in one source but not the other;
            # we only assert type equality when both are non-None.
            if a_val is not None and b_val is not None:
                a_numeric = isinstance(a_val, (int, float))
                b_numeric = isinstance(b_val, (int, float))
                if a_numeric and b_numeric:
                    continue
                assert type(a_val) is type(b_val), (
                    f"Field {key!r} type mismatch: "
                    f"awc={type(a_val).__name__}, api={type(b_val).__name__}"
                )
