"""Tests for yesterday completion (TELEM-02) and junction telemetry (TELEM-03)."""

from __future__ import annotations

import types
from dataclasses import dataclass
from datetime import date
from unittest.mock import MagicMock, patch


def _make_client(queries: dict[str, list[list]]) -> MagicMock:
    """Create a mock ClickHouse client returning canned results per query substring."""
    client = MagicMock()

    def _query(sql: str, **_kw: object) -> MagicMock:
        result = MagicMock()
        for substr, rows in queries.items():
            if substr in sql:
                result.result_rows = rows
                return result
        result.result_rows = []
        return result

    client.query.side_effect = _query
    return client


class _FakeOuroborosMode:
    """Minimal stand-in for OuroborosMode enum (has .value attribute)."""

    def __init__(self, value: str = "aion") -> None:
        self.value = value

    def __eq__(self, other: object) -> bool:
        if isinstance(other, str):
            return self.value == other
        if isinstance(other, _FakeOuroborosMode):
            return self.value == other.value
        return NotImplemented

    def __hash__(self) -> int:
        return hash(self.value)


def _make_symbol_entry(
    symbol: str,
    exchange: str = "binance",
    market: str = "spot",
    enabled: bool = True,
    asset_class: str = "crypto",
    ouroboros_mode: str = "aion",
) -> object:
    """Create a minimal SymbolEntry-like object for mocking get_symbol_entries."""

    @dataclass(frozen=True)
    class FakeSymbolEntry:
        symbol: str
        enabled: bool
        asset_class: str
        exchange: str
        market: str
        ouroboros_mode: object = None  # type: ignore[assignment]
        listing_date: date = date(2020, 1, 1)

    return FakeSymbolEntry(
        symbol=symbol,
        enabled=enabled,
        asset_class=asset_class,
        exchange=exchange,
        market=market,
        ouroboros_mode=_FakeOuroborosMode(ouroboros_mode),
    )


def _mock_symbol_entries() -> types.MappingProxyType:
    """Return a mixed registry with Binance spot + Exness forex entries."""
    entries = {
        "BTCUSDT": _make_symbol_entry("BTCUSDT", "binance", "spot"),
        "ETHUSDT": _make_symbol_entry("ETHUSDT", "binance", "spot"),
        "SOLUSDT": _make_symbol_entry("SOLUSDT", "binance", "spot"),
        "ADAUSDT": _make_symbol_entry("ADAUSDT", "binance", "spot"),
        "EURUSD": _make_symbol_entry("EURUSD", "exness", "raw_spread", asset_class="forex"),
        "GBPUSD": _make_symbol_entry("GBPUSD", "exness", "raw_spread", asset_class="forex"),
        "DISABLED": _make_symbol_entry("DISABLED", "binance", "spot", enabled=False),
    }
    return types.MappingProxyType(entries)


