"""Tests for oracle: ensemble probability forecasting for prediction markets."""

from __future__ import annotations

import dataclasses
import time
from unittest.mock import MagicMock, patch

import pytest

from horizon.flow import Holder, Trade
from horizon.oracle import (
    EdgeOpportunity,
    MarketForecast,
    OracleConfig,
    OracleReport,
    SignalReading,
    _cached_score_wallet,
    _holder_concentration_signal,
    _microstructure_signal,
    _momentum_signal,
    _score_cache,
    _smart_money_flow_signal,
    _temporal_pattern_signal,
    _volume_profile_signal,
    forecast_market,
    oracle,
    oracle_report,
    scan_edges,
)
from horizon.wallet_intel import WalletScore

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_AUTH = "horizon.oracle.auth_require_ultra"


def _make_trade(
    side="BUY",
    price=0.5,
    size=10.0,
    timestamp=1000,
    condition_id="0xcond1",
    market_title="Test",
):
    return Trade(
        wallet="0xaaa",
        side=side,
        outcome="Yes",
        size=size,
        price=price,
        usdc_size=size * price,
        timestamp=timestamp,
        market_slug="test",
        market_title=market_title,
        condition_id=condition_id,
        token_id="0xtok1",
        tx_hash=None,
        pseudonym=None,
    )


def _make_holder(wallet="0xaaa", amount=100.0):
    return Holder(
        wallet=wallet,
        amount=amount,
        token_id="0xtok1",
        outcome_index=0,
        pseudonym=None,
        name=None,
    )


def _make_score(wallet="0xaaa", composite=0.5, win_rate=0.6, sharpe=1.0):
    return WalletScore(
        wallet=wallet,
        win_rate=win_rate,
        avg_pnl_pct=5.0,
        sharpe=sharpe,
        total_pnl=100.0,
        trade_count=50,
        position_count=10,
        composite_score=composite,
    )


# ===================================================================
# Dataclass tests
# ===================================================================


class TestSignalReadingDataclass:
    def test_signal_reading_frozen(self):
        sr = SignalReading(
            name="test", raw_value=0.7, normalized_value=0.7,
            weight=0.25, contribution=0.175,
        )
        with pytest.raises(dataclasses.FrozenInstanceError):
            sr.name = "changed"

    def test_market_forecast_frozen(self):
        fc = MarketForecast(
            market_id="0x1", market_title="T", ensemble_prob=0.6,
            confidence_low=0.4, confidence_high=0.8, edge_bps=100.0,
            signals=[], market_price=0.5, timestamp=1.0,
        )
        with pytest.raises(dataclasses.FrozenInstanceError):
            fc.market_id = "changed"

    def test_edge_opportunity_frozen(self):
        eo = EdgeOpportunity(
            market_id="0x1", market_title="T", forecast_prob=0.7,
            market_price=0.5, edge_bps=2000.0, confidence=0.8,
            direction="buy_yes", signal_agreement=0.8, recommended_size=50.0,
        )
        with pytest.raises(dataclasses.FrozenInstanceError):
            eo.direction = "changed"

    def test_oracle_report_frozen(self):
        rpt = OracleReport(
            forecasts=[], top_edges=[], model_calibration=0.5,
            coverage=10, avg_confidence=0.7, timestamp=1.0,
        )
        with pytest.raises(dataclasses.FrozenInstanceError):
            rpt.coverage = 99

    def test_oracle_config_defaults(self):
        cfg = OracleConfig()
        assert cfg.min_confidence == 0.3
        assert cfg.edge_threshold_bps == 200.0
        assert cfg.recalibrate_interval == 50
        assert cfg.holder_limit == 50
        assert cfg.trade_limit == 200
        assert "smart_money_flow" in cfg.signal_weights
        assert len(cfg.signal_weights) == 6

    def test_oracle_config_custom_weights(self):
        custom = {"smart_money_flow": 0.5, "microstructure": 0.5}
        cfg = OracleConfig(signal_weights=custom, min_confidence=0.1)
        assert cfg.signal_weights == custom
        assert cfg.min_confidence == 0.1


