"""Parametric integration tests against the LIVE production API + AWC.

This is the contract: if this file passes, the quant's code works.

Runs against:
  - api.mostlyright.xyz (WeatherHistory, MarketsClient)
  - aviationweather.gov (WeatherLive)

Stable test windows use NYC 2024-01-15 to 2024-01-16 for deterministic results.
AWC tests use live data (current observations).

Usage:
  pytest tests/test_integration.py -v --tb=short
  pytest tests/test_integration.py -v --tb=short --reruns 2 --reruns-delay 10
"""

from __future__ import annotations

import io
from pathlib import Path

import pandas as pd
import pytest

from therminal.exceptions import TherminalError
from therminal.markets import MarketsClient
from therminal.weather import WeatherHistory, WeatherLive

# ── Stable test parameters ──────────────────────────────────

STATION = "NYC"
FROM = "2024-01-15"
TO = "2024-01-16"
# Climate needs a wider window to guarantee data
CLIMATE_FROM = "2024-01-01"
CLIMATE_TO = "2024-01-31"

# ── Fixtures ────────────────────────────────────────────────

pytestmark = pytest.mark.integration


@pytest.fixture(scope="module")
def weather() -> WeatherHistory:
    w = WeatherHistory()
    yield w
    w.close()


@pytest.fixture(scope="module")
def live() -> WeatherLive:
    lv = WeatherLive()
    yield lv
    lv.close()


@pytest.fixture(scope="module")
def markets() -> MarketsClient:
    m = MarketsClient()
    yield m
    m.close()


# ── DataFrame assertion helpers ─────────────────────────────


def assert_no_bytes(df: pd.DataFrame) -> None:
    """Every object/string column must contain str, not bytes."""
    for col in df.select_dtypes(include=["object", "string"]).columns:
        sample = df[col].dropna()
        if len(sample) == 0:
            continue
        bad = sample[sample.map(lambda v: not isinstance(v, str))]
        assert len(bad) == 0, (
            f"Column '{col}' has {len(bad)} non-str values, first: {type(bad.iloc[0]).__name__}"
        )


def assert_index_type(df: pd.DataFrame, expected_name: str, tz_aware: bool | None) -> None:
    """Validate the DataFrame index name and dtype.

    tz_aware=True  -> datetime64 with UTC
    tz_aware=False -> datetime64 tz-naive (calendar dates)
    tz_aware=None  -> numeric index (LOB epoch)
    """
    assert df.index.name == expected_name, (
        f"Index name is '{df.index.name}', expected '{expected_name}'"
    )
    if tz_aware is True:
        assert hasattr(df.index, "tz") and df.index.tz is not None, (
            f"Index '{expected_name}' should be UTC but has no timezone"
        )
    elif tz_aware is False:
        tz = getattr(df.index, "tz", None)
        assert tz is None, (
            f"Index '{expected_name}' should be tz-naive but has tz={tz}"
        )
    else:
        assert pd.api.types.is_numeric_dtype(df.index), (
            f"Index '{expected_name}' should be numeric but is {df.index.dtype}"
        )


def assert_parquet_roundtrip(df: pd.DataFrame) -> None:
    """df.to_parquet() must not crash."""
    buf = io.BytesIO()
    df.to_parquet(buf)
    assert buf.tell() > 0


def assert_df_basics(
    df: pd.DataFrame,
    index_name: str,
    tz_aware: bool | None,
    min_rows: int = 1,
) -> None:
    """Run all standard DataFrame assertions."""
    assert df.shape[0] >= min_rows, (
        f"Expected >= {min_rows} rows, got {df.shape[0]}"
    )
    assert_no_bytes(df)
    assert_index_type(df, index_name, tz_aware)
    assert_parquet_roundtrip(df)


# ── Shared discovery helpers ────────────────────────────────


def find_lob_market(mc: MarketsClient) -> str | None:
    """Find a market ticker that has LOB data. Returns None if none found."""
    mkt_list = mc.markets(limit=5)
    for m in mkt_list:
        try:
            data = mc.lob(market=m.ticker, interval=3600, levels=1)
            if data:
                return m.ticker
        except TherminalError:
            continue
    return None


def find_candle_market(mc: MarketsClient) -> str | None:
    """Find a market ticker that has candle data."""
    series_list = mc.series(limit=1)
    if not series_list:
        return None
    mkt_list = mc.markets(series=series_list[0].ticker, limit=1)
    if not mkt_list:
        return None
    return mkt_list[0].ticker


