"""Tests for kintsugi day-recompute _repair() for gaps and overlaps.

Validates the day-recompute strategy: when any Stathera anomaly (gap or
overlap) is detected, delete all bars for the affected Ouroboros day(s)
and reprocess from scratch via Ariadne.
"""

from __future__ import annotations

from datetime import UTC, datetime
from unittest.mock import MagicMock, patch

import pytest
from opendeviationbar.kintsugi import Shard, _repair


def _make_shard(
    *,
    symbol: str = "BTCUSDT",
    threshold_decimal_bps: int = 250,
    gap_start_ms: int = 1_709_251_200_000,  # 2024-03-01 00:00:00 UTC
    gap_end_ms: int = 1_709_337_600_000,  # 2024-03-02 00:00:00 UTC
    anchor_last_tid: int | None = 3_000_000_000,
    resume_first_tid: int | None = 3_000_100_000,
    missing_trade_count: int = 99_999,
    is_overlap: bool = False,
) -> Shard:
    """Create a Shard for testing _repair()."""
    return Shard(
        symbol=symbol,
        threshold_decimal_bps=threshold_decimal_bps,
        gap_start_ms=gap_start_ms,
        gap_end_ms=gap_end_ms,
        gap_hours=(gap_end_ms - gap_start_ms) / 3_600_000,
        anchor_last_tid=anchor_last_tid,
        resume_first_tid=resume_first_tid,
        missing_trade_count=missing_trade_count,
        is_overlap=is_overlap,
    )


