"""Tests for whale galaxy: discovery, ranking, clustering, and alerting."""

from __future__ import annotations

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

import pytest

from horizon._horizon import Engine, Side
from horizon.flow import Holder, Trade
from horizon.wallet_intel import WalletScore
from horizon.whale_galaxy import (
    GalaxyConfig,
    GalaxySnapshot,
    RankedWallet,
    WalletCluster,
    WhaleAlert,
    _categorize_wallet,
    _generate_alerts,
    _score_cache,
    auto_target,
    detect_clusters,
    galaxy_hunt,
    galaxy_tracker,
    scan_galaxy,
)

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

_AUTH = "horizon.whale_galaxy.auth_require_ultra"


def _make_trade(
    wallet="0xaaa",
    side="BUY",
    price=0.5,
    size=10.0,
    timestamp=1000,
    condition_id="0xcond1",
    market_title="Test Market",
):
    return Trade(
        wallet=wallet,
        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,
                trade_count=50):
    return WalletScore(
        wallet=wallet,
        win_rate=win_rate,
        avg_pnl_pct=5.0,
        sharpe=sharpe,
        total_pnl=100.0,
        trade_count=trade_count,
        position_count=10,
        composite_score=composite,
    )


def _make_snapshot(top_wallets=None, bottom_wallets=None):
    return GalaxySnapshot(
        top_wallets=top_wallets or [],
        bottom_wallets=bottom_wallets or [],
        clusters=[],
        alerts=[],
        scan_time=1.0,
        market_count=5,
        wallet_count=20,
    )


def _make_ranked(address="0xaaa", score=0.7, category="smart_money"):
    return RankedWallet(
        address=address,
        score=score,
        win_rate=0.65,
        pnl=500.0,
        sharpe=1.2,
        edge_category=category,
        trade_count=50,
        position_count=10,
        last_active=1000.0,
    )


def _mock_ctx(market_id="mkt1"):
    ctx = MagicMock()
    ctx.market = MagicMock()
    ctx.market.id = market_id
    ctx.params = {"engine": MagicMock(spec=Engine)}
    return ctx


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


class TestDataclasses:
    def test_ranked_wallet_frozen(self):
        rw = _make_ranked()
        with pytest.raises(dataclasses.FrozenInstanceError):
            rw.address = "changed"

    def test_wallet_cluster_frozen(self):
        wc = WalletCluster(
            wallets=["0xaaa", "0xbbb"], co_trade_count=5,
            avg_delay_secs=30.0, cluster_type="copy_chain",
            shared_markets=["0xcond1"],
        )
        with pytest.raises(dataclasses.FrozenInstanceError):
            wc.cluster_type = "changed"

    def test_whale_alert_frozen(self):
        wa = WhaleAlert(
            wallet="0xaaa", pseudonym="Whale", market_id="0xcond1",
            market_title="Test", action="entered_long", size_usdc=5000.0,
            significance=3.5, timestamp=1000.0,
        )
        with pytest.raises(dataclasses.FrozenInstanceError):
            wa.action = "changed"

    def test_galaxy_snapshot_frozen(self):
        snap = _make_snapshot()
        with pytest.raises(dataclasses.FrozenInstanceError):
            snap.scan_time = 99.0

    def test_galaxy_config_defaults(self):
        cfg = GalaxyConfig()
        assert cfg.top_n == 20
        assert cfg.min_trades == 10
        assert cfg.cluster_window_secs == 3600.0
        assert cfg.cluster_min_overlap == 3
        assert cfg.alert_min_score == 0.3
        assert cfg.alert_min_size_usdc == 500.0

    def test_galaxy_config_custom(self):
        cfg = GalaxyConfig(top_n=5, min_trades=20, alert_min_score=0.8)
        assert cfg.top_n == 5
        assert cfg.min_trades == 20
        assert cfg.alert_min_score == 0.8


# ===================================================================
# detect_clusters tests
# ===================================================================


