"""Tests for populate_cache_multi_threshold and helpers.

Covers:
1. Single-threshold delegation to populate_cache_resumable
2. Per-threshold error isolation in ThreadPoolExecutor fan-out
3. Configurable worker count via n_workers kwarg
4. Empty-tick fast-path in _process_one_threshold_day
5. populate_cache_resumable list-delegation to multi-threshold
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import polars as pl
import pytest

# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------

BASE_TS_MS = 1_704_067_200_000  # 2024-01-01T00:00:00Z
DAY_MS = 86_400_000


def _make_ticks(n: int = 5) -> pl.DataFrame:
    """Minimal tick DataFrame matching _select_trade_columns output."""
    return pl.DataFrame(
        {
            "timestamp": [BASE_TS_MS + i * 1000 for i in range(n)],
            "price": [50_000.0 + i * 10.0 for i in range(n)],
            "quantity": [1.0] * n,
            "is_buyer_maker": [i % 2 == 0 for i in range(n)],
            "agg_trade_id": [1000 + i for i in range(n)],
            "first_trade_id": [2000 + i for i in range(n)],
            "last_trade_id": [2000 + i for i in range(n)],
        }
    )


# ---------------------------------------------------------------------------
# _process_one_threshold_day
# ---------------------------------------------------------------------------


class TestProcessOneThresholdDay:
    """Unit tests for _process_one_threshold_day."""

    def test_empty_ticks_returns_zero(self):
        from opendeviationbar.checkpoint import _process_one_threshold_day

        ctx = MagicMock()
        proc = MagicMock()
        result = _process_one_threshold_day(None, ctx, proc, MagicMock())
        assert result == (0, proc)

    def test_empty_dataframe_returns_zero(self):
        from opendeviationbar.checkpoint import _process_one_threshold_day

        empty = pl.DataFrame(
            {
                "timestamp": [],
                "price": [],
                "quantity": [],
                "is_buyer_maker": [],
                "agg_trade_id": [],
                "first_trade_id": [],
                "last_trade_id": [],
            },
            schema={
                "timestamp": pl.Int64,
                "price": pl.Float64,
                "quantity": pl.Float64,
                "is_buyer_maker": pl.Boolean,
                "agg_trade_id": pl.Int64,
                "first_trade_id": pl.Int64,
                "last_trade_id": pl.Int64,
            },
        )
        ctx = MagicMock()
        proc = MagicMock()
        result = _process_one_threshold_day(empty, ctx, proc, MagicMock())
        assert result == (0, proc)


# ---------------------------------------------------------------------------
# _process_day_multi_threshold — error isolation
# ---------------------------------------------------------------------------


class TestProcessDayMultiThresholdErrorIsolation:
    """Verify that one threshold failing does not abort other thresholds."""

    def _make_ctx(self, threshold: int) -> MagicMock:
        ctx = MagicMock()
        ctx.symbol = "BTCUSDT"
        ctx.threshold_decimal_bps = threshold
        ctx.ouroboros_mode = "day"
        ctx.market = "spot"
        ctx.include_microstructure = False
        ctx.inter_bar_lookback_count = 0
        ctx.inter_bar_lookback_bars = None
        return ctx

    def test_one_failure_does_not_abort_others(self):
        """When threshold 500 raises, thresholds 250/750 still return results."""
        from opendeviationbar.checkpoint import _process_day_multi_threshold

        ctxs = {250: self._make_ctx(250), 500: self._make_ctx(500), 750: self._make_ctx(750)}
        processors = {250: None, 500: None, 750: None}
        ticks = _make_ticks()

        call_counts: dict[int, int] = {}

        def fake_process_one(ticks_df, ctx, proc, fcw):
            t = ctx.threshold_decimal_bps
            call_counts[t] = call_counts.get(t, 0) + 1
            if t == 500:
                msg = "simulated ClickHouse failure"
                raise RuntimeError(msg)
            return (42, MagicMock())

        with patch(
            "opendeviationbar.checkpoint._process_one_threshold_day",
            side_effect=fake_process_one,
        ):
            # Patch storage on the any_ctx
            any_ctx = next(iter(ctxs.values()))
            any_ctx.storage.has_ticks.return_value = True
            any_ctx.storage.read_ticks.return_value = ticks
            for ctx in ctxs.values():
                ctx.storage = any_ctx.storage

            results = _process_day_multi_threshold(
                "2024-01-01", ctxs, processors, MagicMock()
            )

        # All three thresholds must appear in results
        assert set(results.keys()) == {250, 500, 750}

        # 250 and 750 succeeded → bars_written = 42
        assert results[250][0] == 42
        assert results[750][0] == 42

        # 500 failed → bars_written = 0, processor unchanged (None)
        assert results[500][0] == 0
        assert results[500][1] is None

    def test_all_succeed(self):
        """Happy path: all thresholds return their bar counts."""
        from opendeviationbar.checkpoint import _process_day_multi_threshold

        thresholds = [250, 500, 750, 1000]
        ctxs = {t: self._make_ctx(t) for t in thresholds}
        processors = {t: None for t in thresholds}
        ticks = _make_ticks()

        def fake_process_one(ticks_df, ctx, proc, fcw):
            return (ctx.threshold_decimal_bps // 100, MagicMock())

        any_ctx = next(iter(ctxs.values()))
        any_ctx.storage.has_ticks.return_value = True
        any_ctx.storage.read_ticks.return_value = ticks
        for ctx in ctxs.values():
            ctx.storage = any_ctx.storage

        with patch(
            "opendeviationbar.checkpoint._process_one_threshold_day",
            side_effect=fake_process_one,
        ):
            results = _process_day_multi_threshold(
                "2024-01-01", ctxs, processors, MagicMock()
            )

        assert set(results.keys()) == set(thresholds)
        assert results[250][0] == 2   # 250 // 100
        assert results[500][0] == 5
        assert results[750][0] == 7
        assert results[1000][0] == 10

    def test_configurable_workers(self):
        """n_workers=1 forces sequential execution; results still complete."""
        from opendeviationbar.checkpoint import _process_day_multi_threshold

        thresholds = [250, 500]
        ctxs = {t: self._make_ctx(t) for t in thresholds}
        processors = {t: None for t in thresholds}
        ticks = _make_ticks()

        def fake_process_one(ticks_df, ctx, proc, fcw):
            return (1, None)

        any_ctx = next(iter(ctxs.values()))
        any_ctx.storage.has_ticks.return_value = True
        any_ctx.storage.read_ticks.return_value = ticks
        for ctx in ctxs.values():
            ctx.storage = any_ctx.storage

        with patch(
            "opendeviationbar.checkpoint._process_one_threshold_day",
            side_effect=fake_process_one,
        ):
            results = _process_day_multi_threshold(
                "2024-01-01", ctxs, processors, MagicMock(), n_workers=1
            )

        assert results[250][0] == 1
        assert results[500][0] == 1


# ---------------------------------------------------------------------------
# populate_cache_multi_threshold — single-threshold delegation
# ---------------------------------------------------------------------------


class TestPopulateCacheMultiThresholdDelegation:
    """Verify delegation and validation paths."""

    def test_single_threshold_delegates_to_resumable(self):
        """len(thresholds)==1 must call populate_cache_resumable, not multi path."""
        with (
            patch(
                "opendeviationbar.checkpoint.populate_cache_resumable",
                return_value=99,
            ) as mock_resumable,
            patch("opendeviationbar.symbol_registry.validate_symbol_registered"),
            patch(
                "opendeviationbar.symbol_registry.validate_and_clamp_start_date",
                side_effect=lambda _sym, d: d,
            ),
            patch(
                "opendeviationbar.symbol_registry.resolve_market", return_value="spot"
            ),
            patch(
                "opendeviationbar.threshold.resolve_and_validate_threshold",
                side_effect=lambda _sym, t: t,
            ),
        ):
            from opendeviationbar.checkpoint import populate_cache_multi_threshold

            result = populate_cache_multi_threshold(
                "BTCUSDT", "2024-01-01", "2024-01-31", [250]
            )

        assert result == {250: 99}
        mock_resumable.assert_called_once()

    def test_empty_thresholds_raises(self):
        from opendeviationbar.checkpoint import populate_cache_multi_threshold

        with pytest.raises(ValueError, match="non-empty"):
            populate_cache_multi_threshold("BTCUSDT", "2024-01-01", "2024-01-31", [])

    def test_list_delegation_in_resumable(self):
        """populate_cache_resumable([250, 500]) delegates to multi-threshold."""
        with (
            patch(
                "opendeviationbar.checkpoint.populate_cache_multi_threshold",
                return_value={250: 10, 500: 5},
            ) as mock_multi,
            patch("opendeviationbar.symbol_registry.validate_symbol_registered"),
            patch(
                "opendeviationbar.symbol_registry.validate_and_clamp_start_date",
                side_effect=lambda _sym, d: d,
            ),
            patch(
                "opendeviationbar.threshold.resolve_and_validate_threshold",
                side_effect=lambda _sym, t: t,
            ),
        ):
            from opendeviationbar.checkpoint import populate_cache_resumable

            result = populate_cache_resumable(
                "BTCUSDT",
                "2024-01-01",
                "2024-01-31",
                threshold_decimal_bps=[250, 500],
            )

        # Returns sum
        assert result == 15
        mock_multi.assert_called_once()
