"""Tests for MEM-014: Chunked gap repair in backfill.py (Issue #115).

Validates that chunked `_fetch_and_process_gap()` produces identical
bars to single-pass processing, maintains Ariadne cursor alignment,
and respects memory watermark thresholds.
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

from conftest import make_test_trades


class TestStreamingVsBatch:
    """process_trades_streaming() across chunks matches single-pass process_trades()."""

    def test_streaming_produces_same_bars_as_batch(self):
        """Chunked streaming produces identical bars to single batch."""
        from opendeviationbar._core import PyOpenDeviationBarProcessor

        trades = make_test_trades(1000, 500)

        # Single-pass (batch)
        batch_proc = PyOpenDeviationBarProcessor(250, symbol="BTCUSDT")
        batch_bars = batch_proc.process_trades(trades)

        # Two-pass streaming (simulates chunked gap repair)
        stream_proc = PyOpenDeviationBarProcessor(250, symbol="BTCUSDT")
        mid = len(trades) // 2
        stream_bars_1 = stream_proc.process_trades_streaming(trades[:mid])
        stream_bars_2 = stream_proc.process_trades_streaming(trades[mid:])
        stream_bars = stream_bars_1 + stream_bars_2

        # Compare completed bars — streaming may hold an incomplete bar
        # so we compare only the bars that both methods produced
        assert len(stream_bars) >= len(batch_bars) - 1
        for i in range(min(len(batch_bars), len(stream_bars))):
            assert batch_bars[i]["Open"] == stream_bars[i]["Open"]
            assert batch_bars[i]["Close"] == stream_bars[i]["Close"]
            assert batch_bars[i]["High"] == stream_bars[i]["High"]
            assert batch_bars[i]["Low"] == stream_bars[i]["Low"]


class TestIncompleteBarContinuation:
    """Incomplete bar at chunk boundary is NOT emitted prematurely."""

    def test_incomplete_bar_continues_across_chunks(self):
        """A bar that hasn't breached threshold continues into the next chunk."""
        from opendeviationbar._core import PyOpenDeviationBarProcessor

        # Use a very wide threshold so bars are unlikely to complete quickly
        proc = PyOpenDeviationBarProcessor(5000, symbol="BTCUSDT")

        # Chunk 1: few trades, probably no completed bar
        trades_1 = make_test_trades(1, 5, base_ts=1_700_000_000_000)
        bars_1 = proc.process_trades_streaming(trades_1)

        # Chunk 2: more trades continuing from chunk 1
        trades_2 = make_test_trades(6, 5, base_ts=1_700_000_000_500)
        bars_2 = proc.process_trades_streaming(trades_2)

        # With a 50% threshold on a $42K asset, 10 trades with tiny
        # price variation won't complete a bar — both chunks should
        # return empty (bar still incomplete in processor state)
        total_bars = len(bars_1) + len(bars_2)
        assert total_bars == 0, (
            f"Expected no completed bars with 5000 dbps threshold, got {total_bars}"
        )


class TestNoOrphanAtChunkBoundary:
    """Orphan bars only appear at Ouroboros boundaries, never at chunk splits."""

    def test_no_orphan_at_chunk_boundary(self):
        """Chunk boundary does not trigger orphan emission."""
        from opendeviationbar._core import PyOpenDeviationBarProcessor

        proc = PyOpenDeviationBarProcessor(250, symbol="BTCUSDT")

        # All trades within the same Ouroboros period (same year)
        trades_1 = make_test_trades(1, 200, base_ts=1_700_000_000_000)
        bars_1 = proc.process_trades_streaming(trades_1)

        trades_2 = make_test_trades(201, 200, base_ts=1_700_000_020_000)
        bars_2 = proc.process_trades_streaming(trades_2)

        all_bars = bars_1 + bars_2
        orphans = [b for b in all_bars if b.get("is_orphan", False)]
        assert len(orphans) == 0, (
            f"Expected no orphan bars at chunk boundary, got {len(orphans)}"
        )


