"""Tests for horizon.wallet_tracker -- unified wallet signal tracker.

All tests mock flow.py functions and auth; no real API calls are made.
"""

from __future__ import annotations

import time
from unittest.mock import MagicMock, patch

import pytest

from horizon.flow import Holder, Trade, WalletPosition
from horizon.wallet_intel import WalletScore
from horizon.wallet_tracker import (
    ConsensusSignal,
    TrackerConfig,
    WalletMarketSignal,
    WalletRegimeChange,
    _clamp,
    _compute_confidence,
    _conviction_component,
    _detect_regime,
    _direction_component,
    _momentum_component,
    _recency_component,
    _safe_std,
    compute_consensus,
    compute_wallet_signal,
    scan_wallets,
    track_wallet,
    wallet_tracker,
)

_AUTH = "horizon.wallet_tracker.auth_require_ultra"
_NOW = time.time()


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


def _make_position(
    wallet: str = "0xaaa",
    condition_id: str = "0xcond1",
    outcome: str = "Yes",
    size: float = 100.0,
    current_price: float = 0.60,
    current_value: float = 60.0,
    pnl: float = 10.0,
    pnl_percent: float = 5.0,
) -> WalletPosition:
    return WalletPosition(
        wallet=wallet,
        market_slug="test-market",
        market_title="Test Market",
        condition_id=condition_id,
        token_id="tok1",
        outcome=outcome,
        size=size,
        avg_price=0.55,
        current_price=current_price,
        current_value=current_value,
        pnl=pnl,
        pnl_percent=pnl_percent,
    )


def _make_trade(
    wallet: str = "0xaaa",
    side: str = "BUY",
    outcome: str = "Yes",
    size: float = 10.0,
    price: float = 0.60,
    timestamp: int | None = None,
    condition_id: str = "0xcond1",
) -> Trade:
    ts = timestamp if timestamp is not None else int(_NOW) - 100
    return Trade(
        wallet=wallet,
        side=side,
        outcome=outcome,
        size=size,
        price=price,
        usdc_size=size * price,
        timestamp=ts,
        market_slug="test-market",
        market_title="Test Market",
        condition_id=condition_id,
        token_id="tok1",
    )


def _make_score(
    wallet: str = "0xaaa",
    composite_score: float = 0.7,
    win_rate: float = 0.6,
) -> WalletScore:
    return WalletScore(
        wallet=wallet,
        win_rate=win_rate,
        avg_pnl_pct=5.0,
        sharpe=1.2,
        total_pnl=500.0,
        trade_count=50,
        position_count=10,
        composite_score=composite_score,
    )


def _make_holder(
    wallet: str = "0xaaa",
    amount: float = 100.0,
    outcome_index: int = 0,
) -> Holder:
    return Holder(
        wallet=wallet,
        amount=amount,
        token_id="tok1",
        outcome_index=outcome_index,
    )


# ===========================================================================
# 1. Direction component
# ===========================================================================


class TestDirectionComponent:
    """Test _direction_component."""

    def test_yes_position_above_half(self):
        """YES position -> direction > 0.5."""
        positions = [_make_position(outcome="Yes", size=100)]
        d = _direction_component(positions, "0xcond1")
        assert d > 0.5

    def test_no_position_below_half(self):
        """NO position -> direction < 0.5."""
        positions = [_make_position(outcome="No", size=100)]
        d = _direction_component(positions, "0xcond1")
        assert d < 0.5

    def test_mixed_positions(self):
        """YES + NO positions cancel partially."""
        positions = [
            _make_position(outcome="Yes", size=60),
            _make_position(outcome="No", size=40),
        ]
        d = _direction_component(positions, "0xcond1")
        # Net = (60 - 40) / 100 = 0.2, mapped = (0.2 + 1) / 2 = 0.6
        assert 0.55 < d < 0.65

    def test_empty_positions_neutral(self):
        """No positions -> 0.5 neutral."""
        d = _direction_component([], "0xcond1")
        assert d == 0.5

    def test_wrong_market_neutral(self):
        """Positions in different market -> 0.5."""
        positions = [_make_position(condition_id="0xother")]
        d = _direction_component(positions, "0xcond1")
        assert d == 0.5


# ===========================================================================
# 2. Conviction component
# ===========================================================================


