"""Tests for wallet intelligence: scoring, adversarial analysis, bot detection."""

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 (
    BotDetection,
    WalletAnalysis,
    WalletPattern,
    WalletScore,
    analyze_wallet,
    bot_scanner,
    reverse_copier,
    scan_bots,
    score_wallet,
)

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

_AUTH = "horizon._horizon.auth_require_pro"


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


def _make_trade(
    wallet: str = "0xaaa",
    side: str = "BUY",
    price: float = 0.5,
    size: float = 10.0,
    timestamp: int = 1000,
    condition_id: str = "0xcond1",
    **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="Test Market",
        condition_id=condition_id,
        token_id="0xtok1",
        tx_hash=None,
        pseudonym=None,
    )
    defaults.update(kwargs)
    return Trade(**defaults)


# ===================================================================
# score_wallet tests
# ===================================================================


class TestScoreWallet:
    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_wallet_positions")
    def test_profitable_wallet_positive_score(self, mock_pos, mock_auth):
        """Profitable wallet should have positive composite score."""
        mock_pos.return_value = [
            _make_position(pnl=100, pnl_percent=20.0),
            _make_position(pnl=50, pnl_percent=10.0),
            _make_position(pnl=30, pnl_percent=5.0),
            _make_position(pnl=-10, pnl_percent=-2.0),
        ]
        score = score_wallet("0xaaa")
        assert score.composite_score > 0
        assert score.win_rate == 0.75
        assert score.total_pnl == 170.0
        assert score.position_count == 4

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_wallet_positions")
    def test_losing_wallet_negative_score(self, mock_pos, mock_auth):
        """Losing wallet should have negative composite score."""
        mock_pos.return_value = [
            _make_position(pnl=-100, pnl_percent=-20.0),
            _make_position(pnl=-50, pnl_percent=-15.0),
            _make_position(pnl=-30, pnl_percent=-10.0),
            _make_position(pnl=5, pnl_percent=1.0),
        ]
        score = score_wallet("0xaaa")
        assert score.composite_score < 0
        assert score.win_rate == 0.25
        assert score.total_pnl == -175.0

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_wallet_positions")
    def test_no_positions_zero_score(self, mock_pos, mock_auth):
        """Empty positions should give zero score."""
        mock_pos.return_value = []
        score = score_wallet("0xaaa")
        assert score.composite_score == 0.0
        assert score.trade_count == 0
        assert score.position_count == 0
        assert score.win_rate == 0.0

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_wallet_positions")
    def test_mixed_positions_correct_winrate(self, mock_pos, mock_auth):
        """Win rate should be correctly calculated."""
        mock_pos.return_value = [
            _make_position(pnl=10, pnl_percent=5.0),
            _make_position(pnl=-10, pnl_percent=-5.0),
        ]
        score = score_wallet("0xaaa")
        assert score.win_rate == 0.5

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_wallet_positions")
    def test_single_position_sharpe_zero(self, mock_pos, mock_auth):
        """Single position should have sharpe=0 (need >1 for stddev)."""
        mock_pos.return_value = [
            _make_position(pnl=10, pnl_percent=5.0),
        ]
        score = score_wallet("0xaaa")
        assert score.sharpe == 0.0

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_wallet_positions")
    def test_composite_score_clamped(self, mock_pos, mock_auth):
        """Composite score should be in [-1, 1] range (tanh)."""
        mock_pos.return_value = [
            _make_position(pnl=10000, pnl_percent=500.0),
        ] * 50
        score = score_wallet("0xaaa")
        assert -1.0 <= score.composite_score <= 1.0


# ===================================================================
# analyze_wallet tests
# ===================================================================


