"""Tests for wallet profiler: deep behavioral profiling and strategy generation."""

from __future__ import annotations

import math
from unittest.mock import MagicMock, patch

import pytest

from horizon.flow import Trade, WalletPosition
from horizon.wallet_intel import WalletScore, WalletAnalysis, WalletPattern
from horizon.wallet_profiler import (
    # Dataclasses
    HourDistribution,
    DayDistribution,
    SessionClassification,
    BurstAnalysis,
    MarketCategory,
    PositionBuildingStyle,
    ExitStyle,
    TemporalProfile,
    MarketSelectionProfile,
    OrderFlowProfile,
    EdgeDetection,
    ProfitStrategy,
    FullWalletProfile,
    # Internal functions
    _categorize_market,
    _shannon_entropy,
    _linreg_slope,
    _lag1_autocorrelation,
    _analyze_temporal,
    _analyze_market_selection,
    _analyze_order_flow,
    _detect_edge,
    _generate_strategies,
    # Public functions
    profile_wallet,
    hunt,
    hunter,
)

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

_AUTH = "horizon._horizon.auth_require_pro"


def _make_trade(
    wallet: str = "0xaaa",
    side: str = "BUY",
    price: float = 0.5,
    size: float = 10.0,
    timestamp: int = 1000,
    condition_id: str = "0xcond1",
    market_title: str = "Test Market",
    **kwargs,
) -> Trade:
    defaults = dict(
        wallet=wallet,
        side=side,
        outcome="Yes",
        size=size,
        price=price,
        usdc_size=size * price,
        timestamp=timestamp,
        market_slug="test-market",
        market_title=market_title,
        condition_id=condition_id,
        token_id="0xtok1",
        tx_hash=None,
        pseudonym=None,
    )
    defaults.update(kwargs)
    return Trade(**defaults)


def _make_position(
    wallet: str = "0xaaa",
    pnl: float = 10.0,
    pnl_percent: float = 5.0,
    condition_id: str = "0xcond1",
    avg_price: float = 0.50,
    current_price: float = 0.55,
    **kwargs,
) -> WalletPosition:
    defaults = dict(
        wallet=wallet,
        market_slug="test-market",
        market_title="Test Market",
        condition_id=condition_id,
        token_id="0xtok1",
        outcome="Yes",
        size=100.0,
        avg_price=avg_price,
        current_price=current_price,
        current_value=55.0,
    )
    defaults.update(kwargs)
    return WalletPosition(pnl=pnl, pnl_percent=pnl_percent, **defaults)


def _make_score(composite: float = 0.5, win_rate: float = 0.6, **kwargs) -> WalletScore:
    defaults = dict(
        wallet="0xaaa",
        win_rate=win_rate,
        avg_pnl_pct=5.0,
        sharpe=1.0,
        total_pnl=100.0,
        trade_count=50,
        position_count=30,
        composite_score=composite,
    )
    defaults.update(kwargs)
    return WalletScore(**defaults)


def _make_analysis(patterns: list[WalletPattern] | None = None) -> WalletAnalysis:
    return WalletAnalysis(
        wallet="0xaaa",
        patterns=patterns or [],
        vulnerability_score=0.5,
        counter_strategies=[],
        trade_count=50,
        analysis_period_hours=100,
    )


# ===================================================================
# Category inference tests
# ===================================================================


class TestCategorizeMarket:
    def test_politics_keyword(self):
        assert _categorize_market("Will Trump win the 2024 election?") == "politics"

    def test_crypto_keyword(self):
        assert _categorize_market("Bitcoin above $100k by December?") == "crypto"

    def test_sports_keyword(self):
        assert _categorize_market("NBA Finals MVP this year?") == "sports"

    def test_economics_keyword(self):
        assert _categorize_market("Will the Fed raise interest rate?") == "economics"

    def test_entertainment_keyword(self):
        assert _categorize_market("Best Picture Oscar winner?") == "entertainment"

    def test_science_keyword(self):
        assert _categorize_market("FDA approves new drug?") == "science"

    def test_unknown_returns_other(self):
        assert _categorize_market("Random unrelated topic xyz") == "other"

    def test_case_insensitive(self):
        assert _categorize_market("BITCOIN PRICE PREDICTION") == "crypto"