class TestConvictionComponent:
    """Test _conviction_component."""

    def test_large_position_high_conviction(self):
        """Position much larger than average -> conviction > 0.5."""
        positions = [
            _make_position(condition_id="0xcond1", size=200, current_price=0.5),
            _make_position(condition_id="0xother", size=20, current_price=0.5),
        ]
        c = _conviction_component(positions, "0xcond1")
        assert c > 0.5

    def test_small_position_low_conviction(self):
        """Position much smaller than average -> conviction < 0.5."""
        positions = [
            _make_position(condition_id="0xcond1", size=5, current_price=0.5),
            _make_position(condition_id="0xother", size=200, current_price=0.5),
        ]
        c = _conviction_component(positions, "0xcond1")
        assert c < 0.5

    def test_average_position_neutral(self):
        """Position = average -> conviction ~ 0.5."""
        positions = [
            _make_position(condition_id="0xcond1", size=100, current_price=0.5),
            _make_position(condition_id="0xother", size=100, current_price=0.5),
        ]
        c = _conviction_component(positions, "0xcond1")
        assert 0.45 < c < 0.55

    def test_empty_positions_neutral(self):
        c = _conviction_component([], "0xcond1")
        assert c == 0.5


# ===========================================================================
# 3. Recency component
# ===========================================================================


class TestRecencyComponent:
    """Test _recency_component."""

    def test_recent_trade_high_weight(self):
        """Trade seconds ago -> high recency."""
        trades = [_make_trade(timestamp=int(time.time()) - 10)]
        r = _recency_component(trades, 21600.0)
        assert r > 0.9

    def test_old_trade_decayed(self):
        """Trade days ago -> low recency."""
        trades = [_make_trade(timestamp=int(time.time()) - 86400 * 5)]
        r = _recency_component(trades, 21600.0)
        assert r < 0.2

    def test_no_trades_zero(self):
        """No trades -> recency = 0.0."""
        r = _recency_component([], 21600.0)
        assert r == 0.0

    def test_zero_timestamp_zero(self):
        """Trades with timestamp 0 -> recency 0."""
        trades = [_make_trade(timestamp=0)]
        r = _recency_component(trades, 21600.0)
        assert r == 0.0


# ===========================================================================
# 4. Momentum component
# ===========================================================================


class TestMomentumComponent:
    """Test _momentum_component."""

    def test_buy_to_sell_flip(self):
        """Old half buys, recent half sells -> momentum < 0.5."""
        trades = [
            _make_trade(side="BUY", timestamp=100),
            _make_trade(side="BUY", timestamp=200),
            _make_trade(side="SELL", timestamp=300),
            _make_trade(side="SELL", timestamp=400),
        ]
        m = _momentum_component(trades)
        assert m < 0.5

    def test_sell_to_buy_flip(self):
        """Old half sells, recent half buys -> momentum > 0.5."""
        trades = [
            _make_trade(side="SELL", timestamp=100),
            _make_trade(side="SELL", timestamp=200),
            _make_trade(side="BUY", timestamp=300),
            _make_trade(side="BUY", timestamp=400),
        ]
        m = _momentum_component(trades)
        assert m > 0.5

    def test_consistent_buys_neutral(self):
        """All buys -> ratio shift = 0, momentum = 0.5."""
        trades = [_make_trade(side="BUY", timestamp=i * 100) for i in range(6)]
        m = _momentum_component(trades)
        assert 0.45 < m < 0.55

    def test_insufficient_data_neutral(self):
        """< 3 trades -> 0.5."""
        trades = [_make_trade()]
        m = _momentum_component(trades)
        assert m == 0.5


# ===========================================================================
# 5. Quality gating
# ===========================================================================


