"""Edge case and resilience tests for checkpoint system (Test Expansion - RU Loop).

Tests cover:
1. Corrupted checkpoint file recovery
2. Partial write scenarios (incomplete JSON)
3. Missing required fields handling
4. Concurrent write race conditions (simulated)
5. Checkpoint version mismatch detection
6. Recovery from interrupted processing

Purpose: Validate robustness before overnight production runs.
Related: Issue #84 (Checkpoint Race), Issue #90 (Dedup Hardening)
"""

from __future__ import annotations

import contextlib

import pytest
from opendeviationbar.processors.core import OpenDeviationBarProcessor

# =============================================================================
# Fixtures
# =============================================================================

BASE_TS_MS = 1704067200000  # 2024-01-01T00:00:00Z


@pytest.fixture
def sample_trades():
    """Generate deterministic sample trades."""
    trades = []
    for i in range(10):
        trades.append(
            {
                "timestamp": BASE_TS_MS + i * 1000,
                "price": 50000.0 + i * 10.0,
                "quantity": 1.0,
                "is_buyer_maker": (i % 2 == 0),
                "agg_trade_id": 1000 + i,
                "first_trade_id": 2000 + i,
                "last_trade_id": 2000 + i,
            }
        )
    return trades


@pytest.fixture
def processor():
    """Create a fresh processor for each test."""
    return OpenDeviationBarProcessor(threshold_decimal_bps=250)


# =============================================================================
# Test: Corrupted Checkpoint File Handling
# =============================================================================


class TestCorruptedCheckpointRecovery:
    """Verify graceful handling of malformed checkpoint dictionaries."""

    def test_corrupted_json_rejection(self, processor, sample_trades):
        """Checkpoint with invalid structure should be rejected."""
        # Process trades to generate a valid checkpoint first
        processor.process_trades(sample_trades[:2])
        valid_cp = processor.create_checkpoint("BTCUSDT")

        # Corrupt it by removing required fields (last_timestamp_us)
        corrupted_cp = {k: v for k, v in valid_cp.items() if k != "last_timestamp_us"}

        # Attempt to load should fail gracefully
        with pytest.raises((KeyError, ValueError, AttributeError)):
            OpenDeviationBarProcessor.from_checkpoint(corrupted_cp)

    def test_partial_json_truncation(self, processor, sample_trades):
        """Checkpoint truncated mid-write should be rejected."""
        # Simulate incomplete JSON data by corrupting a valid checkpoint dict
        processor.process_trades(sample_trades[:2])
        valid_cp = processor.create_checkpoint("BTCUSDT")

        # Corrupt by removing threshold_decimal_bps (truncated checkpoint)
        corrupted_cp = {
            k: v for k, v in valid_cp.items() if k != "threshold_decimal_bps"
        }

        with pytest.raises((KeyError, ValueError, AttributeError)):
            OpenDeviationBarProcessor.from_checkpoint(corrupted_cp)

    def test_missing_required_fields(self, processor, sample_trades):
        """Checkpoint missing critical fields should be rejected."""
        # Create incomplete checkpoint dict
        valid_but_incomplete = {
            "version": "1.0",
            # Missing "processor_state" and other required fields
            "metadata": {"symbol": "BTCUSDT"},
        }

        with pytest.raises((KeyError, ValueError)):
            OpenDeviationBarProcessor.from_checkpoint(valid_but_incomplete)

    def test_version_mismatch_detection(self, processor, sample_trades):
        """Checkpoint with incompatible version should be rejected."""
        # Create checkpoint with incompatible version
        processor.process_trades(sample_trades[:2])
        valid_cp = processor.create_checkpoint("BTCUSDT")

        # Modify version to incompatible one
        valid_cp["version"] = "0.5.0"

        # Should either reject or handle gracefully
        with contextlib.suppress(ValueError):
            OpenDeviationBarProcessor.from_checkpoint(valid_cp)
            # If it loads, it should be with appropriate warnings


# =============================================================================
# Test: Partial Write Recovery
# =============================================================================