class TestGetHeartbeatSymbols:
    """Tests for get_heartbeat_symbols() dynamic registry lookup."""

    def test_returns_only_binance_spot(self):
        """get_heartbeat_symbols() filters to Binance spot only, excluding Exness forex."""
        with patch(
            "opendeviationbar.symbol_registry.get_symbol_entries",
            return_value=_mock_symbol_entries(),
        ):
            from heartbeat.config import get_heartbeat_symbols

            get_heartbeat_symbols.cache_clear()
            symbols = get_heartbeat_symbols()

        # Should include Binance spot, exclude Exness forex and disabled
        assert "BTCUSDT" in symbols
        assert "ETHUSDT" in symbols
        assert "SOLUSDT" in symbols
        assert "ADAUSDT" in symbols
        assert "EURUSD" not in symbols
        assert "GBPUSD" not in symbols
        assert "DISABLED" not in symbols

    def test_returns_all_enabled_binance_spot(self):
        """get_heartbeat_symbols() returns all enabled Binance spot symbols (not just 2)."""
        with patch(
            "opendeviationbar.symbol_registry.get_symbol_entries",
            return_value=_mock_symbol_entries(),
        ):
            from heartbeat.config import get_heartbeat_symbols

            get_heartbeat_symbols.cache_clear()
            symbols = get_heartbeat_symbols()

        # 4 enabled Binance spot in mock, not just BTC+ETH
        assert len(symbols) == 4

    def test_returns_sorted_tuple(self):
        """get_heartbeat_symbols() returns a sorted tuple."""
        with patch(
            "opendeviationbar.symbol_registry.get_symbol_entries",
            return_value=_mock_symbol_entries(),
        ):
            from heartbeat.config import get_heartbeat_symbols

            get_heartbeat_symbols.cache_clear()
            symbols = get_heartbeat_symbols()

        assert isinstance(symbols, tuple)
        assert symbols == tuple(sorted(symbols))

    def test_fallback_on_import_error(self):
        """get_heartbeat_symbols() falls back to (BTCUSDT, ETHUSDT) on ImportError."""
        with patch(
            "opendeviationbar.symbol_registry.get_symbol_entries",
            side_effect=ImportError("no module"),
        ):
            from heartbeat.config import get_heartbeat_symbols

            get_heartbeat_symbols.cache_clear()
            symbols = get_heartbeat_symbols()

        assert symbols == ("BTCUSDT", "ETHUSDT")

    def test_fallback_on_runtime_error(self):
        """get_heartbeat_symbols() falls back on any exception, not just ImportError."""
        with patch(
            "opendeviationbar.symbol_registry.get_symbol_entries",
            side_effect=RuntimeError("registry broken"),
        ):
            from heartbeat.config import get_heartbeat_symbols

            get_heartbeat_symbols.cache_clear()
            symbols = get_heartbeat_symbols()

        assert symbols == ("BTCUSDT", "ETHUSDT")