class TestQualityGating:
    """Test that wallet quality amplifies/inverts/neutralizes signal."""

    @patch(_AUTH)
    def test_good_wallet_amplifies(self, mock_auth):
        """High-quality wallet amplifies directional signal."""
        positions = [_make_position(outcome="Yes", size=200)]
        trades = [_make_trade(side="BUY", timestamp=int(time.time()) - 30)]

        sig = compute_wallet_signal(
            "0xaaa", "0xcond1",
            positions=positions, trades=trades, wallet_quality=0.9,
        )
        # YES position + high quality + recent trade -> signal > 0.5
        assert sig.signal > 0.5

    @patch(_AUTH)
    def test_bad_wallet_inverts(self, mock_auth):
        """Negative-quality wallet inverts signal (fade the bad wallet)."""
        positions = [_make_position(outcome="Yes", size=200)]
        trades = [_make_trade(side="BUY", timestamp=int(time.time()) - 30)]

        sig = compute_wallet_signal(
            "0xaaa", "0xcond1",
            positions=positions, trades=trades, wallet_quality=-0.8,
        )
        # YES position + negative quality + recent trade -> signal < 0.5 (inverted)
        assert sig.signal < 0.5

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.get_wallet_trades", return_value=[])
    @patch("horizon.wallet_tracker.get_wallet_positions")
    @patch("horizon.wallet_tracker._cached_score")
    def test_zero_quality_neutral(self, mock_score, mock_pos, mock_trades, mock_auth):
        """Zero-quality wallet -> signal = 0.5 (neutral)."""
        mock_pos.return_value = [_make_position(outcome="Yes", size=200)]
        mock_score.return_value = _make_score(composite_score=0.0)

        sig = compute_wallet_signal("0xaaa", "0xcond1")
        assert sig.signal == 0.5


# ===========================================================================
# 6. Full composition (end-to-end)
# ===========================================================================


class TestFullComposition:
    """End-to-end compute_wallet_signal with mocked data."""

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.get_wallet_trades")
    @patch("horizon.wallet_tracker.get_wallet_positions")
    @patch("horizon.wallet_tracker._cached_score")
    def test_bullish_signal(self, mock_score, mock_pos, mock_trades, mock_auth):
        """Wallet with YES position + recent buys + good quality -> bullish signal."""
        now = int(time.time())
        mock_pos.return_value = [_make_position(outcome="Yes", size=200)]
        mock_trades.return_value = [
            _make_trade(side="BUY", timestamp=now - 60),
            _make_trade(side="BUY", timestamp=now - 120),
            _make_trade(side="BUY", timestamp=now - 180),
            _make_trade(side="BUY", timestamp=now - 300),
        ]
        mock_score.return_value = _make_score(composite_score=0.8)

        sig = compute_wallet_signal("0xaaa", "0xcond1", market_price=0.50)
        assert isinstance(sig, WalletMarketSignal)
        assert sig.signal > 0.5
        assert sig.direction > 0.5
        assert sig.recency > 0.5
        assert sig.wallet_quality > 0

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.get_wallet_trades")
    @patch("horizon.wallet_tracker.get_wallet_positions")
    @patch("horizon.wallet_tracker._cached_score")
    def test_signal_bounds(self, mock_score, mock_pos, mock_trades, mock_auth):
        """Signal always in [0.01, 0.99]."""
        now = int(time.time())
        mock_pos.return_value = [_make_position(outcome="Yes", size=1000)]
        mock_trades.return_value = [
            _make_trade(side="BUY", timestamp=now - 10, size=1000)
        ]
        mock_score.return_value = _make_score(composite_score=1.0)

        sig = compute_wallet_signal("0xaaa", "0xcond1")
        assert 0.01 <= sig.signal <= 0.99

    @patch(_AUTH)
    def test_no_data_neutral(self, mock_auth):
        """No positions or trades -> neutral signal."""
        sig = compute_wallet_signal(
            "0xaaa", "0xcond1",
            positions=[], trades=[], wallet_quality=0.5,
        )
        assert sig.signal == 0.5

    @patch(_AUTH)
    def test_pre_fetched_data(self, mock_auth):
        """Pre-fetched positions/trades/quality are used directly."""
        positions = [_make_position(outcome="Yes", size=100)]
        trades = [_make_trade(side="BUY", timestamp=int(time.time()) - 60)]
        sig = compute_wallet_signal(
            "0xaaa", "0xcond1",
            positions=positions,
            trades=trades,
            wallet_quality=0.6,
        )
        assert isinstance(sig, WalletMarketSignal)
        assert sig.wallet_quality == 0.6


# ===========================================================================
# 7. Consensus
# ===========================================================================