# ═══════════════════════════════════════════════════════════
# OBSERVATIONS
# ═══════════════════════════════════════════════════════════


class TestObservations:
    """observations() -- list, DataFrame, csv, parquet."""

    def test_list_small(self, weather: WeatherHistory) -> None:
        data = weather.observations(station=STATION, from_date=FROM, to_date=TO)
        assert len(data) > 0
        obs = data[0]
        assert obs["station_code"] == STATION
        assert obs.station_code == STATION
        d = obs.to_dict()
        assert len(d) == 29, f"Observation has {len(d)} keys, expected 29"

    def test_dataframe(self, weather: WeatherHistory) -> None:
        df = weather.observations(
            station=STATION, from_date=FROM, to_date=TO, as_dataframe=True,
        )
        assert_df_basics(df, "observed_at", tz_aware=True)

    def test_csv(self, weather: WeatherHistory, tmp_path: Path) -> None:
        path = weather.observations(
            station=STATION, from_date=FROM, to_date=TO,
            format="csv", save_path=tmp_path / "obs.csv",
        )
        assert Path(path).stat().st_size > 0

    def test_parquet(self, weather: WeatherHistory, tmp_path: Path) -> None:
        path = weather.observations(
            station=STATION, from_date=FROM, to_date=TO,
            format="parquet", save_path=tmp_path / "obs.parquet",
        )
        assert Path(path).stat().st_size > 0

    def test_parquet_dataframe(self, weather: WeatherHistory, tmp_path: Path) -> None:
        """Parquet loaded as DataFrame: no bytes, correct index."""
        df = weather.observations(
            station=STATION, from_date=FROM, to_date=TO,
            format="parquet", as_dataframe=True,
            save_path=tmp_path / "obs.parquet",
        )
        assert_df_basics(df, "observed_at", tz_aware=True)

    def test_large_paginate(self, weather: WeatherHistory) -> None:
        """Auto-pagination: large result set via JSON."""
        data = weather.observations(
            station=STATION, from_date="2024-01-01", to_date="2024-02-01",
        )
        assert len(data) > 700, f"Expected > 700 rows, got {len(data)}"

    def test_large_paginate_dataframe(self, weather: WeatherHistory) -> None:
        """Auto-parquet for large DataFrame results."""
        df = weather.observations(
            station=STATION, from_date="2024-01-01", to_date="2024-02-01",
            as_dataframe=True,
        )
        assert df.shape[0] > 700
        assert_df_basics(df, "observed_at", tz_aware=True)

    def test_large_paginate_parquet(self, weather: WeatherHistory, tmp_path: Path) -> None:
        """Parquet bulk: same large window."""
        path = weather.observations(
            station=STATION, from_date="2024-01-01", to_date="2024-02-01",
            format="parquet", save_path=tmp_path / "obs_large.parquet",
        )
        assert Path(path).stat().st_size > 0

    def test_limit_respected(self, weather: WeatherHistory) -> None:
        """limit=50 returns exactly 50 rows, not 10K."""
        data = weather.observations(
            station=STATION, from_date="2024-01-01", to_date="2024-12-31",
            limit=50,
        )
        assert len(data) == 50

    def test_units_metric(self, weather: WeatherHistory) -> None:
        """units=metric returns converted values."""
        data = weather.observations(
            station=STATION, from_date=FROM, to_date=TO, units="metric",
        )
        assert len(data) > 0
        obs = data[0]
        if obs.temp_f is not None:
            assert -50 < obs.temp_f < 60, (
                f"Metric temp {obs.temp_f} looks like Fahrenheit"
            )

    def test_resolution_1min(self, weather: WeatherHistory) -> None:
        """resolution=1min returns 1-minute ASOS data."""
        # ATL, not NYC (NYC has no 1-min ASOS)
        data = weather.observations(
            station="ATL", from_date=FROM, to_date=TO, resolution="1min",
        )
        assert len(data) > 0

    def test_resolution_1min_dataframe(self, weather: WeatherHistory) -> None:
        """1-min ASOS as DataFrame."""
        df = weather.observations(
            station="ATL", from_date=FROM, to_date=TO,
            resolution="1min", as_dataframe=True,
        )
        assert_df_basics(df, "observed_at", tz_aware=True)


# ═══════════════════════════════════════════════════════════
# CLIMATE
# ═══════════════════════════════════════════════════════════