# ===================================================================
# _cached_score_wallet tests
# ===================================================================


class TestCachedScoreWallet:
    def setup_method(self):
        _score_cache.clear()

    @patch("horizon.oracle.score_wallet")
    def test_cached_score_miss_calls_score_wallet(self, mock_score):
        ws = _make_score()
        mock_score.return_value = ws
        result = _cached_score_wallet("0xaaa")
        assert result is ws
        mock_score.assert_called_once_with("0xaaa", limit=100)

    @patch("horizon.oracle.score_wallet")
    def test_cached_score_hit_returns_cache(self, mock_score):
        ws = _make_score()
        _score_cache["0xaaa"] = (time.time(), ws)
        result = _cached_score_wallet("0xaaa")
        assert result is ws
        mock_score.assert_not_called()

    @patch("horizon.oracle.score_wallet")
    @patch("horizon.oracle.time")
    def test_cached_score_expired_refreshes(self, mock_time, mock_score):
        ws_old = _make_score(composite=0.1)
        ws_new = _make_score(composite=0.9)
        # First call: now = 1000
        mock_time.time.return_value = 1000.0
        _score_cache["0xaaa"] = (500.0, ws_old)  # expired (1000 - 500 > 300)
        mock_score.return_value = ws_new
        result = _cached_score_wallet("0xaaa")
        assert result is ws_new
        mock_score.assert_called_once()

    @patch("horizon.oracle.score_wallet", side_effect=RuntimeError("fail"))
    def test_cached_score_error_returns_none(self, mock_score):
        result = _cached_score_wallet("0xaaa")
        assert result is None


# ===================================================================
# Signal tests
# ===================================================================


class TestSmartMoneyFlowSignal:
    @patch("horizon.oracle._cached_score_wallet")
    @patch("horizon.oracle.get_top_holders")
    def test_smart_money_flow_bullish(self, mock_holders, mock_score):
        mock_holders.return_value = [
            _make_holder("0xaaa", 200.0),
            _make_holder("0xbbb", 100.0),
        ]
        mock_score.return_value = _make_score(composite=0.8)
        result = _smart_money_flow_signal("0xcond1", OracleConfig())
        assert result > 0.5

    @patch("horizon.oracle._cached_score_wallet")
    @patch("horizon.oracle.get_top_holders")
    def test_smart_money_flow_bearish(self, mock_holders, mock_score):
        # outcome_index=1 means NO side
        h = Holder(wallet="0xaaa", amount=200.0, token_id="0xtok1",
                    outcome_index=1, pseudonym=None, name=None)
        mock_holders.return_value = [h]
        mock_score.return_value = _make_score(composite=0.8)
        result = _smart_money_flow_signal("0xcond1", OracleConfig())
        assert result < 0.5

    @patch("horizon.oracle.get_top_holders", return_value=[])
    def test_smart_money_flow_neutral(self, mock_holders):
        result = _smart_money_flow_signal("0xcond1", OracleConfig())
        assert result == 0.5

    @patch("horizon.oracle.get_top_holders", side_effect=RuntimeError("fail"))
    def test_smart_money_flow_error_returns_neutral(self, mock_holders):
        result = _smart_money_flow_signal("0xcond1", OracleConfig())
        assert result == 0.5


class TestMicrostructureSignal:
    @patch("horizon.oracle.VpinDetector")
    @patch("horizon.oracle.kyles_lambda", return_value=0.25)
    @patch("horizon.oracle.get_market_trades")
    def test_microstructure_signal_with_trades(self, mock_trades, mock_kl, mock_vpin_cls):
        trades = [_make_trade(price=0.5 + i * 0.01, timestamp=1000 + i)
                  for i in range(20)]
        mock_trades.return_value = trades
        mock_det = MagicMock()
        mock_det.vpin.return_value = 0.6
        mock_vpin_cls.return_value = mock_det
        result = _microstructure_signal("0xcond1", OracleConfig())
        assert 0.0 <= result <= 1.0

    @patch("horizon.oracle.get_market_trades", return_value=[])
    def test_microstructure_signal_no_trades(self, mock_trades):
        result = _microstructure_signal("0xcond1", OracleConfig())
        assert result == 0.5

    @patch("horizon.oracle.get_market_trades", side_effect=RuntimeError("fail"))
    def test_microstructure_signal_error_returns_neutral(self, mock_trades):
        result = _microstructure_signal("0xcond1", OracleConfig())
        assert result == 0.5