# ===================================================================
# Shannon entropy tests
# ===================================================================


class TestShannonEntropy:
    def test_uniform_distribution_max_entropy(self):
        counts = {i: 10 for i in range(4)}  # 4 equal buckets
        entropy = _shannon_entropy(counts)
        assert abs(entropy - 2.0) < 0.01  # log2(4) = 2

    def test_single_bucket_zero_entropy(self):
        counts = {0: 100}
        assert _shannon_entropy(counts) == 0.0

    def test_empty_zero_entropy(self):
        assert _shannon_entropy({}) == 0.0

    def test_two_equal_buckets(self):
        counts = {0: 50, 1: 50}
        assert abs(_shannon_entropy(counts) - 1.0) < 0.01


# ===================================================================
# Linear regression slope tests
# ===================================================================


class TestLinregSlope:
    def test_increasing_positive_slope(self):
        assert _linreg_slope([1, 2, 3, 4, 5]) > 0

    def test_decreasing_negative_slope(self):
        assert _linreg_slope([5, 4, 3, 2, 1]) < 0

    def test_constant_zero_slope(self):
        assert _linreg_slope([3, 3, 3, 3]) == 0.0

    def test_single_value_zero(self):
        assert _linreg_slope([5]) == 0.0

    def test_empty_zero(self):
        assert _linreg_slope([]) == 0.0


# ===================================================================
# Lag-1 autocorrelation tests
# ===================================================================


class TestLag1Autocorrelation:
    def test_perfect_alternating_negative(self):
        # 0, 1, 0, 1, 0, 1 -> negative autocorrelation
        result = _lag1_autocorrelation([0, 1, 0, 1, 0, 1, 0, 1])
        assert result < 0

    def test_constant_sequence(self):
        # All same -> variance is 0, return 0
        assert _lag1_autocorrelation([1, 1, 1, 1]) == 0.0

    def test_too_short_returns_zero(self):
        assert _lag1_autocorrelation([1, 0]) == 0.0

    def test_streaky_positive(self):
        # Long streaks -> positive autocorrelation
        result = _lag1_autocorrelation([1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1])
        assert result > 0


# ===================================================================
# Temporal analysis tests
# ===================================================================


class TestAnalyzeTemporal:
    def test_peak_hour_detected(self):
        # All trades at hour 14 UTC (timestamp for 14:00 UTC on Jan 1 2024)
        import datetime
        base = int(datetime.datetime(2024, 1, 1, 14, 0, tzinfo=datetime.timezone.utc).timestamp())
        trades = [_make_trade(timestamp=base + i * 60) for i in range(20)]
        result = _analyze_temporal(trades)
        assert result.hour_distribution.peak_hour == 14

    def test_us_session_classified(self):
        # Trades between 14-20 UTC -> US session
        import datetime
        trades = []
        for h in range(14, 21):
            ts = int(datetime.datetime(2024, 1, 1, h, 0, tzinfo=datetime.timezone.utc).timestamp())
            trades.extend([_make_trade(timestamp=ts + i * 60) for i in range(10)])
        result = _analyze_temporal(trades)
        assert result.session.session == "us"

    def test_asia_session_classified(self):
        # Trades between 1-7 UTC -> Asia session
        import datetime
        trades = []
        for h in range(1, 8):
            ts = int(datetime.datetime(2024, 1, 1, h, 0, tzinfo=datetime.timezone.utc).timestamp())
            trades.extend([_make_trade(timestamp=ts + i * 60) for i in range(10)])
        result = _analyze_temporal(trades)
        assert result.session.session == "asia"

    def test_bursty_trades_detected(self):
        # Cluster: 20 trades 1s apart, then 1hr gap, then 20 more 1s apart
        trades = [_make_trade(timestamp=1000 + i) for i in range(20)]
        trades += [_make_trade(timestamp=4600 + i) for i in range(20)]
        result = _analyze_temporal(trades)
        assert result.burst.is_bursty is True
        assert result.burst.burstiness > 0.2

    def test_even_trades_not_bursty(self):
        # Evenly spaced every 100 seconds
        trades = [_make_trade(timestamp=1000 + i * 100) for i in range(20)]
        result = _analyze_temporal(trades)
        assert result.burst.is_bursty is False

    def test_weekend_ratio(self):
        # All trades on Saturday (day 5)
        import datetime
        # 2024-01-06 is a Saturday
        sat = int(datetime.datetime(2024, 1, 6, 12, 0, tzinfo=datetime.timezone.utc).timestamp())
        trades = [_make_trade(timestamp=sat + i * 60) for i in range(20)]
        result = _analyze_temporal(trades)
        assert result.day_distribution.weekend_ratio > 0.9

    def test_empty_trades_default(self):
        result = _analyze_temporal([])
        assert result.hour_distribution.entropy == 0.0
        assert result.active_days_pct == 0.0