class TestAnalyzeWallet:
    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_wallet_trades")
    def test_regular_timing_exploitable(self, mock_trades, mock_auth):
        """Low timing CV should be detected as exploitable."""
        # Regular timing: trades every 100 seconds
        trades = [
            _make_trade(timestamp=1000 + i * 100) for i in range(20)
        ]
        mock_trades.return_value = trades
        report = analyze_wallet("0xaaa")
        timing = next((p for p in report.patterns if p.name == "timing_regularity"), None)
        assert timing is not None
        assert timing.exploitable is True
        assert timing.value < 0.3

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_wallet_trades")
    def test_irregular_timing_not_exploitable(self, mock_trades, mock_auth):
        """High timing CV should not be flagged as exploitable."""
        # Irregular timing: random intervals
        import random
        random.seed(42)
        timestamps = sorted([1000 + random.randint(0, 100000) for _ in range(20)])
        trades = [_make_trade(timestamp=ts) for ts in timestamps]
        mock_trades.return_value = trades
        report = analyze_wallet("0xaaa")
        timing = next((p for p in report.patterns if p.name == "timing_regularity"), None)
        assert timing is not None
        assert timing.exploitable is False

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_wallet_trades")
    def test_size_regularity_detected(self, mock_trades, mock_auth):
        """Constant trade sizes should be detected."""
        trades = [
            _make_trade(size=10.0, timestamp=1000 + i * 100) for i in range(20)
        ]
        mock_trades.return_value = trades
        report = analyze_wallet("0xaaa")
        sizing = next((p for p in report.patterns if p.name == "sizing_predictability"), None)
        assert sizing is not None
        assert sizing.exploitable is True

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_wallet_trades")
    def test_directional_bias_heavy_buyer(self, mock_trades, mock_auth):
        """Heavy buyer should be detected as biased."""
        trades = [_make_trade(side="BUY", timestamp=1000 + i * 100) for i in range(18)]
        trades += [_make_trade(side="SELL", timestamp=2800 + i * 100) for i in range(2)]
        mock_trades.return_value = trades
        report = analyze_wallet("0xaaa")
        bias = next((p for p in report.patterns if p.name == "directional_bias"), None)
        assert bias is not None
        assert bias.exploitable is True
        assert bias.value > 0.75  # buy_ratio

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_wallet_trades")
    def test_balanced_trading_not_biased(self, mock_trades, mock_auth):
        """Balanced trading should not be flagged as biased."""
        trades = [_make_trade(side="BUY", timestamp=1000 + i * 200) for i in range(10)]
        trades += [_make_trade(side="SELL", timestamp=1100 + i * 200) for i in range(10)]
        mock_trades.return_value = trades
        report = analyze_wallet("0xaaa")
        bias = next((p for p in report.patterns if p.name == "directional_bias"), None)
        assert bias is not None
        assert bias.exploitable is False

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_wallet_trades")
    def test_concentrated_markets_detected(self, mock_trades, mock_auth):
        """Trading in a single market should have high Herfindahl."""
        trades = [
            _make_trade(condition_id="0xcond1", timestamp=1000 + i * 100)
            for i in range(20)
        ]
        mock_trades.return_value = trades
        report = analyze_wallet("0xaaa")
        conc = next((p for p in report.patterns if p.name == "concentration"), None)
        assert conc is not None
        assert conc.exploitable is True
        assert conc.value == 1.0  # single market -> HHI = 1.0

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_wallet_trades")
    def test_empty_trades_empty_patterns(self, mock_trades, mock_auth):
        """No trades should give empty patterns."""
        mock_trades.return_value = []
        report = analyze_wallet("0xaaa")
        assert report.patterns == []
        assert report.vulnerability_score == 0.0
        assert report.trade_count == 0

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_wallet_trades")
    def test_vulnerability_score_is_exploitable_fraction(self, mock_trades, mock_auth):
        """Vulnerability score should be fraction of exploitable patterns."""
        # All regular -> all exploitable
        trades = [
            _make_trade(size=10.0, side="BUY", timestamp=1000 + i * 100, condition_id="0xcond1")
            for i in range(20)
        ]
        mock_trades.return_value = trades
        report = analyze_wallet("0xaaa")
        if report.patterns:
            exploitable_count = sum(1 for p in report.patterns if p.exploitable)
            expected = exploitable_count / len(report.patterns)
            assert abs(report.vulnerability_score - expected) < 0.01