class TestConsensus:
    """Test compute_consensus."""

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.get_top_holders")
    @patch("horizon.wallet_tracker._cached_score")
    @patch("horizon.wallet_tracker.get_wallet_positions")
    @patch("horizon.wallet_tracker.get_wallet_trades")
    def test_3_wallets_consensus(self, mock_trades, mock_pos, mock_score, mock_holders, mock_auth):
        """3+ wallets produce a valid consensus."""
        holders = [
            _make_holder(wallet="0xaaa", amount=100),
            _make_holder(wallet="0xbbb", amount=80),
            _make_holder(wallet="0xccc", amount=60),
        ]
        mock_holders.return_value = holders
        mock_score.return_value = _make_score(composite_score=0.7)
        mock_pos.return_value = [_make_position(outcome="Yes", size=100)]
        mock_trades.return_value = [
            _make_trade(side="BUY", timestamp=int(time.time()) - 60),
            _make_trade(side="BUY", timestamp=int(time.time()) - 120),
            _make_trade(side="BUY", timestamp=int(time.time()) - 180),
        ]

        c = compute_consensus("0xcond1")
        assert isinstance(c, ConsensusSignal)
        assert c.n_wallets == 3
        assert 0.01 <= c.signal <= 0.99
        assert c.best_wallet in ("0xaaa", "0xbbb", "0xccc")

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.get_top_holders")
    def test_below_minimum_wallets_neutral(self, mock_holders, mock_auth):
        """< min_wallets -> neutral consensus."""
        mock_holders.return_value = [_make_holder(wallet="0xaaa")]

        c = compute_consensus("0xcond1")
        assert c.signal == 0.5
        assert c.is_actionable is False

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.get_top_holders")
    def test_no_holders_neutral(self, mock_holders, mock_auth):
        """No holders -> neutral."""
        mock_holders.return_value = []
        c = compute_consensus("0xcond1")
        assert c.signal == 0.5
        assert c.n_wallets == 0

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.get_top_holders")
    @patch("horizon.wallet_tracker._cached_score")
    @patch("horizon.wallet_tracker.get_wallet_positions")
    @patch("horizon.wallet_tracker.get_wallet_trades")
    def test_agreement_metric(self, mock_trades, mock_pos, mock_score, mock_holders, mock_auth):
        """Agreement is [0, 1], higher when wallets agree."""
        holders = [
            _make_holder(wallet=f"0x{i}", amount=100 - i * 10)
            for i in range(5)
        ]
        mock_holders.return_value = holders
        mock_score.return_value = _make_score(composite_score=0.7)
        mock_pos.return_value = [_make_position(outcome="Yes", size=100)]
        mock_trades.return_value = [
            _make_trade(side="BUY", timestamp=int(time.time()) - 60),
            _make_trade(side="BUY", timestamp=int(time.time()) - 120),
            _make_trade(side="BUY", timestamp=int(time.time()) - 180),
        ]

        c = compute_consensus("0xcond1")
        assert 0.0 <= c.agreement <= 1.0


# ===========================================================================
# 8. Regime detection
# ===========================================================================


class TestRegimeDetection:
    """Test _detect_regime."""

    def test_bullish_regime(self):
        """Consistent buying across both halves -> bullish (not flipping)."""
        trades = [
            _make_trade(side="BUY", timestamp=100),
            _make_trade(side="BUY", timestamp=200),
            _make_trade(side="BUY", timestamp=300),
            _make_trade(side="BUY", timestamp=400),
            _make_trade(side="BUY", timestamp=500),
            _make_trade(side="BUY", timestamp=600),
        ]
        r = _detect_regime(trades, threshold=0.4)
        assert r == "bullish"

    def test_bearish_regime(self):
        """Consistent selling across both halves -> bearish (not flipping)."""
        trades = [
            _make_trade(side="SELL", timestamp=100),
            _make_trade(side="SELL", timestamp=200),
            _make_trade(side="SELL", timestamp=300),
            _make_trade(side="SELL", timestamp=400),
            _make_trade(side="SELL", timestamp=500),
            _make_trade(side="SELL", timestamp=600),
        ]
        r = _detect_regime(trades, threshold=0.4)
        assert r == "bearish"

    def test_flipping_regime(self):
        """Large buy-ratio shift -> flipping."""
        trades = [
            _make_trade(side="BUY", timestamp=100),
            _make_trade(side="BUY", timestamp=200),
            _make_trade(side="SELL", timestamp=300),
            _make_trade(side="SELL", timestamp=400),
        ]
        r = _detect_regime(trades, threshold=0.4)
        # old=100%, recent=0%, delta=-1.0 >= 0.4 threshold
        assert r == "flipping"

    def test_insufficient_data_neutral(self):
        """< 4 trades -> neutral."""
        trades = [_make_trade(side="BUY", timestamp=100)]
        r = _detect_regime(trades, threshold=0.4)
        assert r == "neutral"


