"""
Tests for RL Costeer (Trading Controller).

Covers:
- Costeer initialization with/without model
- Market data initialization
- Action retrieval (mocked model)
- Risk limit enforcement
- Observation building
- Step execution
- Performance tracking
"""

from pathlib import Path
from unittest.mock import MagicMock, patch

import numpy as np
import pandas as pd
import pytest

from rdagent.components.coder.rl.costeer import RLCosteer


# =============================================================================
# FIXTURES
# =============================================================================


@pytest.fixture
def mock_prices() -> pd.Series:
    """Generate 200-step price series."""
    np.random.seed(42)
    return pd.Series(100.0 + np.cumsum(np.random.randn(200) * 0.5))


@pytest.fixture
def mock_indicators() -> pd.DataFrame:
    """Generate mock indicators DataFrame."""
    np.random.seed(42)
    return pd.DataFrame(
        np.random.randn(200, 3).astype(np.float32),
        columns=["rsi", "macd", "bb_width"],
    )


@pytest.fixture
def basic_costeer() -> RLCosteer:
    """Create costeer without model."""
    return RLCosteer(
        algorithm="PPO",
        window_size=30,
        max_position=1.0,
        risk_limit=0.15,
    )


@pytest.fixture
def initialized_costeer(mock_prices: pd.Series, mock_indicators: pd.DataFrame) -> RLCosteer:
    """Create costeer with market data but no model."""
    costeer = RLCosteer(window_size=30)
    costeer.initialize(mock_prices, mock_indicators, initial_equity=100000.0)
    return costeer


# =============================================================================
# INITIALIZATION
# =============================================================================


class TestCosteerInit:
    """Test costeer initialization."""

    def test_default_values(self) -> None:
        """Default parameters should match specification."""
        costeer = RLCosteer()
        assert costeer.algorithm == "PPO"
        assert costeer.window_size == 60
        assert costeer.max_position == 1.0
        assert costeer.risk_limit == 0.15
        assert costeer.is_active is False
        assert costeer.model is None
        assert costeer.trade_history == []

    def test_custom_values(self) -> None:
        """Custom parameters should be stored."""
        costeer = RLCosteer(
            algorithm="SAC",
            window_size=120,
            max_position=0.5,
            risk_limit=0.10,
        )
        assert costeer.algorithm == "SAC"
        assert costeer.window_size == 120
        assert costeer.max_position == 0.5
        assert costeer.risk_limit == 0.10

    def test_initialize_sets_market_data(
        self, mock_prices: pd.Series, mock_indicators: pd.DataFrame
    ) -> None:
        """Initialize should store market data and activate costeer."""
        costeer = RLCosteer(window_size=30)
        costeer.initialize(mock_prices, mock_indicators, initial_equity=50000.0)

        assert costeer.is_active is True
        assert len(costeer.prices) == 200
        assert costeer.indicators is not None
        assert costeer.initial_equity == 50000.0
        assert costeer.current_step == 30
        assert costeer.peak_equity == 50000.0

    def test_initialize_without_indicators(self, mock_prices: pd.Series) -> None:
        """Initialize should work without indicators."""
        costeer = RLCosteer(window_size=30)
        costeer.initialize(mock_prices, initial_equity=100000.0)

        assert costeer.indicators is None
        assert costeer.is_active is True


# =============================================================================
# GET ACTION
# =============================================================================


class TestGetAction:
    """Test action retrieval."""

    def test_no_model_returns_zero(self, initialized_costeer: RLCosteer) -> None:
        """Without model, action should be 0 (hold)."""
        action = initialized_costeer.get_action(
            current_equity=100000.0, cash=50000.0, position=0.0
        )
        assert action == 0.0

    def test_not_active_returns_zero(self, basic_costeer: RLCosteer) -> None:
        """Inactive costeer should return 0."""
        action = basic_costeer.get_action(
            current_equity=100000.0, cash=50000.0, position=0.0
        )
        assert action == 0.0

    @patch.object(RLCosteer, "_build_observation")
    def test_model_action_risk_scaled(
        self, mock_obs: MagicMock, initialized_costeer: RLCosteer
    ) -> None:
        """Model action should be returned and risk-scaled."""
        mock_obs.return_value = np.random.randn(100).astype(np.float32)

        mock_model = MagicMock()
        mock_model.predict.return_value = (np.array([[0.8]]), None)
        initialized_costeer.model = mock_model

        action = initialized_costeer.get_action(
            current_equity=100000.0, cash=50000.0, position=0.0
        )

        # At full risk (no drawdown), action should be ~0.8
        assert abs(action) <= 1.0

    def test_risk_limit_forces_close(self, initialized_costeer: RLCosteer) -> None:
        """Drawdown > risk_limit should force position to 0."""
        mock_model = MagicMock()
        mock_model.predict.return_value = (np.array([[1.0]]), None)
        initialized_costeer.model = mock_model

        # Simulate 20% drawdown (> 15% limit)
        action = initialized_costeer.get_action(
            current_equity=80000.0,  # 20% drawdown from 100k
            cash=50000.0,
            position=0.5,
        )
        assert action == 0.0

    def test_action_clipped_to_max_position(
        self, initialized_costeer: RLCosteer
    ) -> None:
        """Action should be clipped to max_position."""
        initialized_costeer.max_position = 0.5

        mock_model = MagicMock()
        mock_model.predict.return_value = (np.array([[1.0]]), None)
        initialized_costeer.model = mock_model

        action = initialized_costeer.get_action(
            current_equity=100000.0, cash=50000.0, position=0.0
        )
        assert action <= 0.5