class TestMemoryWatermark:
    """_check_memory_watermark() returns correct tiered levels."""

    def test_ok_level(self):
        from opendeviationbar.backfill import _check_memory_watermark

        # Mock RSS to 100 MB with 4 GB ceiling → 2.4% = ok
        with patch(
            "opendeviationbar.resource_guard._get_process_rss_mb",
            return_value=100,
        ):
            result = _check_memory_watermark(4 * 1024**3)
        assert result == "ok"

    def test_gc_level(self):
        from opendeviationbar.backfill import _check_memory_watermark

        # Mock RSS to 3000 MB with 4 GB ceiling → 73% = gc
        with patch(
            "opendeviationbar.resource_guard._get_process_rss_mb",
            return_value=3000,
        ):
            result = _check_memory_watermark(4 * 1024**3)
        assert result == "gc"

    def test_abort_level(self):
        from opendeviationbar.backfill import _check_memory_watermark

        # Mock RSS to 3800 MB with 4 GB ceiling → 93% = abort
        with patch(
            "opendeviationbar.resource_guard._get_process_rss_mb",
            return_value=3800,
        ):
            result = _check_memory_watermark(4 * 1024**3)
        assert result == "abort"


class TestAriadneCursorAdvancement:
    """current_from_id correctly advances between chunks."""

    def test_ariadne_cursor_advances_between_chunks(self):
        """Verify the cursor tracking logic used in _fetch_and_process_gap."""
        trades_chunk_1 = make_test_trades(1000, 50)
        trades_chunk_2 = make_test_trades(1050, 50)

        # Simulate the cursor advancement logic from _fetch_and_process_gap
        current_from_id = 1000

        # After chunk 1
        last_trade = trades_chunk_1[-1]
        last_id = last_trade["agg_trade_id"]
        assert last_id == 1049
        current_from_id = last_id + 1
        assert current_from_id == 1050

        # After chunk 2
        last_trade = trades_chunk_2[-1]
        last_id = last_trade["agg_trade_id"]
        assert last_id == 1099
        current_from_id = last_id + 1
        assert current_from_id == 1100

        # Verify no gap between chunks
        assert trades_chunk_2[0]["agg_trade_id"] == 1050
        assert trades_chunk_1[-1]["agg_trade_id"] + 1 == trades_chunk_2[0]["agg_trade_id"]


class TestFetchAndProcessGapChunked:
    """Integration test for the chunked _fetch_and_process_gap path."""

    def test_chunked_path_writes_bars(self):
        """Ariadne path uses chunked processing and writes to ClickHouse."""
        from opendeviationbar.backfill import _fetch_and_process_gap

        trades = make_test_trades(500, 100)

        mock_cache = MagicMock()
        mock_cache.get_ariadne_high_water_mark.return_value = 499
        mock_cache.store_bars_batch.return_value = 5
        mock_cache.__enter__ = MagicMock(return_value=mock_cache)
        mock_cache.__exit__ = MagicMock(return_value=False)

        with (
            patch(
                "opendeviationbar.backfill._fetch_trades_ariadne",
                return_value=trades,
            ) as mock_fetch,
            patch(
                "opendeviationbar.clickhouse.OpenDeviationBarCache",
                return_value=mock_cache,
            ),
            patch(
                "opendeviationbar.backfill._check_memory_watermark",
                return_value="ok",
            ),
        ):
            written = _fetch_and_process_gap(
                symbol="BTCUSDT",
                threshold_decimal_bps=250,
                start_ms=1_700_000_000_000,
                end_ms=1_700_000_010_000,
                include_microstructure=False,
            )

        # Ariadne fetch was called with from_id = 499 + 1 = 500
        mock_fetch.assert_called_once()
        call_args = mock_fetch.call_args
        assert call_args[0][1] == 500  # from_id

    def test_fallback_path_unchunked(self):
        """Non-Ariadne (first-ever) path uses _fetch_aggtrades_rest, not Ariadne."""
        from opendeviationbar.backfill import _fetch_and_process_gap

        trades = make_test_trades(1, 50)

        mock_cache = MagicMock()
        mock_cache.get_ariadne_high_water_mark.return_value = None
        mock_cache.store_bars_batch.return_value = 3
        mock_cache.__enter__ = MagicMock(return_value=mock_cache)
        mock_cache.__exit__ = MagicMock(return_value=False)

        with (
            patch(
                "opendeviationbar.backfill._fetch_aggtrades_rest",
                return_value=trades,
            ) as mock_rest,
            patch(
                "opendeviationbar.backfill._fetch_trades_ariadne",
            ) as mock_ariadne,
            patch(
                "opendeviationbar.clickhouse.OpenDeviationBarCache",
                return_value=mock_cache,
            ),
        ):
            _fetch_and_process_gap(
                symbol="BTCUSDT",
                threshold_decimal_bps=250,
                start_ms=1_700_000_000_000,
                end_ms=1_700_000_005_000,
                include_microstructure=False,
            )

        # Non-Ariadne path uses REST startTime, not Ariadne fromId
        mock_rest.assert_called_once()
        mock_ariadne.assert_not_called()
