"""Tests for _parquet_prefill_aion() -- Parquet-first gap fill for Aion sidecar startup (RESUME-02)."""

from __future__ import annotations

import time
from pathlib import Path
from unittest.mock import MagicMock, patch

import polars as pl
import pyarrow as pa
import pytest

# Patch targets at their definition modules (lazy imports inside functions)
_TICK_STORAGE_TARGET = "opendeviationbar.storage.parquet.TickStorage"
_PROCESSOR_TARGET = "opendeviationbar.processors.core.OpenDeviationBarProcessor"


@pytest.fixture
def mock_ch_cache():
    """Mock OpenDeviationBarCache."""
    cache = MagicMock()
    cache.store_bars_batch = MagicMock(return_value=5)
    return cache


@pytest.fixture
def sample_trades_df():
    """Sample trades DataFrame as returned by TickStorage.read_ticks_streaming."""
    return pl.DataFrame(
        {
            "agg_trade_id": [100, 101, 102, 103, 104],
            "price": [50000.0, 50001.0, 50002.0, 50003.0, 50004.0],
            "quantity": [1.0, 2.0, 0.5, 1.5, 3.0],
            "first_trade_id": [200, 201, 202, 203, 204],
            "last_trade_id": [200, 201, 202, 203, 204],
            "timestamp": [
                1700000000000000,
                1700000001000000,
                1700000002000000,
                1700000003000000,
                1700000004000000,
            ],
            "is_buyer_maker": [True, False, True, False, True],
        }
    )


def _make_fresh_parquet_path(*, exists: bool = True, age_s: float = 0.0) -> MagicMock:
    """Create a mock Path that behaves like a Parquet file path."""
    p = MagicMock(spec=Path)
    p.exists.return_value = exists
    p.stat.return_value = MagicMock(st_mtime=time.time() - age_s)
    # Chain / operator: ticks_dir / symbol / "YYYY-MM-DD.parquet"
    p.__truediv__ = MagicMock(return_value=p)
    return p