@pytest.mark.skip(reason="Tests updated for Vision-first day-recompute; updating assertions in Issue #254 Phase 4")
class TestRepairDayRecompute:
    """Test _repair() day-recompute strategy."""

    @patch("opendeviationbar.kintsugi.repair._notify_repair_failure")
    def test_raises_on_no_anchor_tid(self, mock_notify: MagicMock) -> None:
        """_repair() raises RuntimeError when anchor_last_tid is None."""
        shard = _make_shard(anchor_last_tid=None)
        with pytest.raises(RuntimeError, match="no anchor_last_tid"):
            _repair(shard)
        mock_notify.assert_called_once()

    @patch("opendeviationbar.backfill._fetch_and_process_gap")
    @patch("opendeviationbar.clickhouse.OpenDeviationBarCache")
    @patch("opendeviationbar.ouroboros.get_operational_ouroboros_mode", return_value="aion")
    def test_deletes_day_range(
        self,
        mock_mode: MagicMock,
        mock_cache_cls: MagicMock,
        mock_fetch: MagicMock,
    ) -> None:
        """_repair() calls invalidate with correct day boundaries."""
        mock_cache = MagicMock()
        mock_cache_cls.return_value.__enter__ = MagicMock(return_value=mock_cache)
        mock_cache_cls.return_value.__exit__ = MagicMock(return_value=False)
        # Sequential queries: (1) mutation count=0, (2) anchor trade ID
        mutation_result = MagicMock()
        mutation_result.result_rows = [[0]]
        anchor_result = MagicMock()
        anchor_result.result_rows = [[2_999_999_999]]
        mock_cache.client.query.side_effect = [mutation_result, anchor_result]
        mock_fetch.return_value = 42

        shard = _make_shard()
        result = _repair(shard)

        # Verify invalidate was called
        mock_cache.invalidate_open_deviation_bars_by_range.assert_called_once()
        call_kwargs = mock_cache.invalidate_open_deviation_bars_by_range.call_args
        args = call_kwargs[0] if call_kwargs[0] else ()
        kwargs = call_kwargs[1] if call_kwargs[1] else {}

        # Should be full-day boundaries
        assert args[0] == "BTCUSDT"
        assert args[1] == 250
        assert kwargs["ouroboros_mode"] == "aion"

        # start_timestamp_ms should be 2024-03-01 00:00:00 UTC
        expected_start = int(
            datetime(2024, 3, 1, tzinfo=UTC).timestamp() * 1000
        )
        assert kwargs["start_timestamp_ms"] == expected_start

        assert result == 42

    @patch("opendeviationbar.backfill._fetch_and_process_gap")
    @patch("opendeviationbar.clickhouse.OpenDeviationBarCache")
    @patch("opendeviationbar.ouroboros.get_operational_ouroboros_mode", return_value="aion")
    def test_uses_ch_anchor_when_available(
        self,
        mock_mode: MagicMock,
        mock_cache_cls: MagicMock,
        mock_fetch: MagicMock,
    ) -> None:
        """_repair() uses ClickHouse anchor over shard fallback."""
        mock_cache = MagicMock()
        mock_cache_cls.return_value.__enter__ = MagicMock(return_value=mock_cache)
        mock_cache_cls.return_value.__exit__ = MagicMock(return_value=False)
        # Sequential queries: (1) mutation count=0, (2) CH anchor
        ch_anchor = 2_999_500_000
        mutation_result = MagicMock()
        mutation_result.result_rows = [[0]]
        anchor_result = MagicMock()
        anchor_result.result_rows = [[ch_anchor]]
        mock_cache.client.query.side_effect = [mutation_result, anchor_result]
        mock_fetch.return_value = 10

        shard = _make_shard(anchor_last_tid=3_000_000_000)
        _repair(shard)

        # _fetch_and_process_gap should use CH anchor, not shard anchor
        call_kwargs = mock_fetch.call_args[1]
        assert call_kwargs["ariadne_anchor_override"] == ch_anchor

    @patch("opendeviationbar.backfill._fetch_and_process_gap")
    @patch("opendeviationbar.clickhouse.OpenDeviationBarCache")
    @patch("opendeviationbar.ouroboros.get_operational_ouroboros_mode", return_value="aion")
    def test_falls_back_to_shard_anchor(
        self,
        mock_mode: MagicMock,
        mock_cache_cls: MagicMock,
        mock_fetch: MagicMock,
    ) -> None:
        """_repair() falls back to shard.anchor_last_tid when no CH bar found."""
        mock_cache = MagicMock()
        mock_cache_cls.return_value.__enter__ = MagicMock(return_value=mock_cache)
        mock_cache_cls.return_value.__exit__ = MagicMock(return_value=False)
        # Sequential queries: (1) mutation count=0, (2) no bars before range
        mutation_result = MagicMock()
        mutation_result.result_rows = [[0]]
        anchor_result = MagicMock()
        anchor_result.result_rows = []
        mock_cache.client.query.side_effect = [mutation_result, anchor_result]
        mock_fetch.return_value = 5

        shard_anchor = 3_000_000_000
        shard = _make_shard(anchor_last_tid=shard_anchor)
        _repair(shard)

        call_kwargs = mock_fetch.call_args[1]
        assert call_kwargs["ariadne_anchor_override"] == shard_anchor

    @patch("opendeviationbar.backfill._fetch_and_process_gap")
    @patch("opendeviationbar.clickhouse.OpenDeviationBarCache")
    @patch("opendeviationbar.ouroboros.get_operational_ouroboros_mode", return_value="aion")
    def test_passes_warn_overlap_strategy(
        self,
        mock_mode: MagicMock,
        mock_cache_cls: MagicMock,
        mock_fetch: MagicMock,
    ) -> None:
        """_repair() uses overlap_strategy='warn' (day already deleted, L3 handles stragglers)."""
        mock_cache = MagicMock()
        mock_cache_cls.return_value.__enter__ = MagicMock(return_value=mock_cache)
        mock_cache_cls.return_value.__exit__ = MagicMock(return_value=False)
        mutation_result = MagicMock()
        mutation_result.result_rows = [[0]]
        anchor_result = MagicMock()
        anchor_result.result_rows = [[2_999_999_999]]
        mock_cache.client.query.side_effect = [mutation_result, anchor_result]
        mock_fetch.return_value = 0

        _repair(_make_shard())

        call_kwargs = mock_fetch.call_args[1]
        assert call_kwargs["overlap_strategy"] == "warn"

    @patch("opendeviationbar.backfill._fetch_and_process_gap")
    @patch("opendeviationbar.clickhouse.OpenDeviationBarCache")
    @patch("opendeviationbar.ouroboros.get_operational_ouroboros_mode", return_value="aion")
    def test_multi_day_gap_spans_full_range(
        self,
        mock_mode: MagicMock,
        mock_cache_cls: MagicMock,
        mock_fetch: MagicMock,
    ) -> None:
        """_repair() handles gaps spanning multiple days."""
        mock_cache = MagicMock()
        mock_cache_cls.return_value.__enter__ = MagicMock(return_value=mock_cache)
        mock_cache_cls.return_value.__exit__ = MagicMock(return_value=False)
        mutation_result = MagicMock()
        mutation_result.result_rows = [[0]]
        anchor_result = MagicMock()
        anchor_result.result_rows = [[1_000_000]]
        mock_cache.client.query.side_effect = [mutation_result, anchor_result]
        mock_fetch.return_value = 100

        # Gap spans 3 days: March 1-3
        shard = _make_shard(
            gap_start_ms=1_709_251_200_000,  # 2024-03-01 00:00:00
            gap_end_ms=1_709_424_000_000,  # 2024-03-03 00:00:00
        )
        _repair(shard)

        # Verify day boundaries span full range
        inv_kwargs = mock_cache.invalidate_open_deviation_bars_by_range.call_args[1]
        start_ms = inv_kwargs["start_timestamp_ms"]
        end_ms = inv_kwargs["end_timestamp_ms"]

        # start should be 2024-03-01 00:00:00
        assert start_ms == int(datetime(2024, 3, 1, tzinfo=UTC).timestamp() * 1000)
        # end should be 2024-03-03 23:59:59.999
        assert end_ms == int(
            datetime(2024, 3, 3, 23, 59, 59, 999000, tzinfo=UTC).timestamp() * 1000
        )

        # _fetch_and_process_gap should get same day boundaries
        fetch_kwargs = mock_fetch.call_args[1]
        assert fetch_kwargs["start_ms"] == start_ms
        assert fetch_kwargs["end_ms"] == end_ms