class TestDetectClusters:
    @patch(_AUTH)
    def test_detect_clusters_calls_auth(self, mock_auth):
        detect_clusters({})
        mock_auth.assert_called_once()

    @patch(_AUTH)
    def test_detect_clusters_empty(self, mock_auth):
        result = detect_clusters({})
        assert result == []

    @patch(_AUTH)
    def test_detect_clusters_no_overlap(self, mock_auth):
        # Two wallets trade in completely different markets
        wt = {
            "0xaaa": [_make_trade(wallet="0xaaa", condition_id="0xm1", timestamp=100)],
            "0xbbb": [_make_trade(wallet="0xbbb", condition_id="0xm2", timestamp=100)],
        }
        result = detect_clusters(wt, min_overlap=1)
        assert result == []

    @patch(_AUTH)
    def test_detect_clusters_copy_chain(self, mock_auth):
        # Two wallets trade same 3 markets within <60s
        wt = {
            "0xaaa": [
                _make_trade(wallet="0xaaa", condition_id=f"0xm{i}", timestamp=1000 + i * 10)
                for i in range(3)
            ],
            "0xbbb": [
                _make_trade(wallet="0xbbb", condition_id=f"0xm{i}", timestamp=1000 + i * 10 + 5)
                for i in range(3)
            ],
        }
        result = detect_clusters(wt, min_overlap=3)
        assert len(result) == 1
        assert result[0].cluster_type == "copy_chain"
        assert result[0].avg_delay_secs < 60.0

    @patch(_AUTH)
    def test_detect_clusters_coordinated(self, mock_auth):
        # avg_delay between 60 and 300
        wt = {
            "0xaaa": [
                _make_trade(wallet="0xaaa", condition_id=f"0xm{i}", timestamp=1000 + i * 100)
                for i in range(3)
            ],
            "0xbbb": [
                _make_trade(wallet="0xbbb", condition_id=f"0xm{i}", timestamp=1000 + i * 100 + 120)
                for i in range(3)
            ],
        }
        result = detect_clusters(wt, min_overlap=3)
        assert len(result) == 1
        assert result[0].cluster_type == "coordinated"

    @patch(_AUTH)
    def test_detect_clusters_coincidence(self, mock_auth):
        # avg_delay > 300
        wt = {
            "0xaaa": [
                _make_trade(wallet="0xaaa", condition_id=f"0xm{i}", timestamp=1000 + i * 100)
                for i in range(3)
            ],
            "0xbbb": [
                _make_trade(wallet="0xbbb", condition_id=f"0xm{i}", timestamp=1000 + i * 100 + 500)
                for i in range(3)
            ],
        }
        result = detect_clusters(wt, min_overlap=3)
        assert len(result) == 1
        assert result[0].cluster_type == "coincidence"

    @patch(_AUTH)
    def test_detect_clusters_min_overlap_filter(self, mock_auth):
        # Only 2 shared markets, but min_overlap=3 -> no cluster
        wt = {
            "0xaaa": [
                _make_trade(wallet="0xaaa", condition_id="0xm0", timestamp=100),
                _make_trade(wallet="0xaaa", condition_id="0xm1", timestamp=200),
            ],
            "0xbbb": [
                _make_trade(wallet="0xbbb", condition_id="0xm0", timestamp=110),
                _make_trade(wallet="0xbbb", condition_id="0xm1", timestamp=210),
            ],
        }
        result = detect_clusters(wt, min_overlap=3)
        assert result == []

    @patch(_AUTH)
    def test_detect_clusters_cap_at_50(self, mock_auth):
        # Create enough wallet pairs to exceed 50 clusters
        wallets = {}
        for i in range(15):
            wallets[f"0x{i:04x}"] = [
                _make_trade(wallet=f"0x{i:04x}", condition_id=f"0xm{j}", timestamp=1000 + j)
                for j in range(4)
            ]
        result = detect_clusters(wallets, min_overlap=3)
        assert len(result) <= 50

    @patch(_AUTH)
    def test_detect_clusters_sorts_by_co_trade_count(self, mock_auth):
        wt = {
            "0xaaa": [
                _make_trade(wallet="0xaaa", condition_id=f"0xm{i}", timestamp=1000 + i)
                for i in range(5)
            ],
            "0xbbb": [
                _make_trade(wallet="0xbbb", condition_id=f"0xm{i}", timestamp=1000 + i + 5)
                for i in range(5)
            ],
            "0xccc": [
                _make_trade(wallet="0xccc", condition_id=f"0xm{i}", timestamp=1000 + i + 5)
                for i in range(3)
            ],
        }
        result = detect_clusters(wt, min_overlap=3)
        assert len(result) >= 2
        # First cluster should have highest co_trade_count
        assert result[0].co_trade_count >= result[1].co_trade_count

    @patch(_AUTH)
    def test_detect_clusters_shared_markets(self, mock_auth):
        wt = {
            "0xaaa": [
                _make_trade(wallet="0xaaa", condition_id=f"0xm{i}", timestamp=1000 + i)
                for i in range(3)
            ],
            "0xbbb": [
                _make_trade(wallet="0xbbb", condition_id=f"0xm{i}", timestamp=1005 + i)
                for i in range(3)
            ],
        }
        result = detect_clusters(wt, min_overlap=3)
        assert len(result) == 1
        assert set(result[0].shared_markets) == {"0xm0", "0xm1", "0xm2"}