# ===================================================================
# Market selection tests
# ===================================================================


class TestAnalyzeMarketSelection:
    def test_herfindahl_single_market(self):
        trades = [_make_trade(condition_id="0x1") for _ in range(10)]
        positions = [_make_position(condition_id="0x1")]
        result = _analyze_market_selection(trades, positions)
        assert result.herfindahl == 1.0
        assert result.diversity_score == 0.0

    def test_herfindahl_two_equal_markets(self):
        trades = [_make_trade(condition_id="0x1") for _ in range(10)]
        trades += [_make_trade(condition_id="0x2") for _ in range(10)]
        result = _analyze_market_selection(trades, [])
        assert abs(result.herfindahl - 0.5) < 0.01

    def test_category_assigned(self):
        trades = [
            _make_trade(market_title="Will Trump win?", condition_id="0x1"),
            _make_trade(market_title="Bitcoin above 100k?", condition_id="0x2"),
        ]
        result = _analyze_market_selection(trades, [])
        cat_names = [c.name for c in result.categories]
        assert "politics" in cat_names
        assert "crypto" in cat_names

    def test_market_count(self):
        trades = [_make_trade(condition_id=f"0x{i}") for i in range(5)]
        result = _analyze_market_selection(trades, [])
        assert result.market_count == 5

    def test_early_entrant_score(self):
        # All buys at 0.50 -> all near opening
        trades = [_make_trade(side="BUY", price=0.50) for _ in range(10)]
        result = _analyze_market_selection(trades, [])
        assert result.early_entrant_score == 1.0


# ===================================================================
# Order flow tests
# ===================================================================


class TestAnalyzeOrderFlow:
    def test_single_entry_style(self):
        # One buy per market
        trades = [_make_trade(side="BUY", condition_id=f"0x{i}") for i in range(10)]
        result = _analyze_order_flow(trades)
        assert result.building_style.style == "single_entry"

    def test_scaling_in_detected(self):
        # Multiple buys per market
        trades = []
        for i in range(10):
            for j in range(3):
                trades.append(_make_trade(
                    side="BUY",
                    condition_id=f"0x{i}",
                    timestamp=1000 + j * 100,
                ))
        result = _analyze_order_flow(trades)
        assert result.building_style.style in ("scaling_in", "dca")

    def test_size_trend_increasing(self):
        trades = [
            _make_trade(size=float(i + 1), timestamp=1000 + i * 100)
            for i in range(20)
        ]
        result = _analyze_order_flow(trades)
        assert result.size_trend == "increasing"
        assert result.size_trend_slope > 0

    def test_size_trend_stable(self):
        trades = [
            _make_trade(size=10.0, timestamp=1000 + i * 100)
            for i in range(20)
        ]
        result = _analyze_order_flow(trades)
        assert result.size_trend == "stable"

    def test_dip_buyer_score(self):
        # Buy at low prices compared to market average
        trades = [
            _make_trade(side="BUY", price=0.3, condition_id="0x1", timestamp=1000),
            _make_trade(side="SELL", price=0.7, condition_id="0x1", timestamp=2000),
        ]
        result = _analyze_order_flow(trades)
        assert result.dip_buyer_score >= 0.0  # 0.3 < 0.5 avg -> dip buy

    def test_empty_trades_defaults(self):
        result = _analyze_order_flow([])
        assert result.building_style.style == "single_entry"
        assert result.size_trend == "stable"


# ===================================================================
# Edge detection tests
# ===================================================================


