"""Tests for copy_trader module."""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest

from horizon._horizon import Engine, OrderRequest, OrderSide, RiskConfig, Side
from horizon.copy_trader import (
    CopyTradeResult,
    CopyTraderConfig,
    _CopyTraderState,
    _map_trade_to_order,
    _process_trade,
    copy_trader,
)
from horizon.context import Context
from horizon.flow import Trade


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


def _make_trade(
    wallet: str = "0xabc",
    side: str = "BUY",
    outcome: str = "Yes",
    size: float = 10.0,
    price: float = 0.60,
    timestamp: int = 1000,
    market_slug: str = "test-market",
    condition_id: str = "0xcond1",
    token_id: str = "tok123",
    tx_hash: str | None = "0xtx1",
) -> Trade:
    return Trade(
        wallet=wallet,
        side=side,
        outcome=outcome,
        size=size,
        price=price,
        usdc_size=size * price,
        timestamp=timestamp,
        market_slug=market_slug,
        market_title="Test Market",
        condition_id=condition_id,
        token_id=token_id,
        tx_hash=tx_hash,
    )


def _make_engine() -> Engine:
    rc = RiskConfig(max_order_size=500.0)
    return Engine(risk_config=rc, api_key="test_key_000000000000000000000000000000")


def _default_config(**overrides) -> CopyTraderConfig:
    defaults = dict(
        wallets=["0xabc"],
        size_scale=1.0,
        max_position_per_market=1000.0,
        min_trade_usdc=1.0,
        poll_interval=30.0,
        inverse=False,
        max_slippage=0.02,
        dry_run=False,
    )
    defaults.update(overrides)
    return CopyTraderConfig(**defaults)


# ---------------------------------------------------------------------------
# _CopyTraderState tests
# ---------------------------------------------------------------------------


class TestCopyTraderState:
    def test_dedup_by_tx_hash(self):
        state = _CopyTraderState()
        trade = _make_trade(tx_hash="0xtx1")
        assert not state.is_duplicate(trade)
        state.mark_seen(trade)
        assert state.is_duplicate(trade)

    def test_dedup_by_composite_key(self):
        state = _CopyTraderState()
        trade = _make_trade(tx_hash=None)
        assert not state.is_duplicate(trade)
        state.mark_seen(trade)
        assert state.is_duplicate(trade)

    def test_dedup_fifo_eviction(self):
        state = _CopyTraderState(capacity=3)
        trades = [_make_trade(tx_hash=f"0xtx{i}") for i in range(5)]
        for t in trades:
            state.mark_seen(t)
        # First two should have been evicted
        assert not state.is_duplicate(trades[0])
        assert not state.is_duplicate(trades[1])
        # Last three should still be seen
        assert state.is_duplicate(trades[2])
        assert state.is_duplicate(trades[3])
        assert state.is_duplicate(trades[4])

    def test_watermark(self):
        state = _CopyTraderState()
        assert state.is_newer("0xabc", 100)
        state.update_watermark("0xabc", 100)
        assert not state.is_newer("0xabc", 50)
        assert not state.is_newer("0xabc", 100)
        assert state.is_newer("0xabc", 101)

    def test_position_limit_clamping(self):
        state = _CopyTraderState()
        # No existing exposure
        assert state.check_position_limit("0xcond1", 500.0, 1000.0) == 500.0
        state.update_exposure("0xcond1", 500.0)
        # Partial room
        assert state.check_position_limit("0xcond1", 600.0, 1000.0) == 500.0
        state.update_exposure("0xcond1", 500.0)
        # Full
        assert state.check_position_limit("0xcond1", 100.0, 1000.0) == 0.0

    def test_seed(self):
        state = _CopyTraderState()
        trades = [_make_trade(tx_hash=f"0xtx{i}", timestamp=100 + i) for i in range(3)]
        state.seed(trades)
        for t in trades:
            assert state.is_duplicate(t)
        assert not state.is_newer("0xabc", 102)
        assert state.is_newer("0xabc", 103)


# ---------------------------------------------------------------------------
# Side mapping tests
# ---------------------------------------------------------------------------


class TestSideMapping:
    def test_buy_yes(self):
        trade = _make_trade(side="BUY", outcome="Yes")
        side, order_side = _map_trade_to_order(trade)
        assert side == Side.Yes
        assert order_side == OrderSide.Buy

    def test_buy_no(self):
        trade = _make_trade(side="BUY", outcome="No")
        side, order_side = _map_trade_to_order(trade)
        assert side == Side.No
        assert order_side == OrderSide.Buy

    def test_sell_yes(self):
        trade = _make_trade(side="SELL", outcome="Yes")
        side, order_side = _map_trade_to_order(trade)
        assert side == Side.Yes
        assert order_side == OrderSide.Sell

    def test_sell_no(self):
        trade = _make_trade(side="SELL", outcome="No")
        side, order_side = _map_trade_to_order(trade)
        assert side == Side.No
        assert order_side == OrderSide.Sell

    def test_inverse_buy_becomes_sell(self):
        trade = _make_trade(side="BUY", outcome="Yes")
        side, order_side = _map_trade_to_order(trade, inverse=True)
        assert side == Side.Yes  # Side unchanged
        assert order_side == OrderSide.Sell  # Flipped

    def test_inverse_sell_becomes_buy(self):
        trade = _make_trade(side="SELL", outcome="Yes")
        side, order_side = _map_trade_to_order(trade, inverse=True)
        assert side == Side.Yes
        assert order_side == OrderSide.Buy