# ===================================================================
# auto_target tests
# ===================================================================


class TestAutoTarget:
    @patch(_AUTH)
    def test_auto_target_calls_auth(self, mock_auth):
        snap = _make_snapshot()
        auto_target(snap)
        mock_auth.assert_called_once()

    @patch(_AUTH)
    def test_auto_target_copy_mode(self, mock_auth):
        top = [_make_ranked("0xaaa", score=0.8), _make_ranked("0xbbb", score=0.6)]
        snap = _make_snapshot(top_wallets=top)
        result = auto_target(snap, strategy="copy")
        assert result["mode"] == "copy"
        assert len(result["wallets"]) == 2

    @patch(_AUTH)
    def test_auto_target_fade_mode(self, mock_auth):
        bottom = [
            _make_ranked("0xaaa", score=-0.5, category="weak_hand"),
            _make_ranked("0xbbb", score=-0.4, category="weak_hand"),
        ]
        snap = _make_snapshot(bottom_wallets=bottom)
        result = auto_target(snap, strategy="fade")
        assert result["mode"] == "fade"
        assert len(result["wallets"]) == 2

    @patch(_AUTH)
    def test_auto_target_auto_mode_prefers_copy(self, mock_auth):
        # More copy targets than fade targets
        top = [_make_ranked(f"0x{i:04x}", score=0.8) for i in range(5)]
        bottom = [_make_ranked(f"0x{i:04x}", score=-0.5, category="weak_hand")
                  for i in range(2)]
        snap = _make_snapshot(top_wallets=top, bottom_wallets=bottom)
        result = auto_target(snap, strategy="auto")
        assert result["mode"] == "copy"

    @patch(_AUTH)
    def test_auto_target_auto_mode_prefers_fade(self, mock_auth):
        # More fade targets than copy targets
        top = [_make_ranked(f"0x{i:04x}", score=0.8) for i in range(2)]
        bottom = [_make_ranked(f"0x{i:04x}", score=-0.5, category="weak_hand")
                  for i in range(5)]
        snap = _make_snapshot(top_wallets=top, bottom_wallets=bottom)
        result = auto_target(snap, strategy="auto")
        assert result["mode"] == "fade"

    @patch(_AUTH)
    def test_auto_target_no_targets(self, mock_auth):
        # All wallets in the gray zone (score between -0.3 and 0.5)
        top = [_make_ranked("0xaaa", score=0.3)]
        bottom = [_make_ranked("0xbbb", score=-0.1, category="neutral")]
        snap = _make_snapshot(top_wallets=top, bottom_wallets=bottom)
        result = auto_target(snap, strategy="auto")
        assert result["wallets"] == []

    @patch(_AUTH)
    def test_auto_target_returns_dict(self, mock_auth):
        snap = _make_snapshot()
        result = auto_target(snap)
        assert "mode" in result
        assert "wallets" in result
        assert "reasoning" in result
        assert "config" in result

    @patch(_AUTH)
    def test_auto_target_explicit_strategy(self, mock_auth):
        top = [_make_ranked("0xaaa", score=0.8)]
        snap = _make_snapshot(top_wallets=top)
        result = auto_target(snap, strategy="copy")
        assert result["mode"] == "copy"
        assert result["config"]["inverse"] is False