class TestDetectEdge:
    def test_positive_info_score_for_winner(self):
        trades = [_make_trade()]
        positions = [_make_position(avg_price=0.40, current_price=0.70, pnl=30)]
        score = _make_score(composite=0.5)
        result = _detect_edge(trades, positions, score)
        assert result.information_score > 0

    def test_negative_info_score_for_loser(self):
        trades = [_make_trade()]
        positions = [_make_position(avg_price=0.70, current_price=0.30, pnl=-40)]
        score = _make_score(composite=-0.5)
        result = _detect_edge(trades, positions, score)
        assert result.information_score < 0

    def test_confidence_high_for_many_positions(self):
        score = _make_score(position_count=250)
        result = _detect_edge([], [], score)
        assert result.confidence == 0.9

    def test_confidence_low_for_few_positions(self):
        score = _make_score(position_count=10)
        result = _detect_edge([], [], score)
        assert result.confidence == 0.1

    def test_streak_analysis_random(self):
        # Random-ish outcomes
        positions = [_make_position(pnl=10 if i % 3 != 0 else -10) for i in range(30)]
        score = _make_score(position_count=30)
        result = _detect_edge([], positions, score)
        assert result.streak_type in ("streaky", "mean_reverting", "random")

    def test_win_rate_by_size_buckets(self):
        trades = [_make_trade(side="BUY", size=float(i + 1)) for i in range(10)]
        positions = [_make_position(condition_id="0xcond1", pnl=10)]
        score = _make_score()
        result = _detect_edge(trades, positions, score)
        assert "small" in result.win_rate_by_size
        assert "medium" in result.win_rate_by_size
        assert "large" in result.win_rate_by_size


# ===================================================================
# Strategy generation tests
# ===================================================================