class TestMomentumSignal:
    @patch("horizon.oracle.ema")
    def test_momentum_signal_uptrend(self, mock_ema):
        # Prices trend up -> short EMA > long EMA
        trades = [_make_trade(price=0.3 + i * 0.02, timestamp=1000 + i)
                  for i in range(20)]
        # short_ema > long_ema => ratio > 1 => normalized > 0.5
        mock_ema.side_effect = [0.55, 0.45]
        result = _momentum_signal(trades)
        assert result > 0.5

    @patch("horizon.oracle.ema")
    def test_momentum_signal_downtrend(self, mock_ema):
        trades = [_make_trade(price=0.7 - i * 0.02, timestamp=1000 + i)
                  for i in range(20)]
        # short_ema < long_ema => ratio < 1 => normalized < 0.5
        mock_ema.side_effect = [0.40, 0.55]
        result = _momentum_signal(trades)
        assert result < 0.5

    @patch("horizon.oracle.ema")
    def test_momentum_signal_flat(self, mock_ema):
        trades = [_make_trade(price=0.5, timestamp=1000 + i) for i in range(20)]
        mock_ema.side_effect = [0.5, 0.5]
        result = _momentum_signal(trades)
        assert result == pytest.approx(0.5, abs=0.05)

    def test_momentum_signal_insufficient_trades(self):
        trades = [_make_trade() for _ in range(5)]
        result = _momentum_signal(trades)
        assert result == 0.5


class TestVolumeProfileSignal:
    def test_volume_profile_all_buys(self):
        trades = [_make_trade(side="BUY") for _ in range(10)]
        result = _volume_profile_signal(trades)
        assert result == 1.0

    def test_volume_profile_all_sells(self):
        trades = [_make_trade(side="SELL") for _ in range(10)]
        result = _volume_profile_signal(trades)
        assert result == 0.0

    def test_volume_profile_mixed(self):
        trades = [_make_trade(side="BUY")] * 3 + [_make_trade(side="SELL")] * 7
        result = _volume_profile_signal(trades)
        assert result == pytest.approx(0.3, abs=0.01)

    def test_volume_profile_no_trades(self):
        result = _volume_profile_signal([])
        assert result == 0.5


class TestHolderConcentrationSignal:
    @patch("horizon.oracle.get_top_holders")
    def test_holder_concentration_high_hhi(self, mock_holders):
        # One holder has all the amount => HHI = 1.0
        mock_holders.return_value = [_make_holder("0xaaa", 1000.0)]
        result = _holder_concentration_signal("0xcond1", OracleConfig())
        assert result == 1.0

    @patch("horizon.oracle.get_top_holders")
    def test_holder_concentration_low_hhi(self, mock_holders):
        # 100 equal holders => HHI = 100 * (1/100)^2 = 0.01 => normalized = 0.1
        mock_holders.return_value = [
            _make_holder(f"0x{i:04x}", 10.0) for i in range(100)
        ]
        result = _holder_concentration_signal("0xcond1", OracleConfig())
        assert result < 0.5


class TestTemporalPatternSignal:
    def test_temporal_pattern_with_trades(self):
        # Create trades with valid timestamps
        trades = [
            _make_trade(side="BUY", timestamp=1700000000 + i * 3600)
            for i in range(20)
        ]
        result = _temporal_pattern_signal(trades)
        assert 0.0 <= result <= 1.0


# ===================================================================
# forecast_market tests
# ===================================================================


