"""Tests for therminal dataclass models."""

from __future__ import annotations

import pytest

from therminal.models.candle import Candle
from therminal.models.climate import Climate
from therminal.models.lob import Level, LOBSnapshot
from therminal.models.market import Market, Series
from therminal.models.observation import Observation

# ---------------------------------------------------------------------------
# Fixtures / helpers
# ---------------------------------------------------------------------------

FULL_OBS_DICT: dict = {
    "station_code": "NYC",
    "observed_at": "2026-03-29T12:00:00Z",
    "observation_type": "METAR",
    "source": "awc",
    "temp_f": 51.1,
    "dewpoint_f": 30.0,
    "relative_humidity": 42.5,
    "wind_dir_degrees": 180,
    "wind_speed_kt": 10,
    "wind_gust_kt": 20,
    "altimeter_inhg": 30.12,
    "sea_level_pressure_mb": 1018.5,
    "sky_cover_1": "FEW",
    "sky_base_1_ft": 5000,
    "sky_cover_2": "SCT",
    "sky_base_2_ft": 10000,
    "sky_cover_3": "BKN",
    "sky_base_3_ft": 20000,
    "sky_cover_4": "OVC",
    "sky_base_4_ft": 25000,
    "visibility_miles": 10.0,
    "weather_codes": "RA",
    "precip_1hr_inches": 0.02,
    "peak_wind_gust_kt": 35,
    "peak_wind_dir": 200,
    "peak_wind_time": "2026-03-29T11:45:00Z",
    "feels_like_f": 48.0,
    "snow_depth_inches": 0.0,
    "raw_metar": "METAR KNYC 291200Z 18010G20KT...",
}

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...",
    "clouds": [{"cover": "FEW", "base": 12000}],
}


# ===========================================================================
# Observation.from_api_dict
# ===========================================================================


class TestObservationFromApiDict:
    """Tests 1-4: Observation.from_api_dict."""

    def test_happy_path_full_dict(self) -> None:
        """1. Full dict with all fields produces correct Observation."""
        obs = Observation.from_api_dict(FULL_OBS_DICT)

        assert obs.station_code == "NYC"
        assert obs.observed_at == "2026-03-29T12:00:00Z"
        assert obs.observation_type == "METAR"
        assert obs.source == "awc"
        assert obs.temp_f == 51.1
        assert obs.dewpoint_f == 30.0
        assert obs.relative_humidity == 42.5
        assert obs.wind_dir_degrees == 180
        assert obs.wind_speed_kt == 10
        assert obs.wind_gust_kt == 20
        assert obs.altimeter_inhg == 30.12
        assert obs.sea_level_pressure_mb == 1018.5
        assert obs.sky_cover_1 == "FEW"
        assert obs.sky_base_1_ft == 5000
        assert obs.sky_cover_2 == "SCT"
        assert obs.sky_base_2_ft == 10000
        assert obs.sky_cover_3 == "BKN"
        assert obs.sky_base_3_ft == 20000
        assert obs.sky_cover_4 == "OVC"
        assert obs.sky_base_4_ft == 25000
        assert obs.visibility_miles == 10.0
        assert obs.weather_codes == "RA"
        assert obs.precip_1hr_inches == 0.02
        assert obs.peak_wind_gust_kt == 35
        assert obs.peak_wind_dir == 200
        assert obs.peak_wind_time == "2026-03-29T11:45:00Z"
        assert obs.feels_like_f == 48.0
        assert obs.snow_depth_inches == 0.0
        assert obs.raw_metar == "METAR KNYC 291200Z 18010G20KT..."

    def test_missing_optional_fields_go_omitempty(self) -> None:
        """2. Missing optional fields (Go omitempty) map to None."""
        sparse_dict = {
            "station_code": "ATL",
            "observed_at": "2026-03-29T08:00:00Z",
            "observation_type": "METAR",
            "source": "isd",
        }
        obs = Observation.from_api_dict(sparse_dict)

        assert obs.station_code == "ATL"
        assert obs.temp_f is None
        assert obs.dewpoint_f is None
        assert obs.wind_dir_degrees is None
        assert obs.wind_speed_kt is None
        assert obs.wind_gust_kt is None
        assert obs.altimeter_inhg is None
        assert obs.sky_cover_1 is None
        assert obs.sky_base_1_ft is None
        assert obs.raw_metar is None

    def test_partial_dict_only_station_and_time(self) -> None:
        """3. Minimal dict: only station_code and observed_at."""
        obs = Observation.from_api_dict({
            "station_code": "ORD",
            "observed_at": "2026-01-01T00:00:00Z",
        })

        assert obs.station_code == "ORD"
        assert obs.observed_at == "2026-01-01T00:00:00Z"
        assert obs.observation_type == "METAR"  # default
        assert obs.source == ""  # default
        assert obs.temp_f is None
        assert obs.visibility_miles is None

    def test_type_coercion_string_numbers(self) -> None:
        """4. String numbers coerced to int/float."""
        obs = Observation.from_api_dict({
            "station_code": "NYC",
            "observed_at": "2026-03-29T12:00:00Z",
            "temp_f": "72.5",
            "wind_dir_degrees": "180",
            "wind_speed_kt": "12",
            "altimeter_inhg": "30.12",
        })

        assert obs.temp_f == 72.5
        assert isinstance(obs.temp_f, float)
        assert obs.wind_dir_degrees == 180
        assert isinstance(obs.wind_dir_degrees, int)
        assert obs.wind_speed_kt == 12
        assert isinstance(obs.wind_speed_kt, int)
        assert obs.altimeter_inhg == 30.12
        assert isinstance(obs.altimeter_inhg, float)