class TestGenerateStrategies:
    def _make_temporal(self, **overrides):
        defaults = dict(
            hour_distribution=HourDistribution(counts={14: 50}, peak_hour=14, entropy=3.0),
            day_distribution=DayDistribution(counts={0: 10}, peak_day=0, weekend_ratio=0.1),
            session=SessionClassification(session="round_the_clock", us_pct=0.4, asia_pct=0.3, europe_pct=0.3),
            burst=BurstAnalysis(burstiness=0.0, is_bursty=False, avg_cluster_size=1.0),
            active_days_pct=0.5,
        )
        defaults.update(overrides)
        return TemporalProfile(**defaults)

    def _make_market_sel(self, **overrides):
        defaults = dict(
            categories=[MarketCategory(name="politics", trade_count=20, win_rate=0.5)],
            best_category="politics",
            worst_category="politics",
            herfindahl=0.2,
            diversity_score=0.8,
            market_count=10,
            early_entrant_score=0.3,
        )
        defaults.update(overrides)
        return MarketSelectionProfile(**defaults)

    def _make_order_flow(self, **overrides):
        defaults = dict(
            building_style=PositionBuildingStyle(style="single_entry", avg_entries_per_market=1.0),
            exit_style=ExitStyle(style="single_exit", avg_exits_per_market=1.0),
            avg_hold_hours=24.0,
            hold_cv=0.5,
            size_trend="stable",
            size_trend_slope=0.0,
            dip_buyer_score=0.3,
            rip_seller_score=0.3,
        )
        defaults.update(overrides)
        return OrderFlowProfile(**defaults)

    def _make_edge(self, **overrides):
        defaults = dict(
            information_score=0.0,
            timing_quality=0.0,
            win_rate_by_size={"small": 0.5, "medium": 0.5, "large": 0.5},
            streak_type="random",
            streak_autocorrelation=0.0,
            edge_bps=0.0,
            confidence=0.5,
        )
        defaults.update(overrides)
        return EdgeDetection(**defaults)

    def test_fade_loser_triggered(self):
        score = _make_score(composite=-0.5)
        analysis = _make_analysis()
        strats = _generate_strategies(
            score, analysis, self._make_temporal(), self._make_market_sel(),
            self._make_order_flow(), self._make_edge(),
        )
        names = [s.name for s in strats]
        assert "fade_loser" in names
        fade = next(s for s in strats if s.name == "fade_loser")
        assert fade.impl_type == "fade"
        assert fade.params["inverse"] is True

    def test_copy_winner_triggered(self):
        score = _make_score(composite=0.5)
        analysis = _make_analysis()
        edge = self._make_edge(information_score=0.3)
        strats = _generate_strategies(
            score, analysis, self._make_temporal(), self._make_market_sel(),
            self._make_order_flow(), edge,
        )
        names = [s.name for s in strats]
        assert "copy_winner" in names
        copy = next(s for s in strats if s.name == "copy_winner")
        assert copy.impl_type == "copy"

    def test_no_copy_winner_without_edge(self):
        score = _make_score(composite=0.5)
        analysis = _make_analysis()
        edge = self._make_edge(information_score=0.0)
        strats = _generate_strategies(
            score, analysis, self._make_temporal(), self._make_market_sel(),
            self._make_order_flow(), edge,
        )
        names = [s.name for s in strats]
        assert "copy_winner" not in names

    def test_session_pre_position(self):
        score = _make_score(composite=0.0)
        analysis = _make_analysis()
        temporal = self._make_temporal(
            session=SessionClassification(session="us", us_pct=0.8, asia_pct=0.1, europe_pct=0.1),
            burst=BurstAnalysis(burstiness=0.5, is_bursty=True, avg_cluster_size=3.0),
        )
        strats = _generate_strategies(
            score, analysis, temporal, self._make_market_sel(),
            self._make_order_flow(), self._make_edge(),
        )
        names = [s.name for s in strats]
        assert "session_pre_position" in names

    def test_front_run_timing(self):
        score = _make_score(composite=0.0)
        analysis = _make_analysis(patterns=[
            WalletPattern(
                name="timing_regularity", description="test",
                value=0.1, exploitable=True,
                counter_strategy="Front-run",
            ),
        ])
        strats = _generate_strategies(
            score, analysis, self._make_temporal(), self._make_market_sel(),
            self._make_order_flow(), self._make_edge(),
        )
        names = [s.name for s in strats]
        assert "front_run_timing" in names

    def test_stop_hunt(self):
        score = _make_score(composite=0.0)
        analysis = _make_analysis(patterns=[
            WalletPattern(
                name="stop_levels", description="test",
                value=0.5, exploitable=True,
                counter_strategy="Hunt stops",
            ),
        ])
        strats = _generate_strategies(
            score, analysis, self._make_temporal(), self._make_market_sel(),
            self._make_order_flow(), self._make_edge(),
        )
        names = [s.name for s in strats]
        assert "stop_hunt" in names

    def test_fade_direction(self):
        score = _make_score(composite=-0.2)
        analysis = _make_analysis(patterns=[
            WalletPattern(
                name="directional_bias", description="test",
                value=0.85, exploitable=True,
                counter_strategy="Fade",
            ),
        ])
        strats = _generate_strategies(
            score, analysis, self._make_temporal(), self._make_market_sel(),
            self._make_order_flow(), self._make_edge(),
        )
        names = [s.name for s in strats]
        assert "fade_direction" in names

    def test_copy_best_category(self):
        score = _make_score(composite=0.0)
        analysis = _make_analysis()
        market_sel = self._make_market_sel(
            categories=[MarketCategory(name="crypto", trade_count=30, win_rate=0.75)],
            best_category="crypto",
        )
        strats = _generate_strategies(
            score, analysis, self._make_temporal(), market_sel,
            self._make_order_flow(), self._make_edge(),
        )
        names = [s.name for s in strats]
        assert "copy_best_category" in names

    def test_concentration_exploit(self):
        score = _make_score(composite=-0.4)
        analysis = _make_analysis()
        market_sel = self._make_market_sel(herfindahl=0.5)
        strats = _generate_strategies(
            score, analysis, self._make_temporal(), market_sel,
            self._make_order_flow(), self._make_edge(),
        )
        names = [s.name for s in strats]
        assert "concentration_exploit" in names

    def test_copy_dip_buyer(self):
        score = _make_score(composite=0.0)
        analysis = _make_analysis()
        order_flow = self._make_order_flow(dip_buyer_score=0.8)
        edge = self._make_edge(edge_bps=50.0, confidence=0.6)
        strats = _generate_strategies(
            score, analysis, self._make_temporal(), self._make_market_sel(),
            order_flow, edge,
        )
        names = [s.name for s in strats]
        assert "copy_dip_buyer" in names

    def test_timing_exploit(self):
        score = _make_score(composite=0.0)
        analysis = _make_analysis()
        temporal = self._make_temporal(
            hour_distribution=HourDistribution(counts={14: 50}, peak_hour=14, entropy=1.5),
        )
        strats = _generate_strategies(
            score, analysis, temporal, self._make_market_sel(),
            self._make_order_flow(), self._make_edge(),
        )
        names = [s.name for s in strats]
        assert "timing_exploit" in names

    def test_no_strategies_for_neutral_wallet(self):
        score = _make_score(composite=0.0)
        analysis = _make_analysis()
        strats = _generate_strategies(
            score, analysis, self._make_temporal(), self._make_market_sel(),
            self._make_order_flow(), self._make_edge(),
        )
        # Neutral wallet with no exploitable patterns should have few/no strategies
        # (only timing_exploit if entropy is low, which it's not at 3.0)
        for s in strats:
            assert s.name not in ("fade_loser", "copy_winner")