# ===========================================================================
# 9. Confidence interval
# ===========================================================================


class TestConfidence:
    """Test _compute_confidence."""

    def test_many_trades_high_quality_high_confidence(self):
        """Many trades + high quality + recent -> high confidence."""
        conf = _compute_confidence(trade_count=50, quality=0.9, recency=0.95)
        assert conf > 0.7

    def test_few_trades_low_confidence(self):
        """Few trades -> lower confidence."""
        conf = _compute_confidence(trade_count=1, quality=0.9, recency=0.95)
        assert conf < 0.7

    def test_zero_quality_lower_confidence(self):
        """Zero quality -> lower confidence than high quality."""
        conf_zero = _compute_confidence(trade_count=50, quality=0.0, recency=0.95)
        conf_high = _compute_confidence(trade_count=50, quality=0.9, recency=0.95)
        assert conf_zero < conf_high

    def test_confidence_bounds(self):
        """Confidence always in [0, 1]."""
        conf = _compute_confidence(trade_count=0, quality=0.0, recency=0.0)
        assert 0.0 <= conf <= 1.0
        conf = _compute_confidence(trade_count=1000, quality=1.0, recency=1.0)
        assert 0.0 <= conf <= 1.0


# ===========================================================================
# 10. Edge detection / actionability
# ===========================================================================


class TestActionability:
    """Test is_actionable gating."""

    @patch(_AUTH)
    def test_actionable_when_thresholds_met(self, mock_auth):
        """Signal is actionable when strength + confidence + edge meet thresholds."""
        positions = [_make_position(outcome="Yes", size=200)]
        now = int(time.time())
        trades = [
            _make_trade(side="BUY", timestamp=now - 30),
            _make_trade(side="BUY", timestamp=now - 60),
            _make_trade(side="BUY", timestamp=now - 90),
            _make_trade(side="BUY", timestamp=now - 120),
            _make_trade(side="BUY", timestamp=now - 150),
            _make_trade(side="BUY", timestamp=now - 200),
        ]
        config = TrackerConfig(
            min_signal_strength=0.01,
            min_confidence=0.01,
            min_edge_bps=0.0,
        )
        sig = compute_wallet_signal(
            "0xaaa", "0xcond1",
            config=config,
            market_price=0.50,
            positions=positions,
            trades=trades,
            wallet_quality=0.8,
        )
        assert sig.is_actionable is True

    @patch(_AUTH)
    def test_not_actionable_low_strength(self, mock_auth):
        """Signal near 0.5 is not actionable."""
        sig = compute_wallet_signal(
            "0xaaa", "0xcond1",
            positions=[], trades=[], wallet_quality=0.0,
            market_price=0.5,
        )
        assert sig.signal == 0.5
        assert sig.is_actionable is False


# ===========================================================================
# 11. Pipeline
# ===========================================================================


