"""Tests for atomic replace in _sync_flush_rest_bars() (v1.3 ATOM-01/ATOM-02).

Verifies that _sync_flush_rest_bars() uses store_bars_replacing() as the
primary write path instead of _write_batch_with_retry(), with correct Polars
DataFrame conversion, Redis lock acquisition, and fallback behavior.
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import polars as pl
import pytest


def _make_bar(
    symbol: str = "BTCUSDT",
    threshold: int = 250,
    open_time_ms: int = 1_710_000_000_000,
    close_time_ms: int = 1_710_000_060_000,
    first_agg_trade_id: int = 100,
    last_agg_trade_id: int = 200,
) -> dict:
    """Return a minimal bar dict as produced by LiveBarEngine."""
    return {
        "_symbol": symbol,
        "_threshold": threshold,
        "open_time_ms": open_time_ms,
        "close_time_ms": close_time_ms,
        "first_agg_trade_id": first_agg_trade_id,
        "last_agg_trade_id": last_agg_trade_id,
        "Open": 42000.0,
        "High": 42100.0,
        "Low": 41900.0,
        "Close": 42050.0,
        "Volume": 10.5,
    }


def _make_cross_midnight_bar(symbol: str = "BTCUSDT", threshold: int = 250) -> dict:
    """Return a bar that crosses UTC midnight (L1 filter should discard)."""
    day_ms = 86_400_000
    # open at 23:59, close at 00:01 next day
    open_ms = (1_710_000_000_000 // day_ms) * day_ms + day_ms - 60_000
    close_ms = open_ms + 120_000  # 2 min later, crosses midnight
    return _make_bar(
        symbol=symbol,
        threshold=threshold,
        open_time_ms=open_ms,
        close_time_ms=close_ms,
    )


class _MockEngine:
    """Mock engine with a finite ring buffer of bars."""

    def __init__(self, bars: list[dict]):
        self._bars = list(bars)
        self._idx = 0

    def next_bar(self, timeout_ms: int = 0) -> dict | None:
        if self._idx >= len(self._bars):
            return None
        bar = self._bars[self._idx]
        self._idx += 1
        return dict(bar)  # copy so .pop() doesn't mutate original


# Patch the lazy import inside _sync_flush_rest_bars
_CACHE_PATCH = "opendeviationbar.clickhouse.cache.OpenDeviationBarCache"


def _setup_mock_cache(mock_cache_cls, return_value=3, side_effect=None):
    """Configure a mock OpenDeviationBarCache with context manager support."""
    mock_cache = MagicMock()
    mock_cache.__enter__ = MagicMock(return_value=mock_cache)
    mock_cache.__exit__ = MagicMock(return_value=False)
    if side_effect:
        mock_cache.store_bars_replacing.side_effect = side_effect
    else:
        mock_cache.store_bars_replacing.return_value = return_value
    mock_cache_cls.return_value = mock_cache
    return mock_cache


class TestSyncFlushAtomicReplace:
    """Test 1: sync_flush calls store_bars_replacing() with correct args."""

    @patch(_CACHE_PATCH)
    def test_calls_store_bars_replacing_with_polars_df(self, mock_cache_cls):
        """store_bars_replacing is called with Polars DataFrame, correct params."""
        from opendeviationbar.sidecar import _sync_flush_rest_bars

        mock_cache = _setup_mock_cache(mock_cache_cls, return_value=3)

        bars = [_make_bar() for _ in range(3)]
        engine = _MockEngine(bars)

        valid_pairs = {("BTCUSDT", 250)}
        result = _sync_flush_rest_bars(engine, valid_pairs)

        assert result == 3
        mock_cache.store_bars_replacing.assert_called_once()

        # Verify call args
        call_kwargs = mock_cache.store_bars_replacing.call_args
        assert call_kwargs.kwargs["symbol"] == "BTCUSDT"
        assert call_kwargs.kwargs["threshold_decimal_bps"] == 250
        assert isinstance(call_kwargs.kwargs["bars_df"], pl.DataFrame)
        assert len(call_kwargs.kwargs["bars_df"]) == 3
        assert call_kwargs.kwargs["_skip_lock"] is False
        assert "+sidecar" in call_kwargs.kwargs["version"]


class TestSyncFlushEmptyBuffer:
    """Test 2: empty ring buffer returns 0 without calling store_bars_replacing."""

    @patch(_CACHE_PATCH)
    def test_empty_buffer_returns_zero(self, mock_cache_cls):
        from opendeviationbar.sidecar import _sync_flush_rest_bars

        engine = _MockEngine([])
        result = _sync_flush_rest_bars(engine, None)

        assert result == 0
        # Cache should not even be instantiated for empty buffer
        mock_cache_cls.assert_not_called()


class TestSyncFlushFallback:
    """Test 3: fallback to _write_batch_with_retry on store_bars_replacing exception."""

    @patch("opendeviationbar.sidecar._write_batch_with_retry", return_value=True)
    @patch(_CACHE_PATCH)
    def test_fallback_on_exception(self, mock_cache_cls, mock_write_retry):
        from opendeviationbar.sidecar import _sync_flush_rest_bars

        _setup_mock_cache(mock_cache_cls, side_effect=RuntimeError("CH down"))

        bars = [_make_bar(), _make_bar()]
        engine = _MockEngine(bars)
        valid_pairs = {("BTCUSDT", 250)}

        result = _sync_flush_rest_bars(engine, valid_pairs)

        assert result == 2
        mock_write_retry.assert_called_once()
        # Verify fallback was called with correct symbol/threshold
        args = mock_write_retry.call_args
        assert args[0][0] == "BTCUSDT"
        assert args[0][1] == 250


class TestSyncFlushCrossMidnightFilter:
    """Test 4: cross-midnight bars filtered before reaching store_bars_replacing."""

    @patch(_CACHE_PATCH)
    def test_cross_midnight_bars_filtered(self, mock_cache_cls):
        from opendeviationbar.sidecar import _sync_flush_rest_bars

        mock_cache = _setup_mock_cache(mock_cache_cls, return_value=1)

        good_bar = _make_bar()
        bad_bar = _make_cross_midnight_bar()
        engine = _MockEngine([good_bar, bad_bar])
        valid_pairs = {("BTCUSDT", 250)}

        result = _sync_flush_rest_bars(engine, valid_pairs)

        assert result == 1
        # Only 1 bar should reach store_bars_replacing (cross-midnight filtered)
        call_kwargs = mock_cache.store_bars_replacing.call_args
        df = call_kwargs.kwargs["bars_df"]
        assert len(df) == 1