# ===================================================================
# profile_wallet integration tests
# ===================================================================


class TestProfileWallet:
    @patch(_AUTH)
    @patch("horizon.wallet_profiler.analyze_wallet")
    @patch("horizon.wallet_profiler.score_wallet")
    @patch("horizon.wallet_profiler.get_wallet_positions")
    @patch("horizon.wallet_profiler.get_wallet_trades")
    def test_returns_full_profile(self, mock_trades, mock_pos, mock_score, mock_analysis, mock_auth):
        mock_trades.return_value = [
            _make_trade(timestamp=1704067200 + i * 100) for i in range(100)
        ]
        mock_pos.return_value = [_make_position(pnl=10)]
        mock_score.return_value = _make_score(composite=0.5, position_count=100)
        mock_analysis.return_value = _make_analysis()

        profile = profile_wallet("0xaaa")
        assert isinstance(profile, FullWalletProfile)
        assert profile.wallet == "0xaaa"
        assert profile.data_quality == "medium"
        assert isinstance(profile.temporal, TemporalProfile)
        assert isinstance(profile.market_selection, MarketSelectionProfile)
        assert isinstance(profile.order_flow, OrderFlowProfile)
        assert isinstance(profile.edge, EdgeDetection)

    @patch(_AUTH)
    @patch("horizon.wallet_profiler.analyze_wallet")
    @patch("horizon.wallet_profiler.score_wallet")
    @patch("horizon.wallet_profiler.get_wallet_positions")
    @patch("horizon.wallet_profiler.get_wallet_trades")
    def test_data_quality_high(self, mock_trades, mock_pos, mock_score, mock_analysis, mock_auth):
        mock_trades.return_value = [_make_trade(timestamp=1704067200 + i * 100) for i in range(250)]
        mock_pos.return_value = []
        mock_score.return_value = _make_score()
        mock_analysis.return_value = _make_analysis()
        profile = profile_wallet("0xaaa")
        assert profile.data_quality == "high"

    @patch(_AUTH)
    @patch("horizon.wallet_profiler.analyze_wallet")
    @patch("horizon.wallet_profiler.score_wallet")
    @patch("horizon.wallet_profiler.get_wallet_positions")
    @patch("horizon.wallet_profiler.get_wallet_trades")
    def test_data_quality_low(self, mock_trades, mock_pos, mock_score, mock_analysis, mock_auth):
        mock_trades.return_value = [_make_trade(timestamp=1704067200 + i * 100) for i in range(10)]
        mock_pos.return_value = []
        mock_score.return_value = _make_score()
        mock_analysis.return_value = _make_analysis()
        profile = profile_wallet("0xaaa")
        assert profile.data_quality == "low"

    @patch(_AUTH)
    @patch("horizon.wallet_profiler.analyze_wallet")
    @patch("horizon.wallet_profiler.score_wallet")
    @patch("horizon.wallet_profiler.get_wallet_positions")
    @patch("horizon.wallet_profiler.get_wallet_trades")
    def test_best_strategy_selected(self, mock_trades, mock_pos, mock_score, mock_analysis, mock_auth):
        mock_trades.return_value = [_make_trade(timestamp=1704067200 + i * 100) for i in range(100)]
        mock_pos.return_value = [_make_position(pnl=-50)]
        mock_score.return_value = _make_score(composite=-0.6)
        mock_analysis.return_value = _make_analysis()
        profile = profile_wallet("0xaaa")
        assert profile.best_strategy is not None
        strategy_names = [s.name for s in profile.strategies]
        assert "fade_loser" in strategy_names

    @patch(_AUTH)
    @patch("horizon.wallet_profiler.analyze_wallet")
    @patch("horizon.wallet_profiler.score_wallet")
    @patch("horizon.wallet_profiler.get_wallet_positions")
    @patch("horizon.wallet_profiler.get_wallet_trades")
    def test_empty_trades_no_crash(self, mock_trades, mock_pos, mock_score, mock_analysis, mock_auth):
        mock_trades.return_value = []
        mock_pos.return_value = []
        mock_score.return_value = _make_score(composite=0.0, position_count=0)
        mock_analysis.return_value = _make_analysis()
        profile = profile_wallet("0xaaa")
        assert profile.data_quality == "low"
        assert isinstance(profile.temporal, TemporalProfile)