# ===================================================================
# scan_galaxy tests
# ===================================================================


class TestScanGalaxy:
    @patch(_AUTH)
    @patch("horizon.whale_galaxy.top_markets", return_value=[])
    def test_scan_galaxy_calls_auth(self, mock_top, mock_auth):
        scan_galaxy(top_n=5)
        mock_auth.assert_called_once()

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.get_wallet_trades", return_value=[])
    @patch("horizon.whale_galaxy._cached_score")
    @patch("horizon.whale_galaxy.get_top_holders")
    @patch("horizon.whale_galaxy.top_markets")
    def test_scan_galaxy_basic(self, mock_top, mock_holders, mock_score,
                               mock_wt, mock_auth):
        market = MagicMock()
        market.condition_id = "0xcond1"
        market.id = "0xcond1"
        mock_top.return_value = [market]
        mock_holders.return_value = [_make_holder("0xaaa"), _make_holder("0xbbb")]
        mock_score.return_value = _make_score(trade_count=50)
        snap = scan_galaxy(top_n=5)
        assert isinstance(snap, GalaxySnapshot)
        assert snap.market_count >= 1

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.get_wallet_trades", return_value=[])
    @patch("horizon.whale_galaxy._cached_score")
    @patch("horizon.whale_galaxy.get_top_holders")
    def test_scan_galaxy_with_markets(self, mock_holders, mock_score,
                                      mock_wt, mock_auth):
        mock_holders.return_value = [_make_holder("0xaaa")]
        mock_score.return_value = _make_score(trade_count=50)
        snap = scan_galaxy(markets=["0xcond1", "0xcond2"], top_n=5)
        assert isinstance(snap, GalaxySnapshot)
        assert snap.market_count == 2

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.get_wallet_trades", return_value=[])
    @patch("horizon.whale_galaxy._cached_score")
    @patch("horizon.whale_galaxy.get_top_holders")
    @patch("horizon.whale_galaxy.top_markets")
    def test_scan_galaxy_top_n(self, mock_top, mock_holders, mock_score,
                                mock_wt, mock_auth):
        market = MagicMock()
        market.condition_id = "0xcond1"
        market.id = "0xcond1"
        mock_top.return_value = [market]
        # Create many holders
        mock_holders.return_value = [
            _make_holder(f"0x{i:04x}", 100.0 - i) for i in range(30)
        ]
        mock_score.return_value = _make_score(trade_count=50)
        snap = scan_galaxy(top_n=5)
        assert len(snap.top_wallets) <= 5

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.get_wallet_trades", return_value=[])
    @patch("horizon.whale_galaxy._cached_score")
    @patch("horizon.whale_galaxy.get_top_holders")
    @patch("horizon.whale_galaxy.top_markets")
    def test_scan_galaxy_min_trades_filter(self, mock_top, mock_holders,
                                           mock_score, mock_wt, mock_auth):
        market = MagicMock()
        market.condition_id = "0xcond1"
        market.id = "0xcond1"
        mock_top.return_value = [market]
        mock_holders.return_value = [_make_holder("0xaaa")]
        # trade_count < min_trades => wallet filtered
        mock_score.return_value = _make_score(trade_count=2)
        snap = scan_galaxy(top_n=5, min_trades=10)
        assert snap.top_wallets == []

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.get_wallet_trades", return_value=[])
    @patch("horizon.whale_galaxy._cached_score")
    @patch("horizon.whale_galaxy.get_top_holders")
    def test_scan_galaxy_categorizes_wallets(self, mock_holders, mock_score,
                                              mock_wt, mock_auth):
        mock_holders.return_value = [_make_holder("0xaaa")]
        mock_score.return_value = _make_score(composite=0.8, trade_count=50)
        snap = scan_galaxy(markets=["0xcond1"], top_n=5)
        if snap.top_wallets:
            assert snap.top_wallets[0].edge_category == "smart_money"

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.get_wallet_trades", return_value=[])
    @patch("horizon.whale_galaxy._cached_score")
    @patch("horizon.whale_galaxy.get_top_holders")
    def test_scan_galaxy_sorts_by_score(self, mock_holders, mock_score,
                                         mock_wt, mock_auth):
        mock_holders.return_value = [
            _make_holder("0xaaa", 100.0),
            _make_holder("0xbbb", 200.0),
        ]
        scores = {
            "0xaaa": _make_score(wallet="0xaaa", composite=0.3, trade_count=50),
            "0xbbb": _make_score(wallet="0xbbb", composite=0.9, trade_count=50),
        }
        mock_score.side_effect = lambda addr, **kw: scores.get(addr)
        snap = scan_galaxy(markets=["0xcond1"], top_n=5)
        if len(snap.top_wallets) >= 2:
            assert snap.top_wallets[0].score >= snap.top_wallets[1].score

    @patch(_AUTH)
    @patch("horizon.whale_galaxy._generate_alerts", return_value=[])
    @patch("horizon.whale_galaxy.detect_clusters")
    @patch("horizon.whale_galaxy.get_wallet_trades", return_value=[])
    @patch("horizon.whale_galaxy._cached_score")
    @patch("horizon.whale_galaxy.get_top_holders")
    def test_scan_galaxy_includes_clusters(self, mock_holders, mock_score,
                                            mock_wt, mock_detect, mock_alerts,
                                            mock_auth):
        mock_holders.return_value = [_make_holder("0xaaa")]
        mock_score.return_value = _make_score(trade_count=50)
        cluster = WalletCluster(
            wallets=["0xaaa", "0xbbb"], co_trade_count=5,
            avg_delay_secs=10.0, cluster_type="copy_chain",
            shared_markets=["0xcond1"],
        )
        mock_detect.return_value = [cluster]
        snap = scan_galaxy(markets=["0xcond1"], top_n=5)
        assert snap.clusters == [cluster]

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.detect_clusters", return_value=[])
    @patch("horizon.whale_galaxy._generate_alerts")
    @patch("horizon.whale_galaxy.get_wallet_trades", return_value=[])
    @patch("horizon.whale_galaxy._cached_score")
    @patch("horizon.whale_galaxy.get_top_holders")
    def test_scan_galaxy_includes_alerts(self, mock_holders, mock_score,
                                          mock_wt, mock_alerts, mock_detect,
                                          mock_auth):
        mock_holders.return_value = [_make_holder("0xaaa")]
        mock_score.return_value = _make_score(trade_count=50)
        alert = WhaleAlert(
            wallet="0xaaa", pseudonym="Whale", market_id="0xcond1",
            market_title="Test", action="entered_long", size_usdc=5000.0,
            significance=3.5, timestamp=1000.0,
        )
        mock_alerts.return_value = [alert]
        snap = scan_galaxy(markets=["0xcond1"], top_n=5)
        assert snap.alerts == [alert]

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.top_markets", side_effect=RuntimeError("api down"))
    def test_scan_galaxy_handles_errors(self, mock_top, mock_auth):
        snap = scan_galaxy(top_n=5)
        assert isinstance(snap, GalaxySnapshot)
        assert snap.market_count == 0
        assert snap.top_wallets == []