# ===================================================================
# scan_bots tests
# ===================================================================


class TestScanBots:
    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_market_trades")
    def test_market_maker_pattern(self, mock_trades, mock_auth):
        """Balanced, regular, high-frequency wallet should be classified as MM."""
        trades = []
        for i in range(60):
            side = "BUY" if i % 2 == 0 else "SELL"
            trades.append(_make_trade(
                wallet="0xmm",
                side=side,
                size=10.0,
                price=0.50,
                timestamp=1000 + i * 10,  # every 10 seconds
            ))
        mock_trades.return_value = trades
        results = scan_bots("0xcond1", min_trades=5)
        assert len(results) >= 1
        mm = next((r for r in results if r.wallet == "0xmm"), None)
        assert mm is not None
        assert mm.is_bot is True
        assert mm.strategy_type == "market_maker"

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_market_trades")
    def test_momentum_bot(self, mock_trades, mock_auth):
        """Directional, high-frequency wallet should be classified as momentum."""
        trades = [
            _make_trade(
                wallet="0xmom",
                side="BUY",
                size=10.0 + i * 0.5,
                timestamp=1000 + i * 30,
            )
            for i in range(30)
        ]
        mock_trades.return_value = trades
        results = scan_bots("0xcond1", min_trades=5)
        mom = next((r for r in results if r.wallet == "0xmom"), None)
        assert mom is not None
        assert mom.is_bot is True
        assert mom.strategy_type == "momentum"

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_market_trades")
    def test_human_trader(self, mock_trades, mock_auth):
        """Irregular timing, varied sizes, low frequency = human."""
        import random
        random.seed(42)
        trades = [
            _make_trade(
                wallet="0xhuman",
                side=random.choice(["BUY", "SELL"]),
                size=random.uniform(5, 100),
                timestamp=1000 + int(random.uniform(0, 86400)),
            )
            for _ in range(10)
        ]
        mock_trades.return_value = trades
        results = scan_bots("0xcond1", min_trades=5)
        human = next((r for r in results if r.wallet == "0xhuman"), None)
        assert human is not None
        assert human.strategy_type == "human"

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_market_trades")
    def test_sniper_pattern(self, mock_trades, mock_auth):
        """Few fast large trades should be classified as sniper."""
        trades = [
            _make_trade(
                wallet="0xsniper",
                side="BUY",
                size=500.0,
                timestamp=1000 + i,  # 1 second apart
            )
            for i in range(8)
        ]
        mock_trades.return_value = trades
        results = scan_bots("0xcond1", min_trades=5)
        sniper = next((r for r in results if r.wallet == "0xsniper"), None)
        assert sniper is not None
        assert sniper.is_bot is True
        assert sniper.strategy_type == "sniper"

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_market_trades")
    def test_grid_bot_pattern(self, mock_trades, mock_auth):
        """Evenly spaced sizes, balanced, regular = grid bot."""
        trades = []
        for i in range(40):
            side = "BUY" if i % 2 == 0 else "SELL"
            trades.append(_make_trade(
                wallet="0xgrid",
                side=side,
                size=10.0,  # constant
                price=0.50,
                timestamp=1000 + i * 60,  # every minute
            ))
        mock_trades.return_value = trades
        results = scan_bots("0xcond1", min_trades=5)
        grid = next((r for r in results if r.wallet == "0xgrid"), None)
        assert grid is not None
        assert grid.is_bot is True
        # Could be market_maker or grid_bot (both balanced + regular)
        assert grid.strategy_type in ("market_maker", "grid_bot")

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_market_trades")
    def test_wallet_below_min_trades_excluded(self, mock_trades, mock_auth):
        """Wallet with fewer trades than min_trades should be excluded."""
        trades = [
            _make_trade(wallet="0xfew", timestamp=1000 + i * 100) for i in range(3)
        ]
        mock_trades.return_value = trades
        results = scan_bots("0xcond1", min_trades=5)
        assert len(results) == 0

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_market_trades")
    def test_empty_market_empty_results(self, mock_trades, mock_auth):
        """Empty market should return empty results."""
        mock_trades.return_value = []
        results = scan_bots("0xcond1")
        assert results == []

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_market_trades")
    def test_confidence_scoring_range(self, mock_trades, mock_auth):
        """All confidence scores should be in [0, 1]."""
        trades = []
        for w in ["0xw1", "0xw2", "0xw3"]:
            for i in range(10):
                trades.append(_make_trade(
                    wallet=w,
                    side="BUY" if i % 2 == 0 else "SELL",
                    size=10.0 + i,
                    timestamp=1000 + i * 60,
                ))
        mock_trades.return_value = trades
        results = scan_bots("0xcond1", min_trades=5)
        for r in results:
            assert 0.0 <= r.confidence <= 1.0

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_market_trades")
    def test_strategy_types_valid(self, mock_trades, mock_auth):
        """All strategy types should be valid."""
        valid_types = {"market_maker", "momentum", "mean_reversion", "copy_bot",
                       "grid_bot", "sniper", "human"}
        trades = []
        for w in ["0xw1", "0xw2"]:
            for i in range(10):
                trades.append(_make_trade(
                    wallet=w,
                    side="BUY" if i % 2 == 0 else "SELL",
                    size=10.0,
                    timestamp=1000 + i * 100,
                ))
        mock_trades.return_value = trades
        results = scan_bots("0xcond1", min_trades=5)
        for r in results:
            assert r.strategy_type in valid_types

    @patch(_AUTH)
    @patch("horizon.wallet_intel.get_market_trades")
    def test_sorted_by_confidence_descending(self, mock_trades, mock_auth):
        """Results should be sorted by confidence descending."""
        trades = []
        # Regular bot
        for i in range(30):
            trades.append(_make_trade(
                wallet="0xbot",
                side="BUY" if i % 2 == 0 else "SELL",
                size=10.0,
                timestamp=1000 + i * 10,
            ))
        # Human-like
        import random
        random.seed(99)
        for i in range(10):
            trades.append(_make_trade(
                wallet="0xhum",
                side=random.choice(["BUY", "SELL"]),
                size=random.uniform(1, 200),
                timestamp=1000 + int(random.uniform(0, 50000)),
            ))
        mock_trades.return_value = trades
        results = scan_bots("0xcond1", min_trades=5)
        if len(results) >= 2:
            for i in range(len(results) - 1):
                assert results[i].confidence >= results[i + 1].confidence


