"""Tests for exchange gap classification and permanent dismissal.

When kintsugi's _repair_aion() processes checksum-verified Parquet and finds
0 trades in a TID range, the gap is classified as an exchange gap (Binance
never issued those trade IDs) and permanently dismissed from future repair.
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import polars as pl
import pytest

from opendeviationbar.kintsugi.discovery import KintsugiResult, Shard


def _make_aion_shard(
    *,
    symbol: str = "BTCUSDT",
    threshold_decimal_bps: int = 250,
    anchor_last_tid: int = 3_000_000_000,
    resume_first_tid: int = 3_000_000_050,
    gap_start_us: int = 1_709_251_200_000_000,  # 2024-03-01 00:00:00 UTC in us
    gap_end_us: int = 1_709_337_600_000_000,  # 2024-03-02 00:00:00 UTC in us
) -> Shard:
    """Create an Aion shard for exchange gap testing."""
    return Shard(
        symbol=symbol,
        threshold_decimal_bps=threshold_decimal_bps,
        gap_start_ms=gap_start_us,
        gap_end_ms=gap_end_us,
        gap_hours=(gap_end_us - gap_start_us) / 3_600_000_000.0,
        anchor_last_tid=anchor_last_tid,
        resume_first_tid=resume_first_tid,
        missing_trade_count=abs(resume_first_tid - anchor_last_tid) - 1,
    )


def _make_trades_df(tid_start: int = 100, n: int = 5) -> pl.DataFrame:
    """Create a small trades DataFrame for Parquet reads."""
    return pl.DataFrame(
        {
            "agg_trade_id": list(range(tid_start, tid_start + n)),
            "price": [42000.0] * n,
            "quantity": [1.0] * n,
            "transact_time": [1_709_251_200_000 + i * 1000 for i in range(n)],
            "is_buyer_maker": [True] * n,
        }
    )


class TestExchangeGapClassification:
    """EXGAP-01: Exchange gap classification when Parquet is checksum-verified."""

    @patch("opendeviationbar.checksum_registry.ChecksumRegistry")
    @patch("opendeviationbar.kintsugi.repair.TickStorage")
    @patch("opendeviationbar.kintsugi.repair._get_mode_for_symbol", return_value="aion")
    @patch("opendeviationbar.kintsugi.repair._select_trade_columns", side_effect=lambda df: df)
    @patch("opendeviationbar.threshold.get_min_threshold_for_symbol", return_value=100)
    def test_exchange_gap_when_all_dates_verified(
        self,
        _mock_min_thr: MagicMock,
        _mock_select: MagicMock,
        _mock_mode: MagicMock,
        mock_storage_cls: MagicMock,
        mock_registry_cls: MagicMock,
    ) -> None:
        """When _repair_aion() gets 0 trades from verified Parquet, result is exchange_gap."""
        shard = _make_aion_shard()

        # Mock TickStorage: has ticks, returns trades outside TID range (filtered to empty)
        mock_storage = MagicMock()
        mock_storage_cls.return_value = mock_storage
        mock_storage.has_ticks.return_value = True
        # Trades with TIDs far below anchor_last_tid -- will be filtered out
        mock_storage.read_ticks.return_value = _make_trades_df(tid_start=1, n=5)

        # Mock ChecksumRegistry: all dates verified
        mock_registry = MagicMock()
        mock_registry_cls.return_value = mock_registry
        mock_registry.has_verified.return_value = True

        from opendeviationbar.kintsugi.repair import repair_shard

        result = repair_shard(shard)

        assert isinstance(result, KintsugiResult)
        assert result.is_exchange_gap is True
        assert result.bars_written == 0
        assert result.healed is False
        assert result.error is None

    @patch("opendeviationbar.checksum_registry.ChecksumRegistry")
    @patch("opendeviationbar.kintsugi.repair.TickStorage")
    @patch("opendeviationbar.kintsugi.repair._get_mode_for_symbol", return_value="aion")
    @patch("opendeviationbar.kintsugi.repair._select_trade_columns", side_effect=lambda df: df)
    @patch("opendeviationbar.threshold.get_min_threshold_for_symbol", return_value=100)
    def test_not_exchange_gap_when_unverified_parquet(
        self,
        _mock_min_thr: MagicMock,
        _mock_select: MagicMock,
        _mock_mode: MagicMock,
        mock_storage_cls: MagicMock,
        mock_registry_cls: MagicMock,
    ) -> None:
        """EXGAP-02: Unverified Parquet + 0 trades = normal clean, NOT exchange_gap."""
        shard = _make_aion_shard()

        mock_storage = MagicMock()
        mock_storage_cls.return_value = mock_storage
        mock_storage.has_ticks.return_value = True
        mock_storage.read_ticks.return_value = _make_trades_df(tid_start=1, n=5)

        # NOT verified
        mock_registry = MagicMock()
        mock_registry_cls.return_value = mock_registry
        mock_registry.has_verified.return_value = False

        from opendeviationbar.kintsugi.repair import repair_shard

        result = repair_shard(shard)

        assert isinstance(result, KintsugiResult)
        assert result.is_exchange_gap is False
        assert result.bars_written == 0

    @patch("opendeviationbar.checksum_registry.ChecksumRegistry")
    @patch("opendeviationbar.kintsugi.repair.TickStorage")
    @patch("opendeviationbar.kintsugi.repair._get_mode_for_symbol", return_value="aion")
    @patch("opendeviationbar.kintsugi.repair._select_trade_columns", side_effect=lambda df: df)
    @patch("opendeviationbar.threshold.get_min_threshold_for_symbol", return_value=100)
    def test_multi_day_partial_verification_not_exchange_gap(
        self,
        _mock_min_thr: MagicMock,
        _mock_select: MagicMock,
        _mock_mode: MagicMock,
        mock_storage_cls: MagicMock,
        mock_registry_cls: MagicMock,
    ) -> None:
        """EXGAP-02 multi-day: 3 dates but only 2 verified = NOT exchange_gap."""
        # Shard spanning 3 days (2024-03-01 to 2024-03-04)
        start_us = 1_709_251_200_000_000  # 2024-03-01 00:00 UTC
        end_us = 1_709_510_400_000_000  # 2024-03-04 00:00 UTC
        shard = _make_aion_shard(gap_start_us=start_us, gap_end_us=end_us)

        mock_storage = MagicMock()
        mock_storage_cls.return_value = mock_storage
        mock_storage.has_ticks.return_value = True
        mock_storage.read_ticks.return_value = _make_trades_df(tid_start=1, n=5)

        # Only 2 of 4 dates verified (partial)
        mock_registry = MagicMock()
        mock_registry_cls.return_value = mock_registry

        def _partial_verify(symbol: str, date: str) -> bool:
            return date != "2024-03-03"  # Third date NOT verified

        mock_registry.has_verified.side_effect = _partial_verify

        from opendeviationbar.kintsugi.repair import repair_shard

        result = repair_shard(shard)

        assert isinstance(result, KintsugiResult)
        assert result.is_exchange_gap is False


class TestExchangeGapTelemetry:
    """DISMISS-02: _log_repair() writes result='exchange_gap' for exchange gaps."""

    def test_log_repair_exchange_gap_result(self) -> None:
        """_log_repair() uses result='exchange_gap' when is_exchange_gap=True."""
        shard = _make_aion_shard()
        result = KintsugiResult(
            shard=shard,
            healed=False,
            bars_written=0,
            duration_seconds=1.5,
            had_anchor_tid=True,
            ouroboros_splits=1,
            error=None,
            source="parquet",
            is_exchange_gap=True,
        )

        mock_cache = MagicMock()

        with (
            patch(
                "opendeviationbar.kintsugi.telemetry._get_worker_cache",
                return_value=mock_cache,
            ),
            patch(
                "opendeviationbar.kintsugi._helpers._get_mode_for_symbol",
                return_value="aion",
            ),
        ):
            from opendeviationbar.kintsugi.telemetry import _log_repair

            _log_repair(result)

        # Verify the INSERT was called with result='exchange_gap'
        mock_cache.client.command.assert_called_once()
        call_args = mock_cache.client.command.call_args
        params = call_args[1].get("parameters") or call_args.kwargs.get("parameters")
        assert params["result"] == "exchange_gap"


class TestPermanentDismiss:
    """DISMISS-01: _get_dismissed_set() returns exchange_gap with no time window."""

    def test_permanent_dismiss_exchange_gap(self) -> None:
        """Exchange gap shards are permanently dismissed (no 30-day window)."""
        mock_cache = MagicMock()

        # First call: _aletheia_purge count query (returns 0)
        purge_count_result = MagicMock()
        purge_count_result.result_rows = [[0]]

        # Second call: clean 30-day query
        clean_result = MagicMock()
        clean_result.result_rows = [
            ("ETHUSDT", 250, 1_709_251_200_000_000),
        ]

        # Third call: exchange_gap permanent query
        exchange_gap_result = MagicMock()
        exchange_gap_result.result_rows = [
            ("BTCUSDT", 250, 1_700_000_000_000_000),
        ]

        mock_cache.client.query.side_effect = [
            purge_count_result,
            clean_result,
            exchange_gap_result,
        ]

        with patch(
            "opendeviationbar.clickhouse.OpenDeviationBarCache"
        ) as mock_cache_cls:
            mock_cache_cls.return_value.__enter__ = MagicMock(return_value=mock_cache)
            mock_cache_cls.return_value.__exit__ = MagicMock(return_value=False)

            from opendeviationbar.kintsugi.discovery import _get_dismissed_set

            dismissed = _get_dismissed_set()

        # Should contain BOTH the clean shard and the exchange_gap shard
        assert ("ETHUSDT", 250, 1_709_251_200_000_000) in dismissed
        assert ("BTCUSDT", 250, 1_700_000_000_000_000) in dismissed

    def test_exchange_gap_query_has_no_time_window(self) -> None:
        """The exchange_gap query must NOT have a time-window filter (permanent)."""
        mock_cache = MagicMock()

        purge_count_result = MagicMock()
        purge_count_result.result_rows = [[0]]

        clean_result = MagicMock()
        clean_result.result_rows = []

        exchange_gap_result = MagicMock()
        exchange_gap_result.result_rows = []

        mock_cache.client.query.side_effect = [
            purge_count_result,
            clean_result,
            exchange_gap_result,
        ]

        with patch(
            "opendeviationbar.clickhouse.OpenDeviationBarCache"
        ) as mock_cache_cls:
            mock_cache_cls.return_value.__enter__ = MagicMock(return_value=mock_cache)
            mock_cache_cls.return_value.__exit__ = MagicMock(return_value=False)

            from opendeviationbar.kintsugi.discovery import _get_dismissed_set

            _get_dismissed_set()

        # The third query (exchange_gap) should NOT contain "INTERVAL" or time window
        queries = [call.args[0] for call in mock_cache.client.query.call_args_list]
        exchange_gap_query = queries[2]
        assert "exchange_gap" in exchange_gap_query
        assert "INTERVAL" not in exchange_gap_query  # No time window = permanent