class TestAionModeRepair:
    """Test that Aion-mode symbols skip the today-guard in _repair()."""

    @patch("opendeviationbar.kintsugi.repair._repair_aion", return_value=42)
    @patch("opendeviationbar.kintsugi.repair._get_mode_for_symbol", return_value="aion")
    def test_aion_skips_today_guard(
        self,
        mock_mode: MagicMock,
        mock_repair_aion: MagicMock,
    ) -> None:
        """_repair() for aion symbol does not defer even when gap touches today."""
        # Make a shard whose gap_end is today (would be deferred in day mode)
        today_ms = int(datetime.now(tz=UTC).timestamp() * 1_000_000)
        shard = _make_shard(
            gap_start_ms=today_ms - 3_600_000_000,  # 1 hour ago
            gap_end_ms=today_ms,
        )
        result = _repair(shard)

        # Should NOT return -1 (deferred sentinel). Instead dispatches to _repair_aion.
        assert result == 42
        mock_repair_aion.assert_called_once()


@pytest.mark.skip(reason="Tests updated for Vision-first day-recompute; updating assertions in Issue #254 Phase 4")
class TestOverlapDayRecompute:
    """Test that overlaps (tid_delta < 0) also trigger day-recompute."""

    @patch("opendeviationbar.backfill._fetch_and_process_gap")
    @patch("opendeviationbar.clickhouse.OpenDeviationBarCache")
    @patch("opendeviationbar.ouroboros.get_operational_ouroboros_mode", return_value="aion")
    def test_overlap_shard_triggers_day_recompute(
        self,
        mock_mode: MagicMock,
        mock_cache_cls: MagicMock,
        mock_fetch: MagicMock,
    ) -> None:
        """_repair() treats overlap shards identically to gap shards."""
        mock_cache = MagicMock()
        mock_cache_cls.return_value.__enter__ = MagicMock(return_value=mock_cache)
        mock_cache_cls.return_value.__exit__ = MagicMock(return_value=False)
        mutation_result = MagicMock()
        mutation_result.result_rows = [[0]]
        anchor_result = MagicMock()
        anchor_result.result_rows = [[2_999_999_999]]
        mock_cache.client.query.side_effect = [mutation_result, anchor_result]
        mock_fetch.return_value = 30

        shard = _make_shard(is_overlap=True, missing_trade_count=500)
        result = _repair(shard)

        # Same day-recompute path: DELETE + Ariadne reprocess
        mock_cache.invalidate_open_deviation_bars_by_range.assert_called_once()
        assert result == 30
        assert mock_fetch.call_args[1]["overlap_strategy"] == "warn"

    def test_shard_is_overlap_flag(self) -> None:
        """Shard.is_overlap distinguishes gaps from overlaps."""
        gap = _make_shard(is_overlap=False)
        overlap = _make_shard(is_overlap=True)

        assert gap.is_overlap is False
        assert overlap.is_overlap is True

    @patch("opendeviationbar.kintsugi.discover_shards")
    def test_discover_shards_returns_overlaps(
        self, mock_discover: MagicMock
    ) -> None:
        """discover_shards() returns both gap and overlap shards."""
        gap_shard = _make_shard(is_overlap=False)
        overlap_shard = _make_shard(is_overlap=True, missing_trade_count=200)
        mock_discover.return_value = [gap_shard, overlap_shard]

        from opendeviationbar.kintsugi import discover_shards

        shards = discover_shards()
        assert len(shards) == 2
        assert not shards[0].is_overlap
        assert shards[1].is_overlap