# ===================================================================
# galaxy_tracker tests
# ===================================================================


class TestGalaxyTracker:
    @patch(_AUTH)
    def test_galaxy_tracker_calls_auth(self, mock_auth):
        galaxy_tracker()
        mock_auth.assert_called_once()

    @patch(_AUTH)
    def test_galaxy_tracker_returns_callable(self, mock_auth):
        fn = galaxy_tracker()
        assert callable(fn)

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

    @patch(_AUTH)
    def test_galaxy_tracker_no_engine_skip(self, mock_auth):
        fn = galaxy_tracker(scan_interval_cycles=1)
        ctx = MagicMock()
        ctx.params = {}  # no "engine" key
        # Should not raise
        fn(ctx)

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.scan_galaxy")
    def test_galaxy_tracker_scan_interval(self, mock_scan, mock_auth):
        fn = galaxy_tracker(scan_interval_cycles=5)
        ctx = _mock_ctx()

        # Cycles 1-4: no scan
        for _ in range(4):
            fn(ctx)
        mock_scan.assert_not_called()

        # Cycle 5: scan fires
        mock_scan.return_value = _make_snapshot()
        fn(ctx)
        mock_scan.assert_called_once()

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.scan_galaxy")
    def test_galaxy_tracker_injects_snapshot(self, mock_scan, mock_auth):
        snap = _make_snapshot()
        mock_scan.return_value = snap
        fn = galaxy_tracker(scan_interval_cycles=1)
        ctx = _mock_ctx()
        fn(ctx)
        assert ctx.params["galaxy_snapshot"] is snap

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.scan_galaxy")
    def test_galaxy_tracker_injects_alerts(self, mock_scan, mock_auth):
        alert = WhaleAlert(
            wallet="0xaaa", pseudonym="W", market_id="0xcond1",
            market_title="T", action="entered_long", size_usdc=1000.0,
            significance=2.0, timestamp=1000.0,
        )
        snap = GalaxySnapshot(
            top_wallets=[], bottom_wallets=[], clusters=[],
            alerts=[alert], scan_time=1.0, market_count=1, wallet_count=1,
        )
        mock_scan.return_value = snap
        fn = galaxy_tracker(scan_interval_cycles=1)
        ctx = _mock_ctx()
        fn(ctx)
        assert ctx.params["galaxy_alerts"] == [alert]

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.scan_galaxy", side_effect=RuntimeError("boom"))
    def test_galaxy_tracker_handles_scan_error(self, mock_scan, mock_auth):
        fn = galaxy_tracker(scan_interval_cycles=1)
        ctx = _mock_ctx()
        # Should not raise
        fn(ctx)
        assert "galaxy_snapshot" not in ctx.params