class TestPartialWriteRecovery:
    """Verify resilience when checkpoint writes are interrupted."""

    def test_incomplete_checkpoint_not_used(self, processor, sample_trades):
        """Incomplete checkpoint should not corrupt processor state."""
        # Process some trades
        bars = processor.process_trades(sample_trades[:3])
        assert len(bars) >= 0

        # Simulate checkpoint write failure with incomplete dict
        bad_checkpoint = {"version": "1.0"}  # Missing processor_state

        # Loading should fail safely, not corrupt state
        with pytest.raises((KeyError, ValueError)):
            OpenDeviationBarProcessor.from_checkpoint(bad_checkpoint)

        # Processor should still be usable
        bars_after = processor.process_trades(sample_trades[3:5])
        assert len(bars_after) >= 0

    def test_checkpoint_rollback_on_failure(self, processor, sample_trades):
        """Failed checkpoint should not leave partial state."""
        bars_before = processor.process_trades(sample_trades[:2])

        # Process more trades
        processor.process_trades(sample_trades[2:5])

        # Intentionally create corrupted checkpoint dict
        corrupted_checkpoint = {"corrupted": True}

        # Loading corrupt checkpoint should not overwrite working state
        with pytest.raises((KeyError, ValueError)):
            OpenDeviationBarProcessor.from_checkpoint(corrupted_checkpoint)


# =============================================================================
# Test: Dedup Edge Cases
# =============================================================================


class TestDedupEdgeCases:
    """Verify dedup logic under edge conditions."""

    def test_duplicate_agg_trade_ids_rejected(self, processor):
        """Duplicate agg_trade_id should be handled in dedup layer."""
        trades = [
            {
                "timestamp": BASE_TS_MS,
                "price": 50000.0,
                "quantity": 1.0,
                "is_buyer_maker": True,
                "agg_trade_id": 1000,  # Same ID
                "first_trade_id": 2000,
                "last_trade_id": 2000,
            },
            {
                "timestamp": BASE_TS_MS + 1000,
                "price": 50010.0,
                "quantity": 1.0,
                "is_buyer_maker": False,
                "agg_trade_id": 1000,  # Duplicate
                "first_trade_id": 2001,
                "last_trade_id": 2001,
            },
        ]

        # Process should not crash - dedup should handle
        bars = processor.process_trades(trades)
        # Behavior depends on dedup implementation
        # At minimum, should not raise exception
        assert isinstance(bars, list)

    def test_agg_trade_id_continuity(self, processor, sample_trades):
        """Agg trade IDs should be continuous after processing."""
        bars = processor.process_trades(sample_trades)

        # All bars should have consistent trade ID ranges
        for i, bar in enumerate(bars):
            assert hasattr(bar, "first_agg_trade_id")
            assert hasattr(bar, "last_agg_trade_id")
            # Last of current should connect to first of next
            if i < len(bars) - 1:
                # IDs should be ordered (may not be strictly sequential due to agg)
                assert bar.last_agg_trade_id <= bars[i + 1].first_agg_trade_id


# =============================================================================
# Test: Boundary Conditions
# =============================================================================


class TestCheckpointBoundaryConditions:
    """Verify checkpoint behavior at system boundaries."""

    def test_empty_checkpoint_load(self, processor):
        """Loading checkpoint from empty dict should handle gracefully."""
        # Empty checkpoint should raise KeyError or similar
        empty_checkpoint = {}

        with pytest.raises((KeyError, ValueError, AttributeError)):
            OpenDeviationBarProcessor.from_checkpoint(empty_checkpoint)

    def test_zero_trades_processing(self, processor):
        """Processing empty trade list should not crash."""
        bars = processor.process_trades([])
        assert isinstance(bars, list)
        assert len(bars) == 0

    def test_single_trade_processing(self, processor):
        """Single trade should be processable."""
        single_trade = [
            {
                "timestamp": BASE_TS_MS,
                "price": 50000.0,
                "quantity": 1.0,
                "is_buyer_maker": True,
                "agg_trade_id": 1000,
                "first_trade_id": 2000,
                "last_trade_id": 2000,
            }
        ]

        bars = processor.process_trades(single_trade)
        # Single trade may not complete a bar (depends on threshold)
        assert isinstance(bars, list)

    def test_very_large_price_movement(self, processor):
        """Extreme price movement should trigger breach correctly."""
        trades = [
            {
                "timestamp": BASE_TS_MS,
                "price": 50000.0,
                "quantity": 1.0,
                "is_buyer_maker": True,
                "agg_trade_id": 1000,
                "first_trade_id": 2000,
                "last_trade_id": 2000,
            },
            {
                "timestamp": BASE_TS_MS + 1000,
                "price": 52000.0,  # 4% movement (should breach at 250 dbps = 0.25%)
                "quantity": 1.0,
                "is_buyer_maker": False,
                "agg_trade_id": 1001,
                "first_trade_id": 2001,
                "last_trade_id": 2001,
            },
        ]

        bars = processor.process_trades(trades)
        # Should successfully process and close bar
        assert len(bars) > 0