# ===================================================================
# reverse_copy tests
# ===================================================================


class TestReverseCopy:
    @patch(_AUTH)
    @patch("horizon.wallet_intel.score_wallet")
    @patch("horizon.copy_trader.copy_trades")
    def test_bad_wallet_triggers_inverse(self, mock_copy, mock_score, mock_auth):
        """Bad wallet (score < threshold) should trigger inverse copy."""
        mock_score.return_value = WalletScore(
            wallet="0xbad",
            win_rate=0.2,
            avg_pnl_pct=-10.0,
            sharpe=-1.0,
            total_pnl=-500.0,
            trade_count=50,
            position_count=20,
            composite_score=-0.6,
        )
        from horizon.wallet_intel import reverse_copy
        reverse_copy("0xbad", threshold=-0.3, dry_run=True)
        mock_copy.assert_called_once()
        call_kwargs = mock_copy.call_args[1]
        assert call_kwargs["inverse"] is True
        assert call_kwargs["wallet"] == "0xbad"

    @patch(_AUTH)
    @patch("horizon.wallet_intel.score_wallet")
    @patch("horizon.copy_trader.copy_trades")
    def test_good_wallet_not_copied(self, mock_copy, mock_score, mock_auth):
        """Good wallet (score > threshold) should not trigger copy."""
        mock_score.return_value = WalletScore(
            wallet="0xgood",
            win_rate=0.7,
            avg_pnl_pct=10.0,
            sharpe=1.5,
            total_pnl=1000.0,
            trade_count=100,
            position_count=40,
            composite_score=0.7,
        )
        from horizon.wallet_intel import reverse_copy
        reverse_copy("0xgood", threshold=-0.3)
        mock_copy.assert_not_called()

    @patch(_AUTH)
    @patch("horizon.wallet_intel.score_wallet")
    @patch("horizon.copy_trader.copy_trader")
    def test_pipeline_mode_integration(self, mock_copy_trader, mock_score, mock_auth):
        """Pipeline reverse_copier should create inner copy_trader with inverse=True."""
        mock_score.return_value = WalletScore(
            wallet="0xbad",
            win_rate=0.2,
            avg_pnl_pct=-10.0,
            sharpe=-1.0,
            total_pnl=-500.0,
            trade_count=50,
            position_count=20,
            composite_score=-0.6,
        )
        inner = MagicMock()
        mock_copy_trader.return_value = inner

        fn = reverse_copier("0xbad", threshold=-0.3)
        ctx = MagicMock()
        fn(ctx)

        mock_copy_trader.assert_called_once()
        call_kwargs = mock_copy_trader.call_args[1]
        assert call_kwargs["inverse"] is True
        inner.assert_called_once_with(ctx)

    @patch(_AUTH)
    @patch("horizon.wallet_intel.score_wallet")
    @patch("horizon.copy_trader.copy_trades")
    def test_dry_run_mode(self, mock_copy, mock_score, mock_auth):
        """Dry run should be passed through to copy_trades."""
        mock_score.return_value = WalletScore(
            wallet="0xbad",
            win_rate=0.2,
            avg_pnl_pct=-10.0,
            sharpe=-1.0,
            total_pnl=-500.0,
            trade_count=50,
            position_count=20,
            composite_score=-0.6,
        )
        from horizon.wallet_intel import reverse_copy
        reverse_copy("0xbad", threshold=-0.3, dry_run=True)
        call_kwargs = mock_copy.call_args[1]
        assert call_kwargs["dry_run"] is True