# ===================================================================
# galaxy_hunt tests
# ===================================================================


class TestGalaxyHunt:
    @patch(_AUTH)
    @patch("horizon.whale_galaxy.scan_galaxy")
    def test_galaxy_hunt_calls_auth(self, mock_scan, mock_auth):
        mock_scan.return_value = _make_snapshot()
        galaxy_hunt(markets=["0xcond1"], dry_run=True)
        mock_auth.assert_called()

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.auto_target")
    @patch("horizon.whale_galaxy.scan_galaxy")
    def test_galaxy_hunt_dry_run(self, mock_scan, mock_target, mock_auth):
        top = [_make_ranked("0xaaa", score=0.8)]
        snap = _make_snapshot(top_wallets=top)
        mock_scan.return_value = snap
        mock_target.return_value = {
            "mode": "copy",
            "wallets": ["0xaaa"],
            "reasoning": "test",
            "config": {"size_scale": 0.5, "max_position": 1000.0,
                       "poll_interval": 30.0, "inverse": False},
        }
        # dry_run should log but not launch copy_trades
        galaxy_hunt(markets=["0xcond1"], dry_run=True)

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.auto_target")
    @patch("horizon.whale_galaxy.scan_galaxy")
    def test_galaxy_hunt_no_targets(self, mock_scan, mock_target, mock_auth):
        mock_scan.return_value = _make_snapshot()
        mock_target.return_value = {
            "mode": "copy",
            "wallets": [],
            "reasoning": "none",
            "config": {},
        }
        # Should return without calling copy_trades
        galaxy_hunt(markets=["0xcond1"])

    @patch(_AUTH)
    @patch("horizon.copy_trader.copy_trades")
    @patch("horizon.whale_galaxy.Engine")
    @patch("horizon.whale_galaxy.auto_target")
    @patch("horizon.whale_galaxy.scan_galaxy")
    def test_galaxy_hunt_copy_mode(self, mock_scan, mock_target, mock_engine,
                                    mock_copy, mock_auth):
        top = [_make_ranked("0xaaa", score=0.8)]
        snap = _make_snapshot(top_wallets=top)
        mock_scan.return_value = snap
        mock_target.return_value = {
            "mode": "copy",
            "wallets": ["0xaaa"],
            "reasoning": "copying",
            "config": {"size_scale": 0.5, "max_position": 1000.0,
                       "poll_interval": 30.0, "inverse": False},
        }
        galaxy_hunt(markets=["0xcond1"])
        mock_copy.assert_called_once()
        call_kwargs = mock_copy.call_args
        assert call_kwargs.kwargs.get("inverse") is False or not call_kwargs[1].get("inverse", True)

    @patch(_AUTH)
    @patch("horizon.copy_trader.copy_trades")
    @patch("horizon.whale_galaxy.Engine")
    @patch("horizon.whale_galaxy.auto_target")
    @patch("horizon.whale_galaxy.scan_galaxy")
    def test_galaxy_hunt_fade_mode(self, mock_scan, mock_target, mock_engine,
                                    mock_copy, mock_auth):
        bottom = [_make_ranked("0xaaa", score=-0.5, category="weak_hand")]
        snap = _make_snapshot(bottom_wallets=bottom)
        mock_scan.return_value = snap
        mock_target.return_value = {
            "mode": "fade",
            "wallets": ["0xaaa"],
            "reasoning": "fading",
            "config": {"size_scale": 0.3, "max_position": 500.0,
                       "poll_interval": 30.0, "inverse": True},
        }
        galaxy_hunt(markets=["0xcond1"])
        mock_copy.assert_called_once()
        _, kwargs = mock_copy.call_args
        assert kwargs.get("inverse") is True

    @patch(_AUTH)
    @patch("horizon.whale_galaxy.auto_target")
    @patch("horizon.whale_galaxy.scan_galaxy")
    def test_galaxy_hunt_custom_params(self, mock_scan, mock_target, mock_auth):
        mock_scan.return_value = _make_snapshot()
        mock_target.return_value = {
            "mode": "copy", "wallets": [], "reasoning": "none", "config": {},
        }
        galaxy_hunt(markets=["0xcond1"], top_n=3, size_scale=0.2, dry_run=True)
        mock_scan.assert_called_once()
        _, kwargs = mock_scan.call_args
        assert kwargs.get("top_n") == 3


