"""Data contract validation tests (Issue #166).

Verifies that BarOutputSchema catches the three bugs from the
v12.50.0-v12.50.2 hotfix cascade:
  1. Missing close_time_ms column (Arrow uses close_time)
  2. μs-scale timestamps (1000x too large)
  3. Second-scale timestamps (1000x too small)

Also tests Hypothesis property-based Arrow/dict path equivalence.
"""

import polars as pl
import pyarrow as pa
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from opendeviationbar.processors.core import OpenDeviationBarProcessor
from opendeviationbar.validation.schemas import BarOutputSchema
from pandera.errors import SchemaError

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

def _valid_bar_df(**overrides: object):
    """Create a minimal valid bar DataFrame. Override any column."""
    defaults = {
        "open_time_ms": [1704067200000],
        "close_time_ms": [1704067201000],
        "open": [42000.0],
        "high": [42100.0],
        "low": [41900.0],
        "close": [42050.0],
        "volume": [1.5],
        "first_agg_trade_id": [100],
        "last_agg_trade_id": [200],
    }
    defaults.update(overrides)
    return pl.DataFrame(defaults)


# ---------------------------------------------------------------------------
# Tier 1: Schema validation — column presence and types
# ---------------------------------------------------------------------------


class TestBarOutputSchemaPresence:
    """Test that required columns must be present."""

    def test_valid_bar_passes(self):
        df = _valid_bar_df()
        result = BarOutputSchema.validate(df)
        assert result is not None

    def test_missing_close_time_ms_rejected(self):
        """Regression: v12.50.0 Arrow produced close_time, not close_time_ms."""
        df = _valid_bar_df().drop("close_time_ms")
        with pytest.raises(SchemaError):
            BarOutputSchema.validate(df)

    def test_missing_open_time_ms_rejected(self):
        df = _valid_bar_df().drop("open_time_ms")
        with pytest.raises(SchemaError):
            BarOutputSchema.validate(df)

    def test_wrong_column_name_close_time_rejected(self):
        """Regression: Arrow uses close_time (no _ms suffix)."""
        df = _valid_bar_df().rename({"close_time_ms": "close_time"})
        with pytest.raises(SchemaError):
            BarOutputSchema.validate(df)

    def test_extra_columns_allowed(self):
        """Extra columns (microstructure, turnover) should not cause failure."""
        df = _valid_bar_df(
            turnover=[1000.0],
            ofi=[0.5],
            vwap_close_deviation=[0.001],
        )
        result = BarOutputSchema.validate(df)
        assert result is not None


# ---------------------------------------------------------------------------
# Tier 2: Schema validation — timestamp bounds
# ---------------------------------------------------------------------------


class TestBarOutputSchemaTimestampBounds:
    """Test that timestamp values must be in millisecond range."""

    def test_microsecond_close_time_rejected(self):
        """Regression: v12.50.1 Arrow returned μs timestamps (1000x too large)."""
        df = _valid_bar_df(close_time_ms=[1704067201000000])  # μs
        with pytest.raises(SchemaError):
            BarOutputSchema.validate(df)

    def test_microsecond_open_time_rejected(self):
        df = _valid_bar_df(open_time_ms=[1704067200000000])  # μs
        with pytest.raises(SchemaError):
            BarOutputSchema.validate(df)

    def test_second_scale_rejected(self):
        """Timestamps in seconds (too small) should be rejected."""
        df = _valid_bar_df(close_time_ms=[1704067201])  # seconds
        with pytest.raises(SchemaError):
            BarOutputSchema.validate(df)

    def test_valid_ms_range_passes(self):
        """Valid millisecond timestamps should pass."""
        df = _valid_bar_df(
            open_time_ms=[1704067200000],   # 2024-01-01
            close_time_ms=[1735689600000],  # 2025-01-01
        )
        result = BarOutputSchema.validate(df)
        assert result is not None


# ---------------------------------------------------------------------------
# Tier 3: Schema validation — value bounds
# ---------------------------------------------------------------------------


class TestBarOutputSchemaValueBounds:
    """Test OHLCV and trade ID value constraints."""

    def test_negative_price_rejected(self):
        df = _valid_bar_df(open=[-1.0])
        with pytest.raises(SchemaError):
            BarOutputSchema.validate(df)

    def test_zero_price_rejected(self):
        df = _valid_bar_df(close=[0.0])
        with pytest.raises(SchemaError):
            BarOutputSchema.validate(df)

    def test_negative_volume_rejected(self):
        df = _valid_bar_df(volume=[-1.0])
        with pytest.raises(SchemaError):
            BarOutputSchema.validate(df)

    def test_zero_volume_allowed(self):
        """Zero volume is valid (no trades in a forced bar)."""
        df = _valid_bar_df(volume=[0.0])
        result = BarOutputSchema.validate(df)
        assert result is not None

    def test_negative_trade_id_rejected(self):
        df = _valid_bar_df(first_agg_trade_id=[-1])
        with pytest.raises(SchemaError):
            BarOutputSchema.validate(df)


# ---------------------------------------------------------------------------
# Tier 4: Hypothesis — Arrow/dict path equivalence
# ---------------------------------------------------------------------------

# Strategy: generate sorted trade lists with realistic prices that will
# produce at least one bar (price must deviate >= threshold from open).
_BASE_TS = 1_704_067_200_000  # 2024-01-01 00:00:00 UTC in ms