class TestClimate:
    """climate() -- list, DataFrame, csv, parquet."""

    def test_list_small(self, weather: WeatherHistory) -> None:
        data = weather.climate(
            station=STATION, from_date=CLIMATE_FROM, to_date=CLIMATE_TO,
        )
        assert len(data) > 0
        c = data[0]
        d = c.to_dict()
        assert len(d) == 8, f"Climate has {len(d)} keys, expected 8"
        assert c["station_code"] == STATION

    def test_dataframe(self, weather: WeatherHistory) -> None:
        """Climate dates must be tz-naive calendar dates, not UTC timestamps."""
        df = weather.climate(
            station=STATION, from_date=CLIMATE_FROM, to_date=CLIMATE_TO,
            as_dataframe=True,
        )
        assert_df_basics(df, "observation_date", tz_aware=False)

    def test_csv(self, weather: WeatherHistory, tmp_path: Path) -> None:
        path = weather.climate(
            station=STATION, from_date=CLIMATE_FROM, to_date=CLIMATE_TO,
            format="csv", save_path=tmp_path / "climate.csv",
        )
        assert Path(path).stat().st_size > 0

    def test_parquet(self, weather: WeatherHistory, tmp_path: Path) -> None:
        path = weather.climate(
            station=STATION, from_date=CLIMATE_FROM, to_date=CLIMATE_TO,
            format="parquet", save_path=tmp_path / "climate.parquet",
        )
        assert Path(path).stat().st_size > 0

    def test_large_dataframe(self, weather: WeatherHistory) -> None:
        """Full year of climate data as DataFrame."""
        df = weather.climate(
            station=STATION, from_date="2024-01-01", to_date="2024-12-31",
            as_dataframe=True,
        )
        assert df.shape[0] >= 300
        assert_df_basics(df, "observation_date", tz_aware=False)

    def test_large_parquet(self, weather: WeatherHistory, tmp_path: Path) -> None:
        path = weather.climate(
            station=STATION, from_date="2024-01-01", to_date="2024-12-31",
            format="parquet", save_path=tmp_path / "climate_lg.parquet",
        )
        assert Path(path).stat().st_size > 0

    def test_df_filter_by_source(self, weather: WeatherHistory) -> None:
        """DataFrame filtering: df[df['source'] == 'iem'] works (not b'iem')."""
        df = weather.climate(
            station=STATION, from_date=CLIMATE_FROM, to_date=CLIMATE_TO,
            as_dataframe=True,
        )
        df_flat = df.reset_index()
        if "source" in df_flat.columns:
            sources = df_flat["source"].dropna()
            if len(sources) > 0:
                assert all(isinstance(v, str) for v in sources), (
                    "source column contains non-str values"
                )
            iem = df_flat[df_flat["source"] == "iem"]
            assert isinstance(iem, pd.DataFrame)


# ═══════════════════════════════════════════════════════════
# CANDLES
# ═══════════════════════════════════════════════════════════


class TestCandles:
    """candles() -- list, DataFrame."""

    def test_list_small(self, markets: MarketsClient) -> None:
        ticker = find_candle_market(markets)
        if not ticker:
            pytest.skip("No markets with candle data available")
        data = markets.candles(market=ticker, limit=100)
        if not data:
            pytest.skip("No candles for this market")
        c = data[0]
        d = c.to_dict()
        assert len(d) == 10, f"Candle has {len(d)} keys, expected 10"

    def test_dataframe(self, markets: MarketsClient) -> None:
        ticker = find_candle_market(markets)
        if not ticker:
            pytest.skip("No markets with candle data available")
        data = markets.candles(market=ticker, limit=100, as_dataframe=True)
        if isinstance(data, pd.DataFrame) and data.shape[0] > 0:
            assert_df_basics(data, "end_period_ts", tz_aware=True)


# ═══════════════════════════════════════════════════════════
# SERIES & MARKETS
# ═══════════════════════════════════════════════════════════


class TestSeries:
    """series() and series_detail()."""

    def test_list(self, markets: MarketsClient) -> None:
        data = markets.series(limit=10)
        assert len(data) > 0
        s = data[0]
        d = s.to_dict()
        assert len(d) == 9, f"Series has {len(d)} keys, expected 9"

    def test_dataframe(self, markets: MarketsClient) -> None:
        df = markets.series(limit=10, as_dataframe=True)
        assert isinstance(df, pd.DataFrame)
        assert df.shape[0] > 0

    def test_detail(self, markets: MarketsClient) -> None:
        series_list = markets.series(limit=1)
        if not series_list:
            pytest.skip("No series")
        detail = markets.series_detail(series_list[0].ticker)
        assert detail.ticker == series_list[0].ticker