# ===================================================================
# _generate_alerts tests
# ===================================================================


class TestGenerateAlerts:
    def test_generate_alerts_above_threshold(self):
        trades = {
            "0xaaa": [_make_trade(
                wallet="0xaaa", condition_id="0xcond1", size=200.0,
                price=0.5, timestamp=1000,
            )],
        }
        # usdc_size = 200 * 0.5 = 100, but alert_min_size_usdc default = 500
        # Use large enough trade
        big_trades = {
            "0xaaa": [Trade(
                wallet="0xaaa", side="BUY", outcome="Yes", size=2000.0,
                price=0.5, usdc_size=1000.0, timestamp=1000,
                market_slug="test", market_title="Test",
                condition_id="0xcond1", token_id="0xtok1",
                tx_hash=None, pseudonym="Whale",
            )],
        }
        scores = {"0xaaa": _make_score(composite=0.8)}
        config = GalaxyConfig(alert_min_score=0.3, alert_min_size_usdc=500.0)
        alerts = _generate_alerts(big_trades, scores, config)
        assert len(alerts) == 1
        assert alerts[0].wallet == "0xaaa"

    def test_generate_alerts_below_threshold(self):
        trades = {
            "0xaaa": [Trade(
                wallet="0xaaa", side="BUY", outcome="Yes", size=10.0,
                price=0.5, usdc_size=5.0, timestamp=1000,
                market_slug="test", market_title="Test",
                condition_id="0xcond1", token_id="0xtok1",
                tx_hash=None, pseudonym=None,
            )],
        }
        scores = {"0xaaa": _make_score(composite=0.1)}  # below alert_min_score
        config = GalaxyConfig(alert_min_score=0.3)
        alerts = _generate_alerts(trades, scores, config)
        assert len(alerts) == 0

    def test_generate_alerts_significance_sorting(self):
        t1 = Trade(
            wallet="0xaaa", side="BUY", outcome="Yes", size=2000.0,
            price=0.5, usdc_size=1000.0, timestamp=1000,
            market_slug="test", market_title="T1",
            condition_id="0xcond1", token_id="0xtok1",
            tx_hash=None, pseudonym=None,
        )
        t2 = Trade(
            wallet="0xbbb", side="BUY", outcome="Yes", size=20000.0,
            price=0.5, usdc_size=10000.0, timestamp=2000,
            market_slug="test", market_title="T2",
            condition_id="0xcond2", token_id="0xtok2",
            tx_hash=None, pseudonym=None,
        )
        trades = {"0xaaa": [t1], "0xbbb": [t2]}
        scores = {
            "0xaaa": _make_score(wallet="0xaaa", composite=0.5),
            "0xbbb": _make_score(wallet="0xbbb", composite=0.5),
        }
        config = GalaxyConfig(alert_min_score=0.3, alert_min_size_usdc=500.0)
        alerts = _generate_alerts(trades, scores, config)
        assert len(alerts) == 2
        # Higher significance first
        assert alerts[0].significance >= alerts[1].significance

    def test_generate_alerts_cap_at_50(self):
        trades = {}
        scores = {}
        for i in range(60):
            addr = f"0x{i:04x}"
            trades[addr] = [Trade(
                wallet=addr, side="BUY", outcome="Yes", size=2000.0,
                price=0.5, usdc_size=1000.0, timestamp=1000 + i,
                market_slug="test", market_title=f"Market {i}",
                condition_id=f"0xcond{i}", token_id="0xtok1",
                tx_hash=None, pseudonym=None,
            )]
            scores[addr] = _make_score(wallet=addr, composite=0.8)
        config = GalaxyConfig(alert_min_score=0.3, alert_min_size_usdc=500.0)
        alerts = _generate_alerts(trades, scores, config)
        assert len(alerts) <= 50


# ===================================================================
# Edge case tests
# ===================================================================


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

    @patch("horizon.whale_galaxy.score_wallet")
    def test_score_cache_ttl(self, mock_score):
        ws = _make_score()
        mock_score.return_value = ws
        # Import and call _cached_score directly
        from horizon.whale_galaxy import _cached_score
        result1 = _cached_score("0xaaa")
        assert result1 is ws
        # Second call should use cache
        result2 = _cached_score("0xaaa")
        assert result2 is ws
        assert mock_score.call_count == 1

    def test_categorize_smart_money(self):
        ws = _make_score(composite=0.7)
        assert _categorize_wallet(ws) == "smart_money"

    def test_categorize_weak_hand(self):
        ws = _make_score(composite=-0.2)
        assert _categorize_wallet(ws) == "weak_hand"