class TestAionFreshness:
    """Tests for Aion mode rolling-window freshness in yesterday check (MON-01)."""

    def test_aion_symbol_fresh(self):
        """Aion symbol with recent bar (5 min ago) -> yesterday_{symbol} = True."""
        import time

        now_us = int(time.time() * 1_000_000)
        five_min_ago_us = now_us - 5 * 60 * 1_000_000

        client = _make_client({
            "max(close_time_us)": [[five_min_ago_us]],
        })
        results: dict = {}

        entries = types.MappingProxyType({
            "BTCUSDT": _make_symbol_entry("BTCUSDT", ouroboros_mode="aion"),
        })

        with (
            patch(
                "heartbeat.checks.yesterday.get_heartbeat_symbols",
                return_value=("BTCUSDT",),
            ),
            patch(
                "heartbeat.checks.yesterday.get_symbol_entries",
                return_value=entries,
            ),
        ):
            from heartbeat.checks.yesterday import collect_yesterday_completion

            collect_yesterday_completion(client, results)

        assert results["yesterday_BTCUSDT"] is True
        assert "aion:fresh" in results["yesterday_BTCUSDT_detail"]
        assert results["yesterday_completion"] is True

    def test_aion_symbol_stale(self):
        """Aion symbol with stale bar (60 min ago, window=30) -> yesterday_{symbol} = False."""
        import time

        now_us = int(time.time() * 1_000_000)
        sixty_min_ago_us = now_us - 60 * 60 * 1_000_000

        client = _make_client({
            "max(close_time_us)": [[sixty_min_ago_us]],
        })
        results: dict = {}

        entries = types.MappingProxyType({
            "BTCUSDT": _make_symbol_entry("BTCUSDT", ouroboros_mode="aion"),
        })

        with (
            patch(
                "heartbeat.checks.yesterday.get_heartbeat_symbols",
                return_value=("BTCUSDT",),
            ),
            patch(
                "heartbeat.checks.yesterday.get_symbol_entries",
                return_value=entries,
            ),
            patch(
                "heartbeat.checks.yesterday.AION_FRESHNESS_WINDOW_MIN",
                30,
            ),
        ):
            from heartbeat.checks.yesterday import collect_yesterday_completion

            collect_yesterday_completion(client, results)

        assert results["yesterday_BTCUSDT"] is False
        assert "aion:stale" in results["yesterday_BTCUSDT_detail"]
        assert results["yesterday_completion"] is False

    def test_aion_no_bars(self):
        """Aion symbol with no bars at all -> yesterday_{symbol} = 'no_bars'."""
        client = _make_client({
            "max(close_time_us)": [[None]],
        })
        results: dict = {}

        entries = types.MappingProxyType({
            "BTCUSDT": _make_symbol_entry("BTCUSDT", ouroboros_mode="aion"),
        })

        with (
            patch(
                "heartbeat.checks.yesterday.get_heartbeat_symbols",
                return_value=("BTCUSDT",),
            ),
            patch(
                "heartbeat.checks.yesterday.get_symbol_entries",
                return_value=entries,
            ),
        ):
            from heartbeat.checks.yesterday import collect_yesterday_completion

            collect_yesterday_completion(client, results)

        assert results["yesterday_BTCUSDT"] == "no_bars"
        assert results["yesterday_completion"] is True

    def test_aion_freshness_uses_rolling_window(self):
        """Aion freshness check uses rolling window, not crossover TIDs."""
        import time

        now_us = int(time.time() * 1_000_000)
        two_min_ago_us = now_us - 2 * 60 * 1_000_000

        client = _make_client({
            "max(close_time_us)": [[two_min_ago_us]],
        })
        results: dict = {}

        entries = types.MappingProxyType({
            "BTCUSDT": _make_symbol_entry("BTCUSDT", ouroboros_mode="aion"),
        })

        with (
            patch(
                "heartbeat.checks.yesterday.get_heartbeat_symbols",
                return_value=("BTCUSDT",),
            ),
            patch(
                "heartbeat.checks.yesterday.get_symbol_entries",
                return_value=entries,
            ),
        ):
            from heartbeat.checks.yesterday import collect_yesterday_completion

            collect_yesterday_completion(client, results)

        assert results["yesterday_BTCUSDT"] is True
        assert "aion:fresh" in results["yesterday_BTCUSDT_detail"]
        assert results["yesterday_completion"] is True


class TestJunctionTelemetry:
    """Tests for collect_junction_telemetry (TELEM-03)."""

    def test_sidecar_health_available(self):
        """Sidecar health endpoint returns data -> junction populated."""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "status": "running",
            "symbols": {
                "BTCUSDT": {
                    "rest_last_tid": 12345678,
                    "ws_first_tid": 12345679,
                    "forming_bar": "open=42000 trades=15",
                    "committed_floors": {"250": 12345677},
                }
            },
        }

        results: dict = {}

        with patch("heartbeat.checks.junction.requests.get", return_value=mock_response):
            from heartbeat.checks.junction import collect_junction_telemetry

            collect_junction_telemetry(results)

        assert results["junction_telemetry"] is True
        assert results["junction_rest_last_tid"] != "unavailable"
        assert results["junction_ws_first_tid"] != "unavailable"

    def test_sidecar_down(self):
        """Sidecar unavailable -> junction keys set to 'unavailable'."""
        import requests

        results: dict = {}

        with patch(
            "heartbeat.checks.junction.requests.get",
            side_effect=requests.ConnectionError("refused"),
        ):
            from heartbeat.checks.junction import collect_junction_telemetry

            collect_junction_telemetry(results)

        assert results["junction_telemetry"] is False
        assert results["junction_rest_last_tid"] == "unavailable"
        assert results["junction_ws_first_tid"] == "unavailable"
        assert results["junction_forming_bar"] == "unavailable"
        assert results["junction_committed_floors"] == "unavailable"