class TestForecastMarket:
    @patch("horizon.oracle._holder_concentration_signal", return_value=0.5)
    @patch("horizon.oracle._microstructure_signal", return_value=0.5)
    @patch("horizon.oracle._smart_money_flow_signal", return_value=0.5)
    @patch("horizon.oracle.get_market_trades", return_value=[])
    @patch(_AUTH)
    def test_forecast_market_calls_auth(self, mock_auth, mock_trades,
                                        mock_smf, mock_micro, mock_hc):
        forecast_market("0xcond1", market_price=0.5)
        mock_auth.assert_called_once()

    @patch(_AUTH)
    @patch("horizon.oracle.get_top_holders")
    @patch("horizon.oracle.get_market_trades")
    @patch("horizon.oracle._cached_score_wallet")
    def test_forecast_market_basic(self, mock_score, mock_trades,
                                   mock_holders, mock_auth):
        mock_holders.return_value = [_make_holder()]
        mock_trades.return_value = [
            _make_trade(timestamp=i * 100) for i in range(20)
        ]
        mock_score.return_value = _make_score()
        fc = forecast_market("0xcond1", market_title="Test", market_price=0.5)
        assert isinstance(fc, MarketForecast)
        assert 0.01 <= fc.ensemble_prob <= 0.99

    @patch(_AUTH)
    @patch("horizon.oracle.get_top_holders", return_value=[])
    @patch("horizon.oracle.get_market_trades", return_value=[])
    def test_forecast_market_clamps_probability(self, mock_trades,
                                                 mock_holders, mock_auth):
        fc = forecast_market("0xcond1", market_price=0.5)
        assert 0.01 <= fc.ensemble_prob <= 0.99

    @patch(_AUTH)
    @patch("horizon.oracle._holder_concentration_signal", return_value=0.9)
    @patch("horizon.oracle._microstructure_signal", return_value=0.9)
    @patch("horizon.oracle._smart_money_flow_signal", return_value=0.9)
    @patch("horizon.oracle.get_market_trades")
    def test_forecast_market_edge_positive(self, mock_trades, mock_smf,
                                           mock_micro, mock_hc, mock_auth):
        mock_trades.return_value = [
            _make_trade(side="BUY", timestamp=i * 100) for i in range(20)
        ]
        fc = forecast_market("0xcond1", market_price=0.3)
        assert fc.edge_bps > 0

    @patch(_AUTH)
    @patch("horizon.oracle._holder_concentration_signal", return_value=0.1)
    @patch("horizon.oracle._microstructure_signal", return_value=0.1)
    @patch("horizon.oracle._smart_money_flow_signal", return_value=0.1)
    @patch("horizon.oracle.get_market_trades")
    def test_forecast_market_edge_negative(self, mock_trades, mock_smf,
                                           mock_micro, mock_hc, mock_auth):
        mock_trades.return_value = [
            _make_trade(side="SELL", timestamp=i * 100) for i in range(20)
        ]
        fc = forecast_market("0xcond1", market_price=0.8)
        assert fc.edge_bps < 0

    @patch(_AUTH)
    @patch("horizon.oracle.get_top_holders", return_value=[])
    @patch("horizon.oracle.get_market_trades", return_value=[])
    def test_forecast_market_confidence_calculation(self, mock_trades,
                                                     mock_holders, mock_auth):
        fc = forecast_market("0xcond1", market_price=0.5)
        assert fc.confidence_low <= fc.ensemble_prob
        assert fc.confidence_high >= fc.ensemble_prob
        assert 0.01 <= fc.confidence_low
        assert fc.confidence_high <= 0.99

    @patch(_AUTH)
    @patch("horizon.oracle.get_top_holders", return_value=[_make_holder()])
    @patch("horizon.oracle.get_market_trades", return_value=[])
    @patch("horizon.oracle._cached_score_wallet", return_value=_make_score())
    def test_forecast_market_custom_config(self, mock_score, mock_trades,
                                           mock_holders, mock_auth):
        custom = OracleConfig(
            signal_weights={"smart_money_flow": 1.0},
            holder_limit=10,
            trade_limit=50,
        )
        fc = forecast_market("0xcond1", market_price=0.5, config=custom)
        assert isinstance(fc, MarketForecast)
        assert fc.market_id == "0xcond1"

    @patch(_AUTH)
    @patch("horizon.oracle.get_top_holders", side_effect=RuntimeError("api down"))
    @patch("horizon.oracle.get_market_trades", side_effect=RuntimeError("api down"))
    def test_forecast_market_handles_api_errors(self, mock_trades,
                                                 mock_holders, mock_auth):
        # Should not raise; falls back to neutral signals
        fc = forecast_market("0xcond1", market_price=0.5)
        assert isinstance(fc, MarketForecast)
        # All signals neutral -> ensemble ~ 0.5
        assert 0.3 <= fc.ensemble_prob <= 0.7