def _trades_to_arrow(trades: list[dict[str, int | float]]) -> pa.Table:
    """Convert trade dicts to Arrow table (DRY helper for Arrow path tests)."""
    return pa.table({
        "timestamp": pa.array([t["timestamp"] for t in trades], type=pa.int64()),
        "price": pa.array([t["price"] for t in trades], type=pa.float64()),
        "quantity": pa.array([t["quantity"] for t in trades], type=pa.float64()),
        "agg_trade_id": pa.array([t["agg_trade_id"] for t in trades], type=pa.int64()),
    })


@st.composite
def _trade_list(draw: st.DrawFn) -> list[dict[str, int | float]]:
    """Generate a sorted list of trades that triggers at least one bar close."""
    n = draw(st.integers(min_value=3, max_value=50))
    base_price = draw(st.floats(min_value=100.0, max_value=50000.0, allow_nan=False, allow_infinity=False))

    trades = []
    for i in range(n):
        # Prices within ±1% of base — enough to breach 250 dbps (0.25%) threshold
        price = base_price * draw(st.floats(min_value=0.99, max_value=1.01, allow_nan=False, allow_infinity=False))
        trades.append({
            "timestamp": _BASE_TS + i * 1000,  # 1 second apart
            "price": round(price, 8),
            "quantity": round(draw(st.floats(min_value=0.001, max_value=100.0, allow_nan=False, allow_infinity=False)), 8),
            "agg_trade_id": i + 1,
        })
    return trades


class TestArrowDictEquivalence:
    """Hypothesis: Arrow and dict paths produce identical OHLCV bars."""

    @given(trades=_trade_list())
    @settings(max_examples=50, deadline=5000)
    def test_ohlcv_equivalence(self, trades: list[dict[str, int | float]]) -> None:
        """Both processing paths must produce identical OHLCV values."""
        threshold = 250

        # Dict path
        proc_dict = OpenDeviationBarProcessor(threshold, symbol="BTCUSDT")
        bars_dict = proc_dict.process_trades(trades)

        # Arrow path
        proc_arrow = OpenDeviationBarProcessor(threshold, symbol="BTCUSDT")
        bars_arrow_rb = proc_arrow.process_trades_arrow(_trades_to_arrow(trades))
        bars_arrow_df = pl.from_arrow(bars_arrow_rb)

        # Same number of bars
        assert len(bars_dict) == len(bars_arrow_df), (
            f"Bar count mismatch: dict={len(bars_dict)}, arrow={len(bars_arrow_df)}"
        )

        if not bars_dict:
            return

        # Compare OHLCV values (core invariant)
        for i, bar_dict in enumerate(bars_dict):
            arrow_row = bars_arrow_df.row(i, named=True)
            for col in ("open", "high", "low", "close", "volume"):
                dict_val = bar_dict[col]
                arrow_val = arrow_row[col]
                assert dict_val == pytest.approx(arrow_val, rel=1e-10), (
                    f"Bar {i} {col}: dict={dict_val}, arrow={arrow_val}"
                )

    @given(trades=_trade_list())
    @settings(max_examples=20, deadline=5000)
    def test_arrow_output_passes_schema_after_normalization(
        self, trades: list[dict[str, int | float]]
    ) -> None:
        """Arrow path output, after μs→ms normalization, must pass BarOutputSchema."""
        proc = OpenDeviationBarProcessor(250, symbol="BTCUSDT")
        bars_rb = proc.process_trades_arrow(_trades_to_arrow(trades))
        bars_df = pl.from_arrow(bars_rb)

        if bars_df.is_empty():
            return

        # Apply same normalization as _stream_bars_with_processor():
        # Arrow returns open_time/close_time in μs → rename + convert to ms
        ts_exprs = []
        if "open_time" in bars_df.columns:
            ts_exprs.append((pl.col("open_time") // 1000).alias("open_time_ms"))
        if "close_time" in bars_df.columns:
            ts_exprs.append((pl.col("close_time") // 1000).alias("close_time_ms"))
        if ts_exprs:
            drop_cols = [c for c in ("open_time", "close_time") if c in bars_df.columns]
            bars_df = bars_df.with_columns(ts_exprs).drop(drop_cols)

        # Must pass schema after normalization
        BarOutputSchema.validate(bars_df)

    def test_raw_arrow_output_fails_schema(self) -> None:
        """Raw Arrow output (no normalization) must fail schema — this IS the bug."""
        proc = OpenDeviationBarProcessor(250, symbol="BTCUSDT")
        trades = [
            {"timestamp": _BASE_TS, "price": 100.0, "quantity": 1.0, "agg_trade_id": 1},
            {"timestamp": _BASE_TS + 1000, "price": 100.0, "quantity": 1.0, "agg_trade_id": 2},
            {"timestamp": _BASE_TS + 2000, "price": 100.78, "quantity": 1.0, "agg_trade_id": 3},
        ]
        bars_rb = proc.process_trades_arrow(_trades_to_arrow(trades))
        bars_df = pl.from_arrow(bars_rb)

        if bars_df.is_empty():
            pytest.skip("No bars produced (threshold not breached)")

        # Raw Arrow output has open_time/close_time (not _ms) — schema must reject
        with pytest.raises(SchemaError):
            BarOutputSchema.validate(bars_df)