class TestPipeline:
    """Test wallet_tracker pipeline factory."""

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.compute_wallet_signal")
    def test_ctx_params_injection(self, mock_signal, mock_auth):
        """Pipeline injects wallet_tracker_signal into ctx.params."""
        mock_sig = MagicMock()
        mock_sig.signal = 0.65
        mock_sig.edge_bps = 300.0
        mock_sig.confidence = 0.7
        mock_sig.regime = "bullish"
        mock_signal.return_value = mock_sig

        pipeline_fn = wallet_tracker(wallets=["0xaaa"])

        market = MagicMock()
        market.id = "0xcond1"
        market.name = "Test"
        feed = MagicMock()
        feed.price = 0.50

        ctx = MagicMock()
        ctx.market = market
        ctx.feed = feed
        ctx.params = {}

        # First call (cycle 1) doesn't trigger refresh (interval=20)
        pipeline_fn(ctx)
        assert "wallet_tracker_signal" not in ctx.params

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.compute_wallet_signal")
    def test_periodic_refresh(self, mock_signal, mock_auth):
        """Pipeline refreshes on the interval cycle."""
        mock_sig = MagicMock()
        mock_sig.signal = 0.65
        mock_sig.edge_bps = 300.0
        mock_sig.confidence = 0.7
        mock_sig.regime = "bullish"
        mock_signal.return_value = mock_sig

        config = TrackerConfig(poll_interval_cycles=5, min_wallets_for_consensus=10)
        pipeline_fn = wallet_tracker(wallets=["0xaaa"], config=config)

        market = MagicMock()
        market.id = "0xcond1"
        market.name = "Test"
        feed = MagicMock()
        feed.price = 0.50

        ctx = MagicMock()
        ctx.market = market
        ctx.feed = feed
        ctx.params = {}

        # Run until cycle 5 (refresh trigger)
        for _ in range(5):
            ctx.params = {}
            pipeline_fn(ctx)

        assert "wallet_tracker_signal" in ctx.params

    @patch(_AUTH)
    def test_no_market_noop(self, mock_auth):
        """Pipeline does nothing if ctx.market is None."""
        pipeline_fn = wallet_tracker(wallets=["0xaaa"])

        ctx = MagicMock()
        ctx.market = None
        ctx.params = {}

        pipeline_fn(ctx)
        assert "wallet_tracker_signal" not in ctx.params


# ===========================================================================
# 12. Track wallet (Mode 1)
# ===========================================================================


class TestTrackWallet:
    """Test track_wallet multi-market signal list."""

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.get_wallet_trades")
    @patch("horizon.wallet_tracker.get_wallet_positions")
    @patch("horizon.wallet_tracker._cached_score")
    def test_multi_market_sorted_by_strength(self, mock_score, mock_pos, mock_trades, mock_auth):
        """Returns signals sorted by |signal - 0.5| descending."""
        mock_pos.return_value = [
            _make_position(condition_id="0xcond1", outcome="Yes", size=200),
            _make_position(condition_id="0xcond2", outcome="No", size=10),
        ]
        mock_trades.return_value = [
            _make_trade(side="BUY", timestamp=int(time.time()) - 60),
            _make_trade(side="BUY", timestamp=int(time.time()) - 120),
            _make_trade(side="BUY", timestamp=int(time.time()) - 180),
        ]
        mock_score.return_value = _make_score(composite_score=0.7)

        signals = track_wallet("0xaaa")
        assert len(signals) == 2
        # First signal should have larger |signal - 0.5|
        assert abs(signals[0].signal - 0.5) >= abs(signals[1].signal - 0.5)

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.get_wallet_positions")
    def test_no_positions_empty(self, mock_pos, mock_auth):
        """No positions -> empty list."""
        mock_pos.return_value = []
        signals = track_wallet("0xaaa")
        assert signals == []


# ===========================================================================
# 13. Tool functions
# ===========================================================================


class TestToolFunctions:
    """Test tool function wrappers return correct dict format."""

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.get_wallet_trades", return_value=[])
    @patch("horizon.wallet_tracker.get_wallet_positions", return_value=[])
    @patch("horizon.wallet_tracker._cached_score", return_value=_make_score())
    def test_compute_wallet_signal_returns_correct_fields(self, *mocks):
        """Signal has all required fields."""
        sig = compute_wallet_signal("0xaaa", "0xcond1")
        assert hasattr(sig, "signal")
        assert hasattr(sig, "direction")
        assert hasattr(sig, "conviction")
        assert hasattr(sig, "recency")
        assert hasattr(sig, "momentum")
        assert hasattr(sig, "wallet_quality")
        assert hasattr(sig, "confidence")
        assert hasattr(sig, "regime")
        assert hasattr(sig, "is_actionable")
        assert hasattr(sig, "edge_bps")
        assert hasattr(sig, "timestamp")

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.get_wallet_trades", return_value=[])
    @patch("horizon.wallet_tracker.get_wallet_positions", return_value=[])
    @patch("horizon.wallet_tracker._cached_score", return_value=_make_score())
    def test_signal_is_frozen(self, *mocks):
        """WalletMarketSignal is frozen dataclass."""
        sig = compute_wallet_signal("0xaaa", "0xcond1")
        with pytest.raises(AttributeError):
            sig.signal = 0.9  # type: ignore[misc]


# ===========================================================================
# 14. Scan wallets (Mode 3)
# ===========================================================================