# ===================================================================
# scan_edges tests
# ===================================================================


class TestScanEdges:
    @patch(_AUTH)
    def test_scan_edges_calls_auth(self, mock_auth):
        scan_edges(markets=[], max_markets=1)
        mock_auth.assert_called_once()

    @patch(_AUTH)
    @patch("horizon.oracle.forecast_market")
    def test_scan_edges_filters_by_threshold(self, mock_forecast, mock_auth):
        fc = MarketForecast(
            market_id="0x1", market_title="T", ensemble_prob=0.52,
            confidence_low=0.4, confidence_high=0.6, edge_bps=100.0,
            signals=[], market_price=0.51, timestamp=1.0,
        )
        mock_forecast.return_value = fc
        markets = [{"id": "0x1", "title": "T", "price": 0.51}]
        edges = scan_edges(markets=markets, min_edge_bps=200.0)
        assert len(edges) == 0  # 100 bps < 200 threshold

    @patch(_AUTH)
    @patch("horizon.oracle.forecast_market")
    def test_scan_edges_sorts_by_edge_confidence(self, mock_forecast, mock_auth):
        sig = SignalReading(name="s", raw_value=0.8, normalized_value=0.8,
                            weight=0.5, contribution=0.4)
        fc1 = MarketForecast(
            market_id="0x1", market_title="Low", ensemble_prob=0.7,
            confidence_low=0.5, confidence_high=0.9, edge_bps=300.0,
            signals=[sig], market_price=0.67, timestamp=1.0,
        )
        fc2 = MarketForecast(
            market_id="0x2", market_title="High", ensemble_prob=0.9,
            confidence_low=0.7, confidence_high=0.99, edge_bps=500.0,
            signals=[sig], market_price=0.85, timestamp=1.0,
        )
        mock_forecast.side_effect = [fc2, fc1]
        markets = [
            {"id": "0x2", "title": "High", "price": 0.85},
            {"id": "0x1", "title": "Low", "price": 0.67},
        ]
        edges = scan_edges(markets=markets, min_edge_bps=250.0)
        assert len(edges) == 2
        # Sorted by |edge| * confidence descending
        assert abs(edges[0].edge_bps) >= abs(edges[1].edge_bps)

    @patch(_AUTH)
    @patch("horizon.oracle.kelly_size", return_value=50.0)
    @patch("horizon.oracle.forecast_market")
    def test_scan_edges_kelly_sizing(self, mock_forecast, mock_kelly, mock_auth):
        sig = SignalReading(name="s", raw_value=0.8, normalized_value=0.8,
                            weight=1.0, contribution=0.8)
        fc = MarketForecast(
            market_id="0x1", market_title="T", ensemble_prob=0.8,
            confidence_low=0.6, confidence_high=0.9, edge_bps=3000.0,
            signals=[sig], market_price=0.5, timestamp=1.0,
        )
        mock_forecast.return_value = fc
        markets = [{"id": "0x1", "title": "T", "price": 0.5}]
        edges = scan_edges(markets=markets, min_edge_bps=200.0)
        assert len(edges) == 1
        assert edges[0].recommended_size >= 0

    @patch(_AUTH)
    @patch("horizon.oracle.forecast_market")
    def test_scan_edges_max_markets(self, mock_forecast, mock_auth):
        sig = SignalReading(name="s", raw_value=0.8, normalized_value=0.8,
                            weight=1.0, contribution=0.8)
        fc = MarketForecast(
            market_id="0x1", market_title="T", ensemble_prob=0.8,
            confidence_low=0.6, confidence_high=0.99, edge_bps=3000.0,
            signals=[sig], market_price=0.5, timestamp=1.0,
        )
        mock_forecast.return_value = fc
        markets = [{"id": f"0x{i}", "title": f"M{i}", "price": 0.5}
                   for i in range(10)]
        edges = scan_edges(markets=markets, min_edge_bps=200.0, max_markets=3)
        # forecast_market called at most max_markets times
        assert mock_forecast.call_count <= 3

    @patch(_AUTH)
    def test_scan_edges_no_edges_found(self, mock_auth):
        edges = scan_edges(markets=[], min_edge_bps=200.0)
        assert edges == []