class TestParquetPrefillAion:
    """Tests for _parquet_prefill_aion function."""

    def test_returns_seed_tids_when_parquet_has_ticks(
        self, mock_ch_cache, sample_trades_df
    ):
        """Test 1: When Parquet has ticks covering the gap range,
        _parquet_prefill_aion returns {symbol: last_completed_bar_tid}."""
        from opendeviationbar.sidecar.parquet_fill import _parquet_prefill_aion

        mock_storage = MagicMock()
        mock_storage.ticks_dir = _make_fresh_parquet_path(exists=True, age_s=0)
        mock_storage.read_ticks_streaming.return_value = iter([sample_trades_df])

        mock_processor = MagicMock()
        mock_processor.last_completed_bar_tid.return_value = 103
        mock_processor.process_trades_arrow_native.return_value = pa.record_batch(
            {"open": [50000.0], "close": [50003.0]},
        )

        with (
            patch(_TICK_STORAGE_TARGET, return_value=mock_storage),
            patch(_PROCESSOR_TARGET, return_value=mock_processor),
        ):
            result = _parquet_prefill_aion(
                ch_cache=mock_ch_cache,
                aion_symbols=["BTCUSDT"],
                resume_tids={"BTCUSDT": 99},
                thresholds=[250, 500],
            )

        assert "BTCUSDT" in result
        assert result["BTCUSDT"] == 103

    def test_omits_symbol_when_no_parquet_ticks(self, mock_ch_cache):
        """Test 2: When Parquet has no ticks for a symbol,
        that symbol is omitted from results (falls through to REST)."""
        from opendeviationbar.sidecar.parquet_fill import _parquet_prefill_aion

        mock_storage = MagicMock()
        mock_storage.ticks_dir = _make_fresh_parquet_path(exists=False)

        with patch(_TICK_STORAGE_TARGET, return_value=mock_storage):
            result = _parquet_prefill_aion(
                ch_cache=mock_ch_cache,
                aion_symbols=["BTCUSDT"],
                resume_tids={"BTCUSDT": 99},
                thresholds=[250],
            )

        assert "BTCUSDT" not in result
        assert result == {}

    def test_skips_stale_parquet(self, mock_ch_cache):
        """Test 3: When Parquet is stale (>10 min old), symbol is skipped."""
        from opendeviationbar.sidecar.parquet_fill import _parquet_prefill_aion

        mock_storage = MagicMock()
        mock_storage.ticks_dir = _make_fresh_parquet_path(
            exists=True, age_s=700,  # 700s > 600s threshold
        )

        with patch(_TICK_STORAGE_TARGET, return_value=mock_storage):
            result = _parquet_prefill_aion(
                ch_cache=mock_ch_cache,
                aion_symbols=["BTCUSDT"],
                resume_tids={"BTCUSDT": 99},
                thresholds=[250],
            )

        assert result == {}

    def test_returns_empty_dict_on_exception(self, mock_ch_cache):
        """Test 4: When processing raises exception,
        function returns empty dict (non-fatal)."""
        from opendeviationbar.sidecar.parquet_fill import _parquet_prefill_aion

        # Force an error in TickStorage construction
        with patch(_TICK_STORAGE_TARGET, side_effect=RuntimeError("boom")):
            result = _parquet_prefill_aion(
                ch_cache=mock_ch_cache,
                aion_symbols=["BTCUSDT"],
                resume_tids={"BTCUSDT": 99},
                thresholds=[250],
            )

        assert result == {}

    def test_filters_trades_below_resume_tid(
        self, mock_ch_cache, sample_trades_df
    ):
        """Test 5: Trades with agg_trade_id <= resume_tid are filtered out
        (only process gap trades)."""
        from opendeviationbar.sidecar.parquet_fill import _parquet_prefill_aion

        mock_storage = MagicMock()
        mock_storage.ticks_dir = _make_fresh_parquet_path(exists=True, age_s=0)
        # Trades have agg_trade_id from 100-104, resume_tid=102
        # Only 103, 104 should be processed
        mock_storage.read_ticks_streaming.return_value = iter([sample_trades_df])

        mock_processor = MagicMock()
        mock_processor.last_completed_bar_tid.return_value = 104
        mock_processor.process_trades_arrow_native.return_value = pa.record_batch(
            {"open": [50003.0], "close": [50004.0]},
        )

        with (
            patch(_TICK_STORAGE_TARGET, return_value=mock_storage),
            patch(_PROCESSOR_TARGET, return_value=mock_processor),
        ):
            result = _parquet_prefill_aion(
                ch_cache=mock_ch_cache,
                aion_symbols=["BTCUSDT"],
                resume_tids={"BTCUSDT": 102},
                thresholds=[250],
            )

        # Verify processor was called with filtered data (only trades > 102)
        call_args = mock_processor.process_trades_arrow_native.call_args
        arrow_table = call_args[0][0]
        # The filtered chunk should only have trades 103 and 104
        trade_ids = arrow_table.column("agg_trade_id").to_pylist()
        assert all(tid > 102 for tid in trade_ids)

    def test_engine_seeds_updated_after_prefill(self, mock_ch_cache):
        """Test 6: After _parquet_prefill_aion, engine seed TIDs are updated
        in __init__.py call site."""
        # This tests the integration pattern in __init__.py
        # We verify the call site logic by simulating the post-prefill step
        engine = MagicMock()
        parquet_seeds = {"BTCUSDT": 5000, "ETHUSDT": 3000}
        thresholds = [250, 500]

        # Simulate the call site code from the plan
        for sym, seed_tid in parquet_seeds.items():
            for thr in thresholds:
                engine.seed_trade_id(sym, thr, seed_tid)

        # Verify all combinations were seeded
        assert engine.seed_trade_id.call_count == 4
        engine.seed_trade_id.assert_any_call("BTCUSDT", 250, 5000)
        engine.seed_trade_id.assert_any_call("BTCUSDT", 500, 5000)
        engine.seed_trade_id.assert_any_call("ETHUSDT", 250, 3000)
        engine.seed_trade_id.assert_any_call("ETHUSDT", 500, 3000)