# ===================================================================
# hunt tests
# ===================================================================


class TestHunt:
    @patch(_AUTH)
    @patch("horizon.copy_trader.copy_trades")
    @patch("horizon.wallet_profiler.profile_wallet")
    def test_hunt_calls_copy_with_inverse(self, mock_profile, mock_copy, mock_auth):
        mock_profile.return_value = FullWalletProfile(
            wallet="0xbad",
            score=_make_score(composite=-0.6),
            analysis=_make_analysis(),
            temporal=MagicMock(),
            market_selection=MagicMock(),
            order_flow=MagicMock(),
            edge=MagicMock(),
            strategies=[ProfitStrategy(
                name="fade_loser", description="Fade", mechanism="Inverse",
                expected_edge="high", risk_level="medium", confidence=0.8,
                impl_type="fade", params={"inverse": True, "size_scale": 0.5, "poll_interval": 30.0},
            )],
            best_strategy=ProfitStrategy(
                name="fade_loser", description="Fade", mechanism="Inverse",
                expected_edge="high", risk_level="medium", confidence=0.8,
                impl_type="fade", params={"inverse": True, "size_scale": 0.5, "poll_interval": 30.0},
            ),
            data_quality="medium",
        )
        hunt("0xbad", dry_run=True)
        mock_copy.assert_called_once()
        kwargs = mock_copy.call_args[1]
        assert kwargs["inverse"] is True
        assert kwargs["wallet"] == "0xbad"

    @patch(_AUTH)
    @patch("horizon.copy_trader.copy_trades")
    @patch("horizon.wallet_profiler.profile_wallet")
    def test_hunt_no_strategy_returns(self, mock_profile, mock_copy, mock_auth):
        mock_profile.return_value = FullWalletProfile(
            wallet="0xneutral",
            score=_make_score(composite=0.0),
            analysis=_make_analysis(),
            temporal=MagicMock(),
            market_selection=MagicMock(),
            order_flow=MagicMock(),
            edge=MagicMock(),
            strategies=[],
            best_strategy=None,
            data_quality="low",
        )
        hunt("0xneutral", dry_run=True)
        mock_copy.assert_not_called()


# ===================================================================
# hunter pipeline tests
# ===================================================================