# ===================================================================
# oracle pipeline tests
# ===================================================================


class TestOraclePipeline:
    @patch(_AUTH)
    def test_oracle_calls_auth(self, mock_auth):
        oracle()
        mock_auth.assert_called_once()

    @patch(_AUTH)
    def test_oracle_returns_callable(self, mock_auth):
        fn = oracle()
        assert callable(fn)

    @patch(_AUTH)
    def test_oracle_callable_name(self, mock_auth):
        fn = oracle()
        assert fn.__name__ == "oracle"

    @patch(_AUTH)
    @patch("horizon.oracle.forecast_market")
    def test_oracle_injects_forecast(self, mock_forecast, mock_auth):
        sig = SignalReading(name="s", raw_value=0.5, normalized_value=0.5,
                            weight=1.0, contribution=0.5)
        fc = MarketForecast(
            market_id="0xcond1", market_title="T", ensemble_prob=0.65,
            confidence_low=0.5, confidence_high=0.8, edge_bps=1500.0,
            signals=[sig], market_price=0.5, timestamp=1.0,
        )
        mock_forecast.return_value = fc
        fn = oracle(forecast_interval_cycles=1)

        ctx = MagicMock()
        ctx.market.id = "0xcond1"
        ctx.market.name = "Test"
        ctx.feed.price = 0.5
        ctx.params = {}
        fn(ctx)

        assert ctx.params["oracle_forecast"] is fc
        assert ctx.params["oracle_edge_bps"] == 1500.0

    @patch(_AUTH)
    @patch("horizon.oracle.forecast_market")
    def test_oracle_respects_interval(self, mock_forecast, mock_auth):
        fn = oracle(forecast_interval_cycles=5)

        ctx = MagicMock()
        ctx.market.id = "0xcond1"
        ctx.market.name = "Test"
        ctx.feed.price = 0.5
        ctx.params = {}

        # Cycles 1-4 should not call forecast_market
        for _ in range(4):
            fn(ctx)
        mock_forecast.assert_not_called()

        # Cycle 5 should call forecast_market
        sig = SignalReading(name="s", raw_value=0.5, normalized_value=0.5,
                            weight=1.0, contribution=0.5)
        fc = MarketForecast(
            market_id="0xcond1", market_title="T", ensemble_prob=0.6,
            confidence_low=0.4, confidence_high=0.8, edge_bps=1000.0,
            signals=[sig], market_price=0.5, timestamp=1.0,
        )
        mock_forecast.return_value = fc
        fn(ctx)
        mock_forecast.assert_called_once()

    @patch(_AUTH)
    def test_oracle_no_engine_skip(self, mock_auth):
        fn = oracle(forecast_interval_cycles=1)
        ctx = MagicMock()
        ctx.market = None
        ctx.params = {}
        # Should not raise
        fn(ctx)


# ===================================================================
# oracle_report tests
# ===================================================================


