"""Tests for kintsugi shard ordering and candidate selection (#157).

Updated for recency-first sort (Issue #164): most recent gaps repaired first.
BTCUSDT gets bandwidth priority among same-recency.

Priority system removed in v12.56 — all repairs use backfill_gap()
(Vision-first + REST fallback).
"""

from __future__ import annotations

import time
from unittest.mock import patch

from opendeviationbar.kintsugi import (
    _PRIORITY_SYMBOLS,
    Shard,
    _select_repair_candidates,
    _shard_sort_key,
)


def _make_shard(
    *,
    gap_end_ms: int = 0,
    symbol: str = "BTCUSDT",
    threshold_decimal_bps: int = 250,
    gap_hours: float = 100.0,
) -> Shard:
    """Create a minimal Shard for testing."""
    return Shard(
        symbol=symbol,
        threshold_decimal_bps=threshold_decimal_bps,
        gap_start_ms=0,
        gap_end_ms=gap_end_ms,
        gap_hours=gap_hours,
    )


# ---------------------------------------------------------------------------
# _shard_sort_key tests (recency-first)
# ---------------------------------------------------------------------------


class TestShardSortKey:
    def test_recency_first(self) -> None:
        """Most recent gap_end sorts first."""
        s_2022 = _make_shard(gap_end_ms=1_641_024_000_000)  # 2022-01-01
        s_2024 = _make_shard(gap_end_ms=1_717_200_000_000)  # ~2024-06-01
        s_2025 = _make_shard(gap_end_ms=1_732_924_800_000)  # ~2025-11-30

        shards = [s_2022, s_2024, s_2025]
        shards.sort(key=_shard_sort_key)

        assert shards == [s_2025, s_2024, s_2022]

    def test_btcusdt_priority_among_same_recency(self) -> None:
        """BTCUSDT sorts before other symbols at the same gap_end time."""
        same_end = 1_772_000_000_000  # 2026-02-26
        s_btc = _make_shard(symbol="BTCUSDT", gap_end_ms=same_end)
        s_eth = _make_shard(symbol="ETHUSDT", gap_end_ms=same_end)
        s_doge = _make_shard(symbol="DOGEUSDT", gap_end_ms=same_end)

        shards = [s_eth, s_doge, s_btc]
        shards.sort(key=_shard_sort_key)

        assert shards[0] is s_btc  # BTCUSDT always first

    def test_btcusdt_in_priority_symbols(self) -> None:
        """BTCUSDT is in the priority symbols list."""
        assert "BTCUSDT" in _PRIORITY_SYMBOLS

    def test_sort_key_tuple_structure(self) -> None:
        """Sort key returns a 2-tuple of ints."""
        shard = _make_shard(gap_end_ms=1_700_000_000_000)
        key = _shard_sort_key(shard)

        assert isinstance(key, tuple)
        assert len(key) == 2
        assert all(isinstance(v, int) for v in key)

    def test_staleness_naturally_most_recent(self) -> None:
        """Shards with gap_end=now sort first naturally (recency is king)."""
        now_ms = int(time.time() * 1000)
        s_now = _make_shard(gap_end_ms=now_ms)
        s_recent = _make_shard(gap_end_ms=now_ms - 3600_000)  # 1 hour ago
        s_ancient = _make_shard(gap_end_ms=1_672_531_200_000)  # 2023-01-01

        shards = [s_ancient, s_recent, s_now]
        shards.sort(key=_shard_sort_key)

        assert shards[0] is s_now
        assert shards[1] is s_recent
        assert shards[2] is s_ancient

    def test_real_world_scenario(self) -> None:
        """Reproduce the actual bug: 2023 gaps processed before 2026 gap."""
        # This was the actual failure mode on bigblack
        gap_2023_jan = _make_shard(
            gap_end_ms=1_673_168_923_000,  # 2023-01-08
            symbol="BTCUSDT",
            gap_hours=33.78,
        )
        gap_2023_jun = _make_shard(
            gap_end_ms=1_686_333_053_000,  # 2023-06-09
            symbol="BTCUSDT",
            gap_hours=36.24,
        )
        gap_2026_recent = _make_shard(
            gap_end_ms=1_772_532_392_000,  # 2026-03-01
            symbol="BTCUSDT",
            gap_hours=1884.0,
        )

        shards = [gap_2023_jan, gap_2023_jun, gap_2026_recent]
        shards.sort(key=_shard_sort_key)

        # The 2026 gap MUST sort first — this was the actual bug
        assert shards[0] is gap_2026_recent
        assert shards[1] is gap_2023_jun
        assert shards[2] is gap_2023_jan


# ---------------------------------------------------------------------------
# _select_repair_candidates tests
# ---------------------------------------------------------------------------


class TestSelectRepairCandidates:
    @patch("opendeviationbar.kintsugi._get_dismissed_set")
    @patch("opendeviationbar.kintsugi._get_rate_limited_set")
    def test_budget_consumed_only_on_acceptance(self, mock_rl: object, mock_dm: object) -> None:
        """A rate-limited shard does NOT waste a max_repairs slot."""
        s1 = _make_shard(gap_end_ms=1_700_000_000_000, symbol="AAA")
        s2 = _make_shard(gap_end_ms=1_600_000_000_000, symbol="BBB")

        # s1 rate-limited, s2 not
        mock_dm.return_value = set()  # type: ignore[attr-defined]
        mock_rl.return_value = {(s1.symbol, s1.threshold_decimal_bps, s1.gap_start_ms)}  # type: ignore[attr-defined]

        result = _select_repair_candidates([s1, s2], max_repairs=1)

        assert len(result) == 1
        assert result[0] is s2

    @patch("opendeviationbar.kintsugi._get_dismissed_set")
    @patch("opendeviationbar.kintsugi._get_rate_limited_set")
    def test_budget_caps_selection(self, mock_rl: object, mock_dm: object) -> None:
        """Only max_repairs shards are selected."""
        shards = [
            _make_shard(gap_end_ms=1_700_000_000_000 + i * 1000)
            for i in range(3)
        ]

        mock_dm.return_value = set()  # type: ignore[attr-defined]
        mock_rl.return_value = set()  # type: ignore[attr-defined]

        result = _select_repair_candidates(shards, max_repairs=1)

        assert len(result) == 1

    @patch("opendeviationbar.kintsugi._get_dismissed_set")
    @patch("opendeviationbar.kintsugi._get_rate_limited_set")
    def test_rate_limited_shards_excluded(self, mock_rl: object, mock_dm: object) -> None:
        """Rate-limited shards are excluded."""
        s1 = _make_shard(gap_end_ms=1_700_000_000_000)

        mock_dm.return_value = set()  # type: ignore[attr-defined]
        mock_rl.return_value = {(s1.symbol, s1.threshold_decimal_bps, s1.gap_start_ms)}  # type: ignore[attr-defined]

        result = _select_repair_candidates([s1], max_repairs=10)

        assert len(result) == 0

    @patch("opendeviationbar.kintsugi._get_dismissed_set")
    @patch("opendeviationbar.kintsugi._get_rate_limited_set")
    def test_all_shards_within_budget(self, mock_rl: object, mock_dm: object) -> None:
        """All shards selected when budget is sufficient."""
        shards = [
            _make_shard(gap_end_ms=1_700_000_000_000 + i * 1000)
            for i in range(3)
        ]

        mock_dm.return_value = set()  # type: ignore[attr-defined]
        mock_rl.return_value = set()  # type: ignore[attr-defined]

        result = _select_repair_candidates(shards, max_repairs=10)

        assert len(result) == 3