class TestScanWallets:
    """Test scan_wallets auto-discovery."""

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.get_top_holders", return_value=[])
    def test_scan_with_market_id(self, mock_holders, mock_auth):
        """scan_wallets with explicit market_id returns ConsensusSignal."""
        result = scan_wallets(market_id="0xcond1")
        assert isinstance(result, ConsensusSignal)

    @patch(_AUTH)
    def test_scan_no_market_no_discovery(self, mock_auth):
        """scan_wallets with no market_id and no discovery -> neutral."""
        with patch("horizon.discovery.top_markets", side_effect=Exception("no discovery")):
            result = scan_wallets()
        assert result.signal == 0.5


# ===========================================================================
# 15. Helpers
# ===========================================================================


class TestHelpers:
    """Test helper functions."""

    def test_clamp(self):
        assert _clamp(0.5) == 0.5
        assert _clamp(-1.0) == 0.0
        assert _clamp(2.0) == 1.0
        assert _clamp(0.5, 0.3, 0.7) == 0.5
        assert _clamp(0.1, 0.3, 0.7) == 0.3

    def test_safe_std_empty(self):
        assert _safe_std([]) == 0.0

    def test_safe_std_single(self):
        assert _safe_std([5.0]) == 0.0

    def test_safe_std_values(self):
        # std of [0, 1] = 0.5
        std = _safe_std([0.0, 1.0])
        assert abs(std - 0.5) < 1e-9


# ===========================================================================
# 16. Regime change in pipeline
# ===========================================================================


class TestRegimeChangePipeline:
    """Test that regime changes are detected and injected."""

    @patch(_AUTH)
    @patch("horizon.wallet_tracker.compute_wallet_signal")
    def test_regime_change_detected(self, mock_signal, mock_auth):
        """When regime changes between refreshes, WalletRegimeChange is emitted."""
        config = TrackerConfig(poll_interval_cycles=1, min_wallets_for_consensus=100)
        pipeline_fn = wallet_tracker(wallets=["0xaaa"], config=config)

        market = MagicMock()
        market.id = "0xcond1"
        market.name = "Test"
        feed = MagicMock()
        feed.price = 0.50

        # First cycle: bullish
        sig1 = MagicMock()
        sig1.signal = 0.7
        sig1.edge_bps = 200.0
        sig1.confidence = 0.8
        sig1.regime = "bullish"
        sig1.timestamp = time.time()
        mock_signal.return_value = sig1

        ctx = MagicMock()
        ctx.market = market
        ctx.feed = feed
        ctx.params = {}
        pipeline_fn(ctx)

        # Second cycle: bearish
        sig2 = MagicMock()
        sig2.signal = 0.3
        sig2.edge_bps = -200.0
        sig2.confidence = 0.8
        sig2.regime = "bearish"
        sig2.timestamp = time.time()
        mock_signal.return_value = sig2

        ctx.params = {}
        pipeline_fn(ctx)

        assert "wallet_tracker_regime_changes" in ctx.params
        changes = ctx.params["wallet_tracker_regime_changes"]
        assert len(changes) == 1
        assert changes[0].old_regime == "bullish"
        assert changes[0].new_regime == "bearish"


# ===========================================================================
# 17. Edge calculation
# ===========================================================================


class TestEdge:
    """Test edge calculation in signals."""

    @patch(_AUTH)
    def test_edge_with_market_price(self, mock_auth):
        """Edge is proportional to (signal - market_price)."""
        sig = compute_wallet_signal(
            "0xaaa", "0xcond1",
            positions=[_make_position(outcome="Yes", size=100)],
            trades=[_make_trade(side="BUY", timestamp=int(time.time()) - 30)],
            wallet_quality=0.7,
            market_price=0.50,
        )
        # Signal > 0.5 for YES + good quality, so edge should be positive
        assert sig.edge_bps > 0
        # Edge magnitude consistent with signal deviation
        assert abs(sig.edge_bps - (sig.signal - 0.50) * 10000) < 1.0

    @patch(_AUTH)
    def test_edge_zero_when_no_market_price(self, mock_auth):
        """Edge is 0 when market_price <= 0."""
        sig = compute_wallet_signal(
            "0xaaa", "0xcond1",
            positions=[], trades=[], wallet_quality=0.5,
            market_price=0.0,
        )
        assert sig.edge_bps == 0.0