# ===========================================================================
# Observation.from_awc_json
# ===========================================================================


class TestObservationFromAwcJson:
    """Tests 5-13: Observation.from_awc_json (delegates to awc_to_observation)."""

    def test_happy_path_real_fixture(self) -> None:
        """5. Real AWC fixture produces correct Observation."""
        obs = Observation.from_awc_json(AWC_FIXTURE)
        assert obs is not None

        assert obs.station_code == "NYC"
        assert obs.observed_at == "2026-03-29T21:51:00Z"
        assert obs.observation_type == "METAR"
        assert obs.source == "awc"
        assert obs.temp_f == 51.1  # 10.6C -> 51.08 -> round = 51.1
        assert obs.wind_dir_degrees == 140
        assert obs.wind_speed_kt == 6
        assert obs.wind_gust_kt == 15
        assert obs.visibility_miles == 10.0
        assert obs.sky_cover_1 == "FEW"
        assert obs.sky_base_1_ft == 12000
        assert obs.raw_metar == "METAR KNYC 292151Z..."

    def test_missing_icao_id_returns_none(self) -> None:
        """6. Missing icaoId returns None."""
        data = {**AWC_FIXTURE}
        del data["icaoId"]
        assert Observation.from_awc_json(data) is None

    def test_non_numeric_obs_time_returns_none(self) -> None:
        """7. Non-numeric obsTime returns None."""
        data = {**AWC_FIXTURE, "obsTime": "not-a-number"}
        assert Observation.from_awc_json(data) is None

    def test_wind_vrb_maps_to_none(self) -> None:
        """8. Wind direction 'VRB' maps to None."""
        data = {**AWC_FIXTURE, "wdir": "VRB"}
        obs = Observation.from_awc_json(data)
        assert obs is not None
        assert obs.wind_dir_degrees is None

    def test_celsius_to_f_go_matched_rounding(self) -> None:
        """9. 10.6C -> 51.1F (Go-matched floor(x*10+0.5)/10)."""
        obs = Observation.from_awc_json(AWC_FIXTURE)
        assert obs is not None
        # 10.6 * 9/5 + 32 = 51.08 -> floor(510.8 + 0.5)/10 = 511/10 = 51.1
        assert obs.temp_f == 51.1

    def test_altimeter_hpa_to_inhg(self) -> None:
        """10. Altimeter 1029.9 hPa -> 30.41 inHg."""
        obs = Observation.from_awc_json(AWC_FIXTURE)
        assert obs is not None
        # 1029.9 * 0.02953 = 30.41247 -> floor(3041.747)/100 = 30.41
        assert obs.altimeter_inhg == 30.41

    def test_cloud_cover_cavok_maps_to_clr(self) -> None:
        """11. CAVOK cloud cover maps to CLR."""
        data = {
            **AWC_FIXTURE,
            "clouds": [{"cover": "CAVOK", "base": 0}],
        }
        obs = Observation.from_awc_json(data)
        assert obs is not None
        assert obs.sky_cover_1 == "CLR"

    def test_four_cloud_layers(self) -> None:
        """12. All 4 cloud layers populated."""
        data = {
            **AWC_FIXTURE,
            "clouds": [
                {"cover": "FEW", "base": 3000},
                {"cover": "SCT", "base": 8000},
                {"cover": "BKN", "base": 15000},
                {"cover": "OVC", "base": 25000},
            ],
        }
        obs = Observation.from_awc_json(data)
        assert obs is not None
        assert obs.sky_cover_1 == "FEW"
        assert obs.sky_base_1_ft == 3000
        assert obs.sky_cover_2 == "SCT"
        assert obs.sky_base_2_ft == 8000
        assert obs.sky_cover_3 == "BKN"
        assert obs.sky_base_3_ft == 15000
        assert obs.sky_cover_4 == "OVC"
        assert obs.sky_base_4_ft == 25000

    def test_raw_ob_truncation_at_2048(self) -> None:
        """13. rawOb longer than 2048 chars is truncated."""
        long_metar = "A" * 3000
        data = {**AWC_FIXTURE, "rawOb": long_metar}
        obs = Observation.from_awc_json(data)
        assert obs is not None
        assert obs.raw_metar is not None
        assert len(obs.raw_metar) == 2048