# ===================================================================
# bot_scanner pipeline tests
# ===================================================================


class TestBotScannerPipeline:
    @patch(_AUTH)
    @patch("horizon.wallet_intel.scan_bots")
    def test_results_stored_in_ctx_params(self, mock_scan, mock_auth):
        """Results should be stored in ctx.params."""
        mock_scan.return_value = [
            BotDetection(
                wallet="0xbot",
                is_bot=True,
                confidence=0.85,
                strategy_type="market_maker",
                evidence=["Balanced"],
                trade_count=50,
                avg_interval_seconds=10.0,
                size_regularity=0.9,
            )
        ]
        fn = bot_scanner(min_trades=5)
        ctx = MagicMock()
        ctx.market_ids = ["0xcond1"]
        ctx.params = {}
        fn(ctx)
        assert "bot_scan_results" in ctx.params
        assert len(ctx.params["bot_scan_results"]) == 1
        assert ctx.params["bot_scan_results"][0].wallet == "0xbot"

    @patch(_AUTH)
    @patch("horizon.wallet_intel.scan_bots")
    def test_returns_none(self, mock_scan, mock_auth):
        """Pipeline function should return None."""
        mock_scan.return_value = []
        fn = bot_scanner()
        ctx = MagicMock()
        ctx.market_ids = ["0xcond1"]
        ctx.params = {}
        result = fn(ctx)
        assert result is None