class TestOracleReport:
    @patch(_AUTH)
    def test_oracle_report_calls_auth(self, mock_auth):
        oracle_report(markets=[])
        mock_auth.assert_called_once()

    @patch(_AUTH)
    @patch("horizon.oracle.forecast_market")
    @patch("horizon.oracle.log_loss", return_value=0.5)
    def test_oracle_report_basic(self, mock_ll, mock_forecast, mock_auth):
        sig = SignalReading(name="s", raw_value=0.7, normalized_value=0.7,
                            weight=1.0, contribution=0.7)
        fc = MarketForecast(
            market_id="0x1", market_title="T", ensemble_prob=0.7,
            confidence_low=0.5, confidence_high=0.9, edge_bps=2000.0,
            signals=[sig], market_price=0.5, timestamp=1.0,
        )
        mock_forecast.return_value = fc
        markets = [
            {"id": "0x1", "title": "T1", "price": 0.5},
            {"id": "0x2", "title": "T2", "price": 0.6},
        ]
        rpt = oracle_report(markets=markets)
        assert isinstance(rpt, OracleReport)
        assert rpt.coverage == 2

    @patch(_AUTH)
    @patch("horizon.oracle.forecast_market")
    @patch("horizon.oracle.log_loss", return_value=0.35)
    def test_oracle_report_calibration(self, mock_ll, mock_forecast, mock_auth):
        sig = SignalReading(name="s", raw_value=0.7, normalized_value=0.7,
                            weight=1.0, contribution=0.7)
        fc = MarketForecast(
            market_id="0x1", market_title="T", ensemble_prob=0.7,
            confidence_low=0.5, confidence_high=0.9, edge_bps=2000.0,
            signals=[sig], market_price=0.5, timestamp=1.0,
        )
        mock_forecast.return_value = fc
        markets = [
            {"id": "0x1", "title": "T1", "price": 0.7},
            {"id": "0x2", "title": "T2", "price": 0.3},
        ]
        rpt = oracle_report(markets=markets)
        assert rpt.model_calibration == 0.35

    @patch(_AUTH)
    @patch("horizon.oracle.forecast_market")
    def test_oracle_report_coverage(self, mock_forecast, mock_auth):
        sig = SignalReading(name="s", raw_value=0.5, normalized_value=0.5,
                            weight=1.0, contribution=0.5)
        fc = MarketForecast(
            market_id="0x1", market_title="T", ensemble_prob=0.5,
            confidence_low=0.4, confidence_high=0.6, edge_bps=0.0,
            signals=[sig], market_price=0.5, timestamp=1.0,
        )
        mock_forecast.return_value = fc
        markets = [{"id": f"0x{i}", "title": f"M{i}", "price": 0.5}
                   for i in range(5)]
        rpt = oracle_report(markets=markets)
        assert rpt.coverage == 5

    @patch(_AUTH)
    def test_oracle_report_no_markets(self, mock_auth):
        rpt = oracle_report(markets=[])
        assert rpt.coverage == 0
        assert rpt.forecasts == []
        assert rpt.top_edges == []
        assert rpt.avg_confidence == 0.0

    @patch(_AUTH)
    @patch("horizon.oracle.forecast_market")
    def test_oracle_report_custom_config(self, mock_forecast, mock_auth):
        sig = SignalReading(name="s", raw_value=0.5, normalized_value=0.5,
                            weight=1.0, contribution=0.5)
        fc = MarketForecast(
            market_id="0x1", market_title="T", ensemble_prob=0.5,
            confidence_low=0.4, confidence_high=0.6, edge_bps=0.0,
            signals=[sig], market_price=0.5, timestamp=1.0,
        )
        mock_forecast.return_value = fc
        custom = OracleConfig(edge_threshold_bps=5000.0)
        markets = [{"id": "0x1", "title": "T", "price": 0.5}]
        rpt = oracle_report(markets=markets, config=custom)
        assert isinstance(rpt, OracleReport)

    @patch(_AUTH)
    @patch("horizon.oracle.forecast_market", side_effect=RuntimeError("boom"))
    def test_oracle_report_handles_errors(self, mock_forecast, mock_auth):
        markets = [{"id": "0x1", "title": "T", "price": 0.5}]
        rpt = oracle_report(markets=markets)
        assert rpt.coverage == 0
        assert rpt.forecasts == []