# ===========================================================================
# Observation dict-like access
# ===========================================================================


class TestObservationDictAccess:
    """Tests 14-17: dict-like protocol on Observation."""

    @pytest.fixture()
    def obs(self) -> Observation:
        return Observation.from_api_dict(FULL_OBS_DICT)

    def test_getitem(self, obs: Observation) -> None:
        """14. obs['temp_f'] works."""
        assert obs["temp_f"] == 51.1

    def test_contains(self, obs: Observation) -> None:
        """15. 'temp_f' in obs works."""
        assert "temp_f" in obs

    def test_get(self, obs: Observation) -> None:
        """16. obs.get('temp_f') works."""
        assert obs.get("temp_f") == 51.1

    def test_get_missing_with_default(self, obs: Observation) -> None:
        """17. obs.get('nonexistent', 42) returns 42."""
        assert obs.get("nonexistent", 42) == 42


# ===========================================================================
# Observation.to_dict
# ===========================================================================


class TestObservationToDict:
    """Tests 18-19: Observation.to_dict."""

    def test_to_dict_all_29_keys(self) -> None:
        """18. to_dict returns dict with all 29 keys."""
        obs = Observation.from_api_dict(FULL_OBS_DICT)
        d = obs.to_dict()

        assert isinstance(d, dict)
        expected_keys = {
            "station_code",
            "observed_at",
            "observation_type",
            "source",
            "temp_f",
            "dewpoint_f",
            "relative_humidity",
            "wind_dir_degrees",
            "wind_speed_kt",
            "wind_gust_kt",
            "altimeter_inhg",
            "sea_level_pressure_mb",
            "sky_cover_1",
            "sky_base_1_ft",
            "sky_cover_2",
            "sky_base_2_ft",
            "sky_cover_3",
            "sky_base_3_ft",
            "sky_cover_4",
            "sky_base_4_ft",
            "visibility_miles",
            "weather_codes",
            "precip_1hr_inches",
            "peak_wind_gust_kt",
            "peak_wind_dir",
            "peak_wind_time",
            "feels_like_f",
            "snow_depth_inches",
            "raw_metar",
        }
        assert set(d.keys()) == expected_keys
        assert len(d) == 29

    def test_round_trip(self) -> None:
        """19. Round-trip: from_api_dict(obs.to_dict()) matches original."""
        original = Observation.from_api_dict(FULL_OBS_DICT)
        rebuilt = Observation.from_api_dict(original.to_dict())
        assert rebuilt == original


# ===========================================================================
# Other models: from_api_dict + to_dict
# ===========================================================================


class TestCandle:
    """Test 20: Candle.from_api_dict."""

    def test_from_api_dict_and_to_dict(self) -> None:
        d = {
            "market_ticker": "KXHIGHNY-26MAR29-T52",
            "period_interval": 60,
            "end_period_ts": "2026-03-29T13:00:00Z",
            "open": 45,
            "high": 50,
            "low": 40,
            "close": 48,
            "volume": 100,
            "open_interest": 500,
            "filled": True,
        }
        candle = Candle.from_api_dict(d)

        assert candle.market_ticker == "KXHIGHNY-26MAR29-T52"
        assert candle.period_interval == 60
        assert candle.open == 45
        assert candle.high == 50
        assert candle.low == 40
        assert candle.close == 48
        assert candle.volume == 100
        assert candle.open_interest == 500
        assert candle.filled is True

        # Round-trip
        assert Candle.from_api_dict(candle.to_dict()) == candle