class TestMarkets:
    """markets() and market()."""

    def test_list(self, markets: MarketsClient) -> None:
        data = markets.markets(limit=10)
        assert len(data) > 0
        m = data[0]
        d = m.to_dict()
        assert len(d) == 9, f"Market has {len(d)} keys, expected 9"

    def test_detail(self, markets: MarketsClient) -> None:
        mkt_list = markets.markets(limit=1)
        if not mkt_list:
            pytest.skip("No markets")
        detail = markets.market(mkt_list[0].ticker)
        assert detail.ticker == mkt_list[0].ticker


# ═══════════════════════════════════════════════════════════
# LOB (Limit Order Book)
# ═══════════════════════════════════════════════════════════


class TestLOB:
    """lob() -- list, DataFrame."""

    def test_list(self, markets: MarketsClient) -> None:
        ticker = find_lob_market(markets)
        if not ticker:
            pytest.skip("No LOB data available")
        data = markets.lob(market=ticker, interval=3600, levels=5)
        assert len(data) > 0
        snap = data[0]
        d = snap.to_dict()
        assert len(d) == 8, f"LOBSnapshot has {len(d)} keys, expected 8"
        assert isinstance(snap.ts, int)

    def test_dataframe(self, markets: MarketsClient) -> None:
        ticker = find_lob_market(markets)
        if not ticker:
            pytest.skip("No LOB data available")
        df = markets.lob(
            market=ticker, interval=3600, levels=5, as_dataframe=True,
        )
        if isinstance(df, pd.DataFrame) and df.shape[0] > 0:
            assert_df_basics(df, "ts", tz_aware=None)


# ═══════════════════════════════════════════════════════════
# CLIMATE GAPS
# ═══════════════════════════════════════════════════════════


class TestClimateGaps:
    """climate_gaps() -- list."""

    def test_list(self, weather: WeatherHistory) -> None:
        data = weather.climate_gaps(station=STATION, from_date="2024-01-01", to_date="2024-12-31")
        assert isinstance(data, list)
        if data:
            assert isinstance(data[0], dict)


# ═══════════════════════════════════════════════════════════
# HEALTH
# ═══════════════════════════════════════════════════════════


class TestHealth:
    """API health check."""

    def test_health(self) -> None:
        # Uses TherminalClient facade which exposes health() publicly
        from therminal import TherminalClient
        with TherminalClient() as client:
            result = client.health()
            assert isinstance(result, dict)


# ═══════════════════════════════════════════════════════════
# LIVE (WeatherLive -- AWC)
# ═══════════════════════════════════════════════════════════


@pytest.mark.awc
class TestLiveCurrent:
    """WeatherLive.current() -- single, batch, DataFrame, metric."""

    def test_single(self, live: WeatherLive) -> None:
        data = live.current("NYC")
        assert len(data) >= 1
        obs = data[0]
        assert obs.station_code == "NYC"
        d = obs.to_dict()
        assert len(d) == 29, f"Live Observation has {len(d)} keys, expected 29"

    def test_single_dataframe(self, live: WeatherLive) -> None:
        df = live.current("NYC", as_dataframe=True)
        assert isinstance(df, pd.DataFrame)
        assert df.shape[0] >= 1
        assert_no_bytes(df)

    def test_batch(self, live: WeatherLive) -> None:
        data = live.current(["NYC", "ATL", "MDW"])
        assert len(data) >= 2
        stations = {obs.station_code for obs in data}
        assert len(stations) >= 2

    def test_batch_dataframe(self, live: WeatherLive) -> None:
        df = live.current(["NYC", "ATL"], as_dataframe=True)
        assert isinstance(df, pd.DataFrame)
        assert df.shape[0] >= 2
        assert_no_bytes(df)

    def test_metric(self, live: WeatherLive) -> None:
        data = live.current("NYC", units="metric")
        assert len(data) >= 1
        obs = data[0]
        if obs.temp_f is not None:
            assert -50 < obs.temp_f < 60, (
                f"Metric temp {obs.temp_f} looks like Fahrenheit"
            )

    def test_relative_humidity(self, live: WeatherLive) -> None:
        """Live observations must include relative_humidity (Magnus formula)."""
        data = live.current("NYC")
        obs = data[0]
        if obs.temp_f is not None and obs.dewpoint_f is not None:
            assert obs.relative_humidity is not None, (
                "relative_humidity is None despite temp+dewpoint being present"
            )
            assert 0 <= obs.relative_humidity <= 100