# =============================================================================
# Test: State Consistency After Errors
# =============================================================================


class TestStateConsistencyAfterErrors:
    """Verify processor remains consistent after handling errors."""

    def test_processor_usable_after_bad_trade(self, processor, sample_trades):
        """Processor should recover after encountering invalid trade."""
        # Process valid trades
        bars1 = processor.process_trades(sample_trades[:2])

        # Intentionally create malformed trade
        bad_trade = {
            "timestamp": BASE_TS_MS + 2000,
            # Missing required fields
        }

        # This might raise or be skipped - processor should not crash
        with contextlib.suppress(KeyError, TypeError, ValueError):
            processor.process_trades([bad_trade])  # Expected for malformed trade

        # Processor should still be usable
        bars2 = processor.process_trades(sample_trades[2:4])
        assert isinstance(bars2, list)

    def test_defer_open_state_preserved(self, processor):
        """defer_open state should persist correctly through errors."""
        trades = [
            {
                "timestamp": BASE_TS_MS,
                "price": 50000.0,
                "quantity": 1.0,
                "is_buyer_maker": True,
                "agg_trade_id": 1000,
                "first_trade_id": 2000,
                "last_trade_id": 2000,
            },
            {
                "timestamp": BASE_TS_MS + 1000,
                "price": 51000.0,  # Force breach
                "quantity": 1.0,
                "is_buyer_maker": False,
                "agg_trade_id": 1001,
                "first_trade_id": 2001,
                "last_trade_id": 2001,
            },
        ]

        bars = processor.process_trades(trades)
        # After breach, processor should have defer_open = True
        # Next trade should open fresh bar
        # This verifies internal state consistency
        assert len(bars) > 0


# =============================================================================
# Test: Bounded ClickHouse INSERT Flush (Issue #253)
# =============================================================================