# ---------------------------------------------------------------------------
# _process_trade tests
# ---------------------------------------------------------------------------


class TestProcessTrade:
    def test_dedup_skips_duplicate(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config()
        trade = _make_trade()
        state.mark_seen(trade)

        result = _process_trade(engine, trade, state, config)
        assert result is None

    def test_old_trade_skipped(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config()
        # Set watermark to 1000
        state.update_watermark("0xabc", 1000)
        trade = _make_trade(tx_hash="0xtx_old", timestamp=999)

        result = _process_trade(engine, trade, state, config)
        assert result is None

    def test_min_trade_filter(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config(min_trade_usdc=100.0)
        # Small trade: 10 * 0.60 = 6 USDC
        trade = _make_trade(size=10.0, price=0.60)

        result = _process_trade(engine, trade, state, config)
        assert result is None

    def test_invalid_price_zero(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config()
        trade = _make_trade(price=0.0)

        result = _process_trade(engine, trade, state, config)
        assert result is None

    def test_invalid_price_one(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config()
        trade = _make_trade(price=1.0)

        result = _process_trade(engine, trade, state, config)
        assert result is None

    def test_position_limit_rejection(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config(max_position_per_market=5.0)
        # Fill up exposure
        state.update_exposure("0xcond1", 5.0)
        trade = _make_trade(size=10.0, price=0.60)  # 6 USDC

        result = _process_trade(engine, trade, state, config)
        assert result is not None
        assert result.error == "position_limit_reached"
        assert result.size == 0.0

    def test_size_scaling(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config(size_scale=0.5, dry_run=True)
        trade = _make_trade(size=20.0, price=0.50)  # 10 USDC * 0.5 = 5 USDC -> 10 tokens

        result = _process_trade(engine, trade, state, config)
        assert result is not None
        assert result.error is None
        # 5 USDC / 0.50 = 10 tokens
        assert result.size == 10.0

    def test_dry_run_no_order(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config(dry_run=True)
        trade = _make_trade()

        result = _process_trade(engine, trade, state, config)
        assert result is not None
        assert result.order_id is None  # No order submitted
        assert result.error is None

    def test_successful_order_submission(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config()
        trade = _make_trade(size=10.0, price=0.50)  # 5 USDC -> 10 tokens

        result = _process_trade(engine, trade, state, config)
        assert result is not None
        assert result.error is None
        assert result.order_id is not None
        assert result.order_id.startswith("p")  # Paper order

    def test_slippage_buy(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config(max_slippage=0.03, dry_run=True)
        trade = _make_trade(side="BUY", price=0.50)

        result = _process_trade(engine, trade, state, config)
        assert result is not None
        assert result.price == 0.53  # 0.50 + 0.03

    def test_slippage_sell(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config(max_slippage=0.03, dry_run=True)
        trade = _make_trade(side="SELL", price=0.50)

        result = _process_trade(engine, trade, state, config)
        assert result is not None
        assert result.price == 0.47  # 0.50 - 0.03

    def test_slippage_clamped_high(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config(max_slippage=0.05, dry_run=True)
        trade = _make_trade(side="BUY", price=0.97)

        result = _process_trade(engine, trade, state, config)
        assert result is not None
        assert result.price == 0.99  # Clamped to 0.99

    def test_slippage_clamped_low(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config(max_slippage=0.05, dry_run=True, min_trade_usdc=0.01)
        trade = _make_trade(side="SELL", price=0.03, size=50.0)  # 1.5 USDC

        result = _process_trade(engine, trade, state, config)
        assert result is not None
        assert result.price == 0.01  # Clamped to 0.01

    def test_exposure_tracked_on_buy(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config()
        trade = _make_trade(size=10.0, price=0.50)  # 5 USDC

        _process_trade(engine, trade, state, config)
        assert state._exposure.get("0xcond1", 0.0) == 5.0

    def test_exposure_not_tracked_on_sell(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config()
        trade = _make_trade(side="SELL", size=10.0, price=0.50)

        _process_trade(engine, trade, state, config)
        assert state._exposure.get("0xcond1", 0.0) == 0.0

    def test_inverse_mode(self):
        engine = _make_engine()
        state = _CopyTraderState()
        config = _default_config(inverse=True, dry_run=True)
        trade = _make_trade(side="BUY", outcome="Yes")

        result = _process_trade(engine, trade, state, config)
        assert result is not None
        assert result.order_side == "sell"  # Flipped
        assert result.side == "yes"  # Unchanged


# ---------------------------------------------------------------------------
# Pipeline mode tests
# ---------------------------------------------------------------------------


class TestCopyTraderPipeline:
    @patch("horizon.copy_trader.get_wallet_trades")
    @patch("horizon._horizon.auth_require_pro", return_value=None)
    def test_pipeline_uses_engine_from_ctx(self, mock_auth, mock_get_trades):
        mock_get_trades.return_value = []
        fn = copy_trader(wallet="0xabc")

        engine = _make_engine()
        ctx = MagicMock(spec=Context)
        ctx.params = {"engine": engine}
        fn(ctx)

        # Should have called get_wallet_trades for seeding + polling
        assert mock_get_trades.call_count >= 1

    @patch("horizon.copy_trader.get_wallet_trades")
    @patch("horizon._horizon.auth_require_pro", return_value=None)
    def test_pipeline_seeds_on_first_call(self, mock_auth, mock_get_trades):
        seed_trades = [_make_trade(tx_hash=f"0xseed{i}", timestamp=100 + i) for i in range(3)]
        mock_get_trades.return_value = seed_trades
        fn = copy_trader(wallet="0xabc")

        engine = _make_engine()
        ctx = MagicMock(spec=Context)
        ctx.params = {"engine": engine}

        fn(ctx)

        # Second call should not re-seed
        mock_get_trades.return_value = []
        fn(ctx)
        # The seeding call (limit=100) only happens once
        seed_calls = [c for c in mock_get_trades.call_args_list if c[1].get("limit") == 100]
        assert len(seed_calls) == 1

    @patch("horizon.copy_trader.get_wallet_trades")
    @patch("horizon._horizon.auth_require_pro", return_value=None)
    def test_pipeline_stores_results(self, mock_auth, mock_get_trades):
        # First call seeds, second call returns new trade
        mock_get_trades.side_effect = [
            [_make_trade(tx_hash="0xseed1", timestamp=100)],  # seed
            [_make_trade(tx_hash="0xseed1", timestamp=100)],  # poll (duplicate)
        ]
        fn = copy_trader(wallet="0xabc", dry_run=True)

        engine = _make_engine()
        ctx = MagicMock(spec=Context)
        ctx.params = {"engine": engine}
        fn(ctx)

        # Results stored in ctx.params
        results = ctx.params.get("copy_trade_results", [])
        assert isinstance(results, list)

    @patch("horizon.copy_trader.get_wallet_trades")
    @patch("horizon._horizon.auth_require_pro", return_value=None)
    def test_pipeline_no_engine_noop(self, mock_auth, mock_get_trades):
        fn = copy_trader(wallet="0xabc")
        ctx = MagicMock(spec=Context)
        ctx.params = {}  # No engine
        fn(ctx)
        mock_get_trades.assert_not_called()

    @patch("horizon._horizon.auth_require_pro", return_value=None)
    def test_pipeline_requires_wallet(self, mock_auth):
        with pytest.raises(ValueError, match="At least one wallet"):
            copy_trader()

    @patch("horizon.copy_trader.get_wallet_trades")
    @patch("horizon._horizon.auth_require_pro", return_value=None)
    def test_pipeline_new_trade_produces_result(self, mock_auth, mock_get_trades):
        seed_trade = _make_trade(tx_hash="0xseed1", timestamp=100)
        new_trade = _make_trade(tx_hash="0xnew1", timestamp=200)

        # Seed call returns old trade, poll returns new trade
        mock_get_trades.side_effect = [
            [seed_trade],   # seed
            [new_trade],    # poll
        ]
        fn = copy_trader(wallet="0xabc", dry_run=True)

        engine = _make_engine()
        ctx = MagicMock(spec=Context)
        ctx.params = {"engine": engine}
        fn(ctx)

        results = ctx.params.get("copy_trade_results", [])
        assert len(results) == 1
        assert results[0].source_tx_hash == "0xnew1"


# ---------------------------------------------------------------------------
# Config tests
# ---------------------------------------------------------------------------


class TestCopyTraderConfig:
    def test_defaults(self):
        config = CopyTraderConfig()
        assert config.size_scale == 1.0
        assert config.max_position_per_market == 1000.0
        assert config.min_trade_usdc == 1.0
        assert config.poll_interval == 30.0
        assert config.inverse is False
        assert config.max_slippage == 0.02
        assert config.dry_run is False
        assert config.dedup_capacity == 10_000


class TestCopyTradeResult:
    def test_fields(self):
        result = CopyTradeResult(
            source_wallet="0xabc",
            source_tx_hash="0xtx1",
            market_slug="test-market",
            condition_id="0xcond1",
            side="yes",
            order_side="buy",
            size=10.0,
            price=0.55,
            order_id="p1",
        )
        assert result.source_wallet == "0xabc"
        assert result.order_id == "p1"
        assert result.error is None