class TestMarket:
    """Test 21: Market.from_api_dict."""

    def test_from_api_dict_and_to_dict(self) -> None:
        d = {
            "ticker": "KXHIGHNY-26MAR29-T52",
            "event_ticker": "KXHIGHNY-26MAR29",
            "series_ticker": "KXHIGHNY",
            "status": "active",
            "strike_type": "above",
            "floor_strike": 50.0,
            "cap_strike": 55.0,
            "result": "",
            "close_time": "2026-03-30T00:00:00Z",
        }
        market = Market.from_api_dict(d)

        assert market.ticker == "KXHIGHNY-26MAR29-T52"
        assert market.event_ticker == "KXHIGHNY-26MAR29"
        assert market.status == "active"
        assert market.strike_type == "above"
        assert market.floor_strike == 50.0
        assert market.cap_strike == 55.0
        assert market.result == ""
        assert market.close_time == "2026-03-30T00:00:00Z"

        # Round-trip
        assert Market.from_api_dict(market.to_dict()) == market


class TestSeries:
    """Test 22: Series.from_api_dict with tags as list."""

    def test_from_api_dict_and_to_dict(self) -> None:
        d = {
            "ticker": "KXHIGHNY",
            "measure_type": "high_temp",
            "title": "NYC Daily High Temp",
            "station_code": "NYC",
            "category": "temperature",
            "frequency": "daily",
            "tags": ["temperature", "nyc", "kalshi"],
            "active": True,
            "last_seen_at": "2026-03-29T00:00:00Z",
        }
        series = Series.from_api_dict(d)

        assert series.ticker == "KXHIGHNY"
        assert series.measure_type == "high_temp"
        assert series.title == "NYC Daily High Temp"
        assert series.station_code == "NYC"
        assert series.tags == ("temperature", "nyc", "kalshi")
        assert isinstance(series.tags, tuple)
        assert series.active is True

        # dataclasses.asdict preserves tuple type
        rd = series.to_dict()
        assert rd["tags"] == ("temperature", "nyc", "kalshi")


class TestClimate:
    """Test 23: Climate.from_api_dict."""

    def test_from_api_dict_and_to_dict(self) -> None:
        d = {
            "station_code": "NYC",
            "observation_date": "2026-03-28",
            "high_temp_f": 55.0,
            "low_temp_f": 38.0,
            "report_type": "cf6",
            "source": "ncei",
            "product_id": "CF6NYC",
            "issued_at": "2026-03-29T06:00:00Z",
        }
        climate = Climate.from_api_dict(d)

        assert climate.station_code == "NYC"
        assert climate.observation_date == "2026-03-28"
        assert climate.high_temp_f == 55.0
        assert climate.low_temp_f == 38.0
        assert climate.report_type == "cf6"
        assert climate.source == "ncei"
        assert climate.product_id == "CF6NYC"
        assert climate.issued_at == "2026-03-29T06:00:00Z"

        # Round-trip
        assert Climate.from_api_dict(climate.to_dict()) == climate


class TestLOBSnapshot:
    """Test 24: LOBSnapshot.from_api_dict with nested bids/asks."""

    def test_from_api_dict_and_to_dict(self) -> None:
        d = {
            "ts": 1711720800,
            "market_id": "KXHIGHNY-26MAR29-T52",
            "best_bid": 45,
            "best_ask": 48,
            "spread": 3,
            "mid": 46.5,
            "bids": [
                {"price": 45, "qty": 100},
                {"price": 44, "qty": 200},
            ],
            "asks": [
                {"price": 48, "qty": 50},
                {"price": 49, "qty": 75},
            ],
        }
        snap = LOBSnapshot.from_api_dict(d)

        assert snap.ts == 1711720800
        assert snap.market_id == "KXHIGHNY-26MAR29-T52"
        assert snap.best_bid == 45
        assert snap.best_ask == 48
        assert snap.spread == 3
        assert snap.mid == 46.5
        assert len(snap.bids) == 2
        assert len(snap.asks) == 2
        assert isinstance(snap.bids, tuple)
        assert isinstance(snap.asks, tuple)
        assert isinstance(snap.bids[0], Level)
        assert snap.bids[0].price == 45
        assert snap.bids[0].qty == 100
        assert snap.asks[1].price == 49
        assert snap.asks[1].qty == 75

        # Round-trip via to_dict
        rd = snap.to_dict()
        assert isinstance(rd["bids"], list)
        assert rd["bids"][0] == {"price": 45, "qty": 100}