@pytest.mark.awc
class TestLiveLatest:
    """WeatherLive.latest() -- hours parameter."""

    def test_latest_hours(self, live: WeatherLive) -> None:
        data = live.latest("NYC", hours=3)
        assert len(data) >= 1

    def test_latest_dataframe(self, live: WeatherLive) -> None:
        df = live.latest("NYC", hours=3, as_dataframe=True)
        assert isinstance(df, pd.DataFrame)
        assert df.shape[0] >= 1
        assert_no_bytes(df)


# ═══════════════════════════════════════════════════════════
# SCHEMA PARITY -- live vs historical Observation
# ═══════════════════════════════════════════════════════════


@pytest.mark.awc
class TestSchemaParity:
    """Live and historical Observations must have identical field sets."""

    def test_field_set_match(self, weather: WeatherHistory, live: WeatherLive) -> None:
        hist = weather.observations(station=STATION, from_date=FROM, to_date=TO, limit=1)
        curr = live.current("NYC")
        assert len(hist) > 0 and len(curr) > 0
        hist_keys = set(hist[0].to_dict().keys())
        curr_keys = set(curr[0].to_dict().keys())
        assert hist_keys == curr_keys, (
            f"Field mismatch: hist-only={hist_keys - curr_keys}, "
            f"live-only={curr_keys - hist_keys}"
        )


class TestDictAccess:
    """Dict-style access on typed models (not AWC-dependent, runs in CI)."""

    def test_observation_dict_access(self, weather: WeatherHistory) -> None:
        """obs['field'] dict access works on every Observation field."""
        data = weather.observations(station=STATION, from_date=FROM, to_date=TO, limit=1)
        obs = data[0]
        for key in obs.to_dict():
            _ = obs[key]


# ═══════════════════════════════════════════════════════════
# ERROR HANDLING
# ═══════════════════════════════════════════════════════════


class TestErrors:
    """Error cases -- empty results."""

    def test_bogus_station_empty(self, weather: WeatherHistory) -> None:
        """Unknown station returns empty list, not an error."""
        data = weather.observations(station="ZZZZZZ", from_date=FROM, to_date=TO)
        assert data == []

    def test_empty_result_list(self, weather: WeatherHistory) -> None:
        """Query with no data returns empty list, not an error."""
        data = weather.observations(
            station=STATION, from_date="1900-01-01", to_date="1900-01-02",
        )
        assert data == []

    def test_empty_result_json(self, weather: WeatherHistory) -> None:
        """Empty JSON query returns empty list (explicit limit avoids auto-parquet)."""
        data = weather.observations(
            station=STATION, from_date="1900-01-01", to_date="1900-01-02",
            limit=1,
        )
        assert data == []


# ═══════════════════════════════════════════════════════════
# ML -- WeatherFeatures + LOBFeatures
# ═══════════════════════════════════════════════════════════


@pytest.mark.slow
class TestMLWeatherFeatures:
    """WeatherFeatures fit/transform against live data."""

    def test_fit_transform(self) -> None:
        import numpy as np

        from therminal.ml import WeatherFeatures

        wf = WeatherFeatures(station="ATL", lookback_hours=6, cache=False)
        dates = pd.date_range("2024-01-15", "2024-01-16", freq="h")
        X = wf.fit_transform(dates)
        assert isinstance(X, np.ndarray)
        assert X.shape[0] == len(dates)
        assert X.shape[1] > 0
        names = wf.get_feature_names_out()
        assert len(names) == X.shape[1]


@pytest.mark.slow
class TestMLLOBFeatures:
    """LOBFeatures fit/transform against live data."""

    def test_fit_transform(self, markets: MarketsClient) -> None:
        import numpy as np

        from therminal.ml import LOBFeatures

        ticker = find_lob_market(markets)
        if not ticker:
            pytest.skip("No LOB data available for ML test")

        lf = LOBFeatures(market=ticker, lookback_minutes=60, cache=False)
        dates = pd.date_range("2024-03-01", "2024-03-02", freq="h")
        X = lf.fit_transform(dates)
        assert isinstance(X, np.ndarray)
        assert X.shape[0] == len(dates)
        assert X.shape[1] > 0