# =============================================================================
# OBSERVATION BUILDING
# =============================================================================


class TestObservationBuilding:
    """Test observation vector construction."""

    def test_observation_shape_no_indicators(
        self, mock_prices: pd.Series
    ) -> None:
        """Observation should have correct shape without indicators."""
        costeer = RLCosteer(window_size=30)
        costeer.initialize(mock_prices, initial_equity=100000.0)

        obs = costeer._build_observation(
            current_equity=100000.0, cash=50000.0, position=0.0
        )

        # window_size + 3 (position, pnl, equity_ratio)
        expected = 30 + 3
        assert obs.shape == (expected,)

    def test_observation_shape_with_indicators(
        self, mock_prices: pd.Series, mock_indicators: pd.DataFrame
    ) -> None:
        """Observation should include indicator dimensions."""
        costeer = RLCosteer(window_size=30)
        costeer.initialize(mock_prices, mock_indicators, initial_equity=100000.0)

        obs = costeer._build_observation(
            current_equity=100000.0, cash=50000.0, position=0.0
        )

        # window_size * (1 + 3) + 3
        expected = 30 + (30 * 3) + 3
        assert obs.shape == (expected,)

    def test_observation_dtype(self, initialized_costeer: RLCosteer) -> None:
        """Observation should be float32."""
        obs = initialized_costeer._build_observation(
            current_equity=100000.0, cash=50000.0, position=0.0
        )
        assert obs.dtype == np.float32


# =============================================================================
# STEP EXECUTION
# =============================================================================


class TestStepExecution:
    """Test step execution."""

    def test_step_records_trade(self, initialized_costeer: RLCosteer) -> None:
        """Step should record trade in history."""
        trade = initialized_costeer.step(
            current_equity=100000.0, cash=50000.0, position=0.0
        )

        assert "timestamp" in trade
        assert "step" in trade
        assert "equity" in trade
        assert "position" in trade
        assert "target_position" in trade
        assert "action" in trade

    def test_step_advances_current_step(self, initialized_costeer: RLCosteer) -> None:
        """Step should increment current_step."""
        initial_step = initialized_costeer.current_step
        initialized_costeer.step(
            current_equity=100000.0, cash=50000.0, position=0.0
        )
        assert initialized_costeer.current_step == initial_step + 1

    def test_step_updates_peak_equity(self, initialized_costeer: RLCosteer) -> None:
        """Peak equity should update when equity exceeds previous peak."""
        initialized_costeer.peak_equity = 100000.0
        initialized_costeer.step(
            current_equity=105000.0, cash=50000.0, position=0.0
        )
        assert initialized_costeer.peak_equity == 105000.0

    def test_multiple_steps_accumulate_trades(
        self, initialized_costeer: RLCosteer
    ) -> None:
        """Multiple steps should accumulate trades."""
        for _ in range(5):
            initialized_costeer.step(
                current_equity=100000.0, cash=50000.0, position=0.0
            )

        assert len(initialized_costeer.trade_history) == 5


# =============================================================================
# MODEL LOADING
# =============================================================================


class TestModelLoading:
    """Test model loading functionality."""

    def test_load_model_import_error(self, tmp_path: Path) -> None:
        """Load should raise ImportError when SB3 not installed."""
        costeer = RLCosteer()

        with patch.dict("sys.modules", {"stable_baselines3": None}):
            with pytest.raises(ImportError, match="stable-baselines3"):
                costeer.load_model(tmp_path / "model.zip")

    def test_load_model_file_not_found(self, tmp_path: Path) -> None:
        """Load should raise ValueError for non-existent file."""
        costeer = RLCosteer(model_path=tmp_path / "nonexistent.zip")
        # Model path doesn't exist, so it won't try to load
        assert costeer.is_active is False


# =============================================================================
# PERFORMANCE TRACKING
# =============================================================================


class TestPerformanceTracking:
    """Test performance history."""

    def test_get_performance_returns_dataframe(
        self, initialized_costeer: RLCosteer
    ) -> None:
        """Performance should return DataFrame."""
        # Add some trades
        initialized_costeer.step(
            current_equity=100000.0, cash=50000.0, position=0.0
        )
        initialized_costeer.step(
            current_equity=101000.0, cash=49000.0, position=0.3
        )

        df = initialized_costeer.get_performance()

        assert isinstance(df, pd.DataFrame)
        assert len(df) == 2
        assert "equity" in df.columns
        assert "position" in df.columns

    def test_empty_performance_history(self, basic_costeer: RLCosteer) -> None:
        """Empty history should return empty DataFrame."""
        df = basic_costeer.get_performance()
        assert isinstance(df, pd.DataFrame)
        assert len(df) == 0