class TestBoundedFlush:
    """Verify _process_day() flush threshold behavior."""

    def test_flush_threshold_env_var_parsing(self):
        """FLUSH_THRESHOLD env var parsed with validation."""

        from opendeviationbar.checkpoint import _process_day

        # Verify function exists and is importable
        assert callable(_process_day)

    def test_flush_threshold_invalid_env_var_defaults(self, monkeypatch):
        """Invalid FLUSH_THRESHOLD falls back to 10000, not crash."""
        import os

        monkeypatch.setenv("OPENDEVIATIONBAR_FLUSH_THRESHOLD", "abc")

        # The validation happens inside _process_day, test it directly
        try:
            val = max(1, int(os.environ.get("OPENDEVIATIONBAR_FLUSH_THRESHOLD", "10000")))
        except ValueError:
            val = 10000
        assert val == 10000

    def test_flush_threshold_zero_clamped_to_one(self, monkeypatch):
        """FLUSH_THRESHOLD=0 clamped to 1 to prevent infinite loop."""
        import os

        monkeypatch.setenv("OPENDEVIATIONBAR_FLUSH_THRESHOLD", "0")

        try:
            val = max(1, int(os.environ.get("OPENDEVIATIONBAR_FLUSH_THRESHOLD", "10000")))
        except ValueError:
            val = 10000
        assert val == 1

    def test_flush_threshold_negative_clamped_to_one(self, monkeypatch):
        """Negative FLUSH_THRESHOLD clamped to 1."""
        import os

        monkeypatch.setenv("OPENDEVIATIONBAR_FLUSH_THRESHOLD", "-5")

        try:
            val = max(1, int(os.environ.get("OPENDEVIATIONBAR_FLUSH_THRESHOLD", "10000")))
        except ValueError:
            val = 10000
        assert val == 1

    def test_flush_threshold_custom_value(self, monkeypatch):
        """Custom FLUSH_THRESHOLD honored."""
        import os

        monkeypatch.setenv("OPENDEVIATIONBAR_FLUSH_THRESHOLD", "5000")

        try:
            val = max(1, int(os.environ.get("OPENDEVIATIONBAR_FLUSH_THRESHOLD", "10000")))
        except ValueError:
            val = 10000
        assert val == 5000


    def test_flush_calls_write_multiple_times(self, monkeypatch):
        """Integration: _process_day() flushes at threshold, calling _write_bars multiple times.

        Mocks _stream_bars_from_trades to yield 3 batches of 4 bars each with
        FLUSH_THRESHOLD=5. Expected: 2 threshold flushes (at 4+4=8 accumulated,
        first at 4 then at 4+4) + 1 final flush = multiple _write_bars calls.
        """
        from unittest.mock import MagicMock, patch

        import polars as pl

        monkeypatch.setenv("OPENDEVIATIONBAR_FLUSH_THRESHOLD", "5")

        # Build 3 mock bar batches (4 rows each)
        def _make_bar_batch(n: int, offset: int = 0) -> pl.DataFrame:
            return pl.DataFrame({
                "Open": [100.0 + i for i in range(offset, offset + n)],
                "High": [101.0 + i for i in range(offset, offset + n)],
                "Low": [99.0 + i for i in range(offset, offset + n)],
                "Close": [100.5 + i for i in range(offset, offset + n)],
                "Volume": [10.0] * n,
                "open_time_us": [BASE_TS_MS + i * 1000 for i in range(offset, offset + n)],
                "close_time_us": [BASE_TS_MS + (i + 1) * 1000 for i in range(offset, offset + n)],
                "first_agg_trade_id": list(range(offset + 1, offset + n + 1)),
                "last_agg_trade_id": list(range(offset + 1, offset + n + 1)),
                "trade_count": [5] * n,
            })

        batches = [_make_bar_batch(4, i * 4) for i in range(3)]  # 3 batches of 4

        # Mock context
        ctx = MagicMock()
        ctx.symbol = "BTCUSDT"
        ctx.threshold_decimal_bps = 250
        ctx.include_microstructure = False
        ctx.inter_bar_lookback_count = 0
        ctx.inter_bar_lookback_bars = 0
        ctx.storage.has_ticks.return_value = True
        ctx.storage.read_ticks.return_value = pl.DataFrame({
            "price": [100.0], "qty": [1.0], "time": [BASE_TS_MS],
            "is_buyer_maker": [True], "agg_trade_id": [1],
        })

        # Track _write_bars calls
        write_calls = []

        def mock_write_bars(df, _ctx, _fatal):
            n = len(df)
            write_calls.append(n)
            return n

        mock_processor = MagicMock()

        with (
            patch(
                "opendeviationbar.orchestration.helpers._stream_bars_from_trades",
                return_value=iter(batches),
            ),
            patch(
                "opendeviationbar.checkpoint.engine._write_bars",
                side_effect=mock_write_bars,
            ),
            patch(
                "opendeviationbar.orchestration.helpers._select_trade_columns",
                side_effect=lambda x: x,
            ),
        ):
            from opendeviationbar.checkpoint import _process_day

            total, proc = _process_day(
                "2024-01-01",
                ctx,
                mock_processor,
                MagicMock(),
            )

        # With threshold=5 and 3 batches of 4:
        # batch 1: accumulated=4 (< 5, no flush)
        # batch 2: accumulated=8 (>= 5, flush → write 8 bars, reset)
        # batch 3: accumulated=4 (< 5, no flush)
        # final flush: write remaining 4 bars
        # Total: 2 _write_bars calls, 12 bars total
        assert len(write_calls) >= 2, f"Expected multiple flushes, got {len(write_calls)}: {write_calls}"
        assert total == 12, f"Expected 12 total bars, got {total}"


if __name__ == "__main__":
    pytest.main([__file__, "-v"])