class TestHunter:
    @patch(_AUTH)
    @patch("horizon.wallet_profiler.profile_wallet")
    @patch("horizon.copy_trader.copy_trader")
    def test_hunter_profiles_on_first_call(self, mock_copy_trader, mock_profile, mock_auth):
        mock_profile.return_value = FullWalletProfile(
            wallet="0xtest",
            score=_make_score(composite=-0.5),
            analysis=_make_analysis(),
            temporal=MagicMock(),
            market_selection=MagicMock(),
            order_flow=MagicMock(),
            edge=MagicMock(),
            strategies=[ProfitStrategy(
                name="fade_loser", description="Fade", mechanism="Inverse",
                expected_edge="high", risk_level="medium", confidence=0.8,
                impl_type="fade", params={"inverse": True, "size_scale": 0.5},
            )],
            best_strategy=ProfitStrategy(
                name="fade_loser", description="Fade", mechanism="Inverse",
                expected_edge="high", risk_level="medium", confidence=0.8,
                impl_type="fade", params={"inverse": True, "size_scale": 0.5},
            ),
            data_quality="medium",
        )
        inner = MagicMock()
        mock_copy_trader.return_value = inner

        fn = hunter("0xtest", dry_run=True)
        ctx = MagicMock()
        ctx.params = {}
        fn(ctx)

        mock_profile.assert_called_once()
        mock_copy_trader.assert_called_once()
        inner.assert_called_once_with(ctx)

    @patch(_AUTH)
    @patch("horizon.wallet_profiler.profile_wallet")
    def test_hunter_no_strategy_no_copy(self, mock_profile, mock_auth):
        mock_profile.return_value = FullWalletProfile(
            wallet="0xtest",
            score=_make_score(composite=0.0),
            analysis=_make_analysis(),
            temporal=MagicMock(),
            market_selection=MagicMock(),
            order_flow=MagicMock(),
            edge=MagicMock(),
            strategies=[],
            best_strategy=None,
            data_quality="low",
        )
        fn = hunter("0xtest")
        ctx = MagicMock()
        ctx.params = {}
        fn(ctx)
        # No copy_trader should be created
        assert "wallet_profile" not in ctx.params or ctx.params.get("wallet_profile") is None

    @patch(_AUTH)
    @patch("horizon.wallet_profiler.profile_wallet")
    @patch("horizon.copy_trader.copy_trader")
    def test_hunter_only_profiles_once(self, mock_copy_trader, mock_profile, mock_auth):
        mock_profile.return_value = FullWalletProfile(
            wallet="0xtest",
            score=_make_score(composite=-0.5),
            analysis=_make_analysis(),
            temporal=MagicMock(),
            market_selection=MagicMock(),
            order_flow=MagicMock(),
            edge=MagicMock(),
            strategies=[ProfitStrategy(
                name="fade_loser", description="Fade", mechanism="Inverse",
                expected_edge="high", risk_level="medium", confidence=0.8,
                impl_type="fade", params={"inverse": True, "size_scale": 0.5},
            )],
            best_strategy=ProfitStrategy(
                name="fade_loser", description="Fade", mechanism="Inverse",
                expected_edge="high", risk_level="medium", confidence=0.8,
                impl_type="fade", params={"inverse": True, "size_scale": 0.5},
            ),
            data_quality="medium",
        )
        inner = MagicMock()
        mock_copy_trader.return_value = inner

        fn = hunter("0xtest")
        ctx = MagicMock()
        ctx.params = {}
        fn(ctx)
        fn(ctx)
        fn(ctx)

        # Profile should only be called once
        assert mock_profile.call_count == 1
        # But inner should be called each time
        assert inner.call_count == 3

    @patch(_AUTH)
    def test_hunter_function_name(self, mock_auth):
        fn = hunter("0xtest")
        assert fn.__name__ == "hunter"


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


class TestDataclassFrozen:
    def test_profit_strategy_frozen(self):
        s = ProfitStrategy(
            name="test", description="d", mechanism="m",
            expected_edge="high", risk_level="low", confidence=0.5,
            impl_type="copy", params={},
        )
        with pytest.raises(AttributeError):
            s.name = "changed"

    def test_full_wallet_profile_frozen(self):
        p = FullWalletProfile(
            wallet="0x", score=_make_score(), analysis=_make_analysis(),
            temporal=MagicMock(), market_selection=MagicMock(),
            order_flow=MagicMock(), edge=MagicMock(),
            strategies=[], best_strategy=None, data_quality="low",
        )
        with pytest.raises(AttributeError):
            p.wallet = "0xnew"

    def test_hour_distribution_frozen(self):
        h = HourDistribution(counts={0: 1}, peak_hour=0, entropy=0.0)
        with pytest.raises(AttributeError):
            h.peak_hour = 5

    def test_edge_detection_frozen(self):
        e = EdgeDetection(
            information_score=0.0, timing_quality=0.0,
            win_rate_by_size={}, streak_type="random",
            streak_autocorrelation=0.0, edge_bps=0.0, confidence=0.0,
        )
        with pytest.raises(AttributeError):
            e.confidence = 1.0
