"""Tests for _sync_flush_rest_bars() batch write path.

Verifies that _sync_flush_rest_bars() uses store_bars_batch() 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

import logging
from unittest.mock import MagicMock, patch

import polars as pl


def _make_bar(
    symbol: str = "BTCUSDT",
    threshold: int = 250,
    open_time_us: int = 1_710_000_000_000,
    close_time_us: 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_us": open_time_us,
        "close_time_us": close_time_us,
        "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,
    }


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

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

    def drain_bars(self) -> list[dict]:
        return [dict(b) for b in self._bars]

    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_batch.side_effect = side_effect
    else:
        mock_cache.store_bars_batch.return_value = return_value
    mock_cache_cls.return_value = mock_cache
    return mock_cache


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

    @patch(_CACHE_PATCH)
    def test_calls_store_bars_batch_with_polars_df(self, mock_cache_cls):
        """store_bars_batch 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_batch.assert_called_once()

        # Verify call args
        call_kwargs = mock_cache.store_bars_batch.call_args
        assert call_kwargs.kwargs["symbol"] == "BTCUSDT"
        assert call_kwargs.kwargs["threshold_decimal_bps"] == 250
        assert isinstance(call_kwargs.kwargs["bars"], pl.DataFrame)
        assert len(call_kwargs.kwargs["bars"]) == 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_batch exception."""

    @patch("opendeviationbar.sidecar.midnight._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 TestStatheraOrderingValidBatch:
    """Test 5: Valid Stathera ordering produces no WARNING logs."""

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

        _setup_mock_cache(mock_cache_cls, return_value=3)

        # Sequential trade IDs: bar1 100-200, bar2 201-300, bar3 301-400
        bars = [
            _make_bar(first_agg_trade_id=100, last_agg_trade_id=200),
            _make_bar(first_agg_trade_id=201, last_agg_trade_id=300),
            _make_bar(first_agg_trade_id=301, last_agg_trade_id=400),
        ]
        engine = _MockEngine(bars)
        valid_pairs = {("BTCUSDT", 250)}

        with caplog.at_level(logging.WARNING):
            _sync_flush_rest_bars(engine, valid_pairs)

        # No Stathera warnings should appear
        stathera_warnings = [
            r for r in caplog.records
            if r.levelno >= logging.WARNING and "stathera" in r.message.lower()
        ]
        assert len(stathera_warnings) == 0, (
            f"Expected no Stathera warnings, got: {[r.message for r in stathera_warnings]}"
        )


class TestStatheraGapWarning:
    """Test 6: Stathera gap within REST batch triggers WARNING but still writes."""

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

        _setup_mock_cache(mock_cache_cls, return_value=3)

        # Gap between bar1 and bar2: tid 200 -> 250 (49 trades missing)
        bars = [
            _make_bar(first_agg_trade_id=100, last_agg_trade_id=200),
            _make_bar(first_agg_trade_id=250, last_agg_trade_id=300),
            _make_bar(first_agg_trade_id=301, last_agg_trade_id=400),
        ]
        engine = _MockEngine(bars)
        valid_pairs = {("BTCUSDT", 250)}

        with caplog.at_level(logging.WARNING):
            result = _sync_flush_rest_bars(engine, valid_pairs)

        # Should still write all bars (non-fatal diagnostic)
        assert result == 3

        # Should have a Stathera gap warning
        stathera_warnings = [
            r for r in caplog.records
            if r.levelno >= logging.WARNING and "stathera" in r.message.lower()
        ]
        assert len(stathera_warnings) >= 1, (
            "Expected at least 1 Stathera gap warning"
        )
        assert any("gap" in r.message.lower() for r in stathera_warnings)


class TestStatheraNonMonotonicWarning:
    """Test 7: Non-monotonic trade IDs within REST batch trigger WARNING."""

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

        _setup_mock_cache(mock_cache_cls, return_value=2)

        # Overlapping TID ranges: after sorting by first_tid, bar2.first_tid <= bar1.last_tid
        bars = [
            _make_bar(first_agg_trade_id=100, last_agg_trade_id=300),
            _make_bar(first_agg_trade_id=200, last_agg_trade_id=400),
        ]
        engine = _MockEngine(bars)
        valid_pairs = {("BTCUSDT", 250)}

        with caplog.at_level(logging.WARNING):
            _sync_flush_rest_bars(engine, valid_pairs)

        # Should have a Stathera non-monotonic warning
        stathera_warnings = [
            r for r in caplog.records
            if r.levelno >= logging.WARNING and "stathera" in r.message.lower()
        ]
        assert len(stathera_warnings) >= 1, (
            "Expected at least 1 Stathera non-monotonic warning"
        )
        assert any("non-monotonic" in r.message.lower() or "non_monotonic" in r.message.lower()
                    for r in stathera_warnings)


class TestJunctionTelemetryInStatus:
    """Test 8: /status includes rest_first_tid, rest_last_tid, and ws_first_tid per symbol."""

    def test_status_includes_all_junction_fields(self):
        """When _junction_state has all three TID fields, /status exposes them."""
        import json
        import socket
        import threading
        import urllib.request

        from opendeviationbar.sidecar.http_server import (
            _junction_state,
            _start_health_server,
        )

        # Populate junction state as midnight.py and _main_loop would
        _junction_state.clear()
        _junction_state["BTCUSDT"] = {
            "rest_first_tid": 1000,
            "rest_last_tid": 5000,
            "ws_first_tid": 5001,
        }

        # Find a free port
        with socket.socket() as s:
            s.bind(("", 0))
            port = s.getsockname()[1]

        # Start real HTTP server
        srv = _start_health_server(port=port, sse_enabled=False)
        assert srv is not None
        thread = threading.Thread(target=srv.handle_request, daemon=True)
        thread.start()

        try:
            url = f"http://127.0.0.1:{port}/status"
            with urllib.request.urlopen(url, timeout=5) as resp:
                body = resp.read()
            data = json.loads(body)

            assert "symbols" in data
            assert "BTCUSDT" in data["symbols"]
            sym = data["symbols"]["BTCUSDT"]
            assert sym["rest_first_tid"] == 1000
            assert sym["rest_last_tid"] == 5000
            assert sym["ws_first_tid"] == 5001
        finally:
            srv.server_close()
            _junction_state.clear()


class TestWsFirstTidOneShot:
    """Test 9: ws_first_tid is recorded once per symbol (first WS bar only)."""

    def test_ws_first_tid_not_overwritten(self):
        """After ws_first_tid is set, subsequent bars do not overwrite it."""
        from opendeviationbar.sidecar.http_server import _junction_state

        _junction_state.clear()
        _junction_state["BTCUSDT"] = {
            "rest_first_tid": 1000,
            "rest_last_tid": 5000,
        }

        # Simulate what _main_loop should do: record ws_first_tid on first bar
        symbol = "BTCUSDT"
        first_bar_tid = 5001
        second_bar_tid = 6000

        # First bar: ws_first_tid should be set
        if symbol in _junction_state and "ws_first_tid" not in _junction_state[symbol]:
            _junction_state[symbol]["ws_first_tid"] = first_bar_tid

        assert _junction_state["BTCUSDT"]["ws_first_tid"] == 5001

        # Second bar: ws_first_tid should NOT be overwritten
        if symbol in _junction_state and "ws_first_tid" not in _junction_state[symbol]:
            _junction_state[symbol]["ws_first_tid"] = second_bar_tid

        assert _junction_state["BTCUSDT"]["ws_first_tid"] == 5001  # Still first value

        # Cleanup
        _junction_state.clear()
