"""
Tests for RL Trading Environment.

Covers:
- Environment creation with various configurations
- Observation space correctness
- Action execution and state transitions
- Reward calculation
- Episode termination and truncation
- Edge cases (empty data, extreme prices)
"""

import numpy as np
import pandas as pd
import pytest
from typing import Tuple

from rdagent.components.coder.rl.env import TradingEnv, TradingState


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


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


@pytest.fixture
def mock_indicators(mock_prices: np.ndarray) -> np.ndarray:
    """Generate mock technical indicators (500 x 3)."""
    np.random.seed(42)
    return np.random.randn(500, 3).astype(np.float32)


@pytest.fixture
def basic_env(mock_prices: np.ndarray) -> TradingEnv:
    """Create a basic trading environment."""
    return TradingEnv(prices=mock_prices, window_size=30, max_steps=200)


@pytest.fixture
def env_with_indicators(mock_prices: np.ndarray, mock_indicators: np.ndarray) -> TradingEnv:
    """Create environment with indicators."""
    return TradingEnv(
        prices=mock_prices,
        indicators=mock_indicators,
        window_size=30,
        max_steps=200,
    )


# =============================================================================
# ENVIRONMENT CREATION
# =============================================================================


class TestEnvCreation:
    """Test environment initialization."""

    def test_basic_creation(self, basic_env: TradingEnv) -> None:
        """Environment should initialize with default values."""
        assert basic_env.initial_balance == 100000.0
        assert basic_env.transaction_cost == 0.0001
        assert basic_env.window_size == 30
        assert basic_env.current_step == 0
        assert basic_env.position == 0.0

    def test_custom_parameters(self) -> None:
        """Custom parameters should be respected."""
        prices = np.random.randn(200) + 100
        env = TradingEnv(
            prices=prices,
            initial_balance=50000.0,
            transaction_cost=0.0005,
            window_size=20,
            max_steps=500,
        )
        assert env.initial_balance == 50000.0
        assert env.transaction_cost == 0.0005
        assert env.window_size == 20
        assert env.max_steps == 500

    def test_observation_space_shape_no_indicators(self, basic_env: TradingEnv) -> None:
        """Observation dim = window_size * (1 + 0 + 3) = 30 * 4 = 120."""
        expected_dim = 30 * (1 + 0 + 3)
        assert basic_env.observation_space.shape == (expected_dim,)

    def test_observation_space_shape_with_indicators(
        self, env_with_indicators: TradingEnv
    ) -> None:
        """Observation dim = window_size * (1 + 3 + 3) = 30 * 7 = 210."""
        expected_dim = 30 * (1 + 3 + 3)
        assert env_with_indicators.observation_space.shape == (expected_dim,)

    def test_action_space_bounds(self, basic_env: TradingEnv) -> None:
        """Action space should be [-1, 1]."""
        assert basic_env.action_space.low[0] == -1.0
        assert basic_env.action_space.high[0] == 1.0
        assert basic_env.action_space.shape == (1,)


# =============================================================================
# RESET
# =============================================================================


class TestReset:
    """Test environment reset."""

    def test_reset_returns_observation_and_info(
        self, basic_env: TradingEnv
    ) -> None:
        obs, info = basic_env.reset()
        assert isinstance(obs, np.ndarray)
        assert obs.dtype == np.float32
        assert isinstance(info, dict)

    def test_reset_restores_initial_state(self, basic_env: TradingEnv) -> None:
        """After reset, state should match initialization."""
        basic_env.reset()
        assert basic_env.current_step == 0
        assert basic_env.balance == basic_env.initial_balance
        assert basic_env.position == 0.0
        assert basic_env.entry_price == 0.0
        assert basic_env.equity_history == [basic_env.initial_balance]
        assert basic_env.trades == []

    def test_reset_with_seed(self, mock_prices: np.ndarray) -> None:
        """Reset with seed should be reproducible."""
        env1 = TradingEnv(prices=mock_prices, window_size=10, max_steps=50)
        env2 = TradingEnv(prices=mock_prices, window_size=10, max_steps=50)

        obs1, _ = env1.reset(seed=123)
        obs2, _ = env2.reset(seed=123)
        np.testing.assert_array_equal(obs1, obs2)


# =============================================================================
# STEP
# =============================================================================


class TestStep:
    """Test environment step execution."""

    def test_step_returns_correct_types(self, basic_env: TradingEnv) -> None:
        """Step should return (obs, reward, terminated, truncated, info)."""
        basic_env.reset()
        action = np.array([0.5], dtype=np.float32)
        obs, reward, terminated, truncated, info = basic_env.step(action)

        assert isinstance(obs, np.ndarray)
        assert isinstance(reward, float)
        assert isinstance(terminated, bool)
        assert isinstance(truncated, bool)
        assert isinstance(info, dict)

    def test_step_updates_position(self, basic_env: TradingEnv) -> None:
        """Position should update after step."""
        basic_env.reset()
        basic_env.step(np.array([0.5], dtype=np.float32))
        assert basic_env.position == 0.5

    def test_step_records_trade_on_change(self, basic_env: TradingEnv) -> None:
        """Trade should be recorded when position changes significantly."""
        basic_env.reset()
        basic_env.step(np.array([0.5], dtype=np.float32))
        assert len(basic_env.trades) == 1

    def test_no_trade_on_small_change(self, basic_env: TradingEnv) -> None:
        """Position changes < 0.01 should not record a trade."""
        basic_env.reset()
        basic_env.step(np.array([0.005], dtype=np.float32))
        assert len(basic_env.trades) == 0

    def test_step_advances_time(self, basic_env: TradingEnv) -> None:
        """current_step should increment after step."""
        basic_env.reset()
        assert basic_env.current_step == 0
        basic_env.step(np.array([0.0], dtype=np.float32))
        assert basic_env.current_step == 1

    def test_equity_history_grows(self, basic_env: TradingEnv) -> None:
        """Equity history should grow with each step."""
        basic_env.reset()
        initial_len = len(basic_env.equity_history)
        basic_env.step(np.array([0.5], dtype=np.float32))
        assert len(basic_env.equity_history) == initial_len + 1

    def test_info_contains_expected_keys(self, basic_env: TradingEnv) -> None:
        """Info dict should have standard keys."""
        basic_env.reset()
        _, _, _, _, info = basic_env.step(np.array([0.5], dtype=np.float32))
        required_keys = ["equity", "balance", "position", "trades_count", "return"]
        for key in required_keys:
            assert key in info


# =============================================================================
# REWARD
# =============================================================================


class TestReward:
    """Test reward calculation."""

    def test_positive_return_reward(self) -> None:
        """Positive equity change should yield positive return component."""
        prices = np.array([100.0, 101.0, 102.0, 103.0, 104.0])
        env = TradingEnv(prices=prices, window_size=2, max_steps=10)
        env.reset()
        env.position = 1.0
        env.entry_price = 100.0

        # Simulate positive move
        old_equity = 100000.0
        new_equity = 101000.0
        reward = env._calculate_reward(new_equity, old_equity)

        # Return component should be positive (~0.01)
        assert reward > -0.01  # Allow small cost penalties

    def test_negative_return_reward(self) -> None:
        """Negative equity change should yield negative reward."""
        prices = np.array([100.0, 99.0, 98.0])
        env = TradingEnv(prices=prices, window_size=2, max_steps=10)
        env.reset()

        old_equity = 100000.0
        new_equity = 99000.0
        reward = env._calculate_reward(new_equity, old_equity)
        assert reward < 0

    def test_drawdown_penalty(self) -> None:
        """Drawdown should penalize reward."""
        prices = np.array([100.0, 101.0])
        env = TradingEnv(prices=prices, window_size=2, max_steps=10)
        env.reset()
        env.equity_history = [100000.0, 110000.0, 105000.0]

        old_equity = 105000.0
        new_equity = 104000.0
        reward_with_dd = env._calculate_reward(new_equity, old_equity)

        # Same return without drawdown
        env.equity_history = [100000.0]
        reward_no_dd = env._calculate_reward(new_equity, old_equity)

        assert reward_with_dd < reward_no_dd


# =============================================================================
# TERMINATION
# =============================================================================


class TestTermination:
    """Test episode termination conditions."""

    def test_truncation_on_max_steps(self) -> None:
        """Episode should truncate when max_steps reached."""
        prices = np.arange(100.0, 150.0, 0.5)  # 100 steps
        env = TradingEnv(prices=prices, window_size=5, max_steps=10)
        env.reset()

        for _ in range(10):
            obs, reward, terminated, truncated, info = env.step(np.array([0.0]))

        assert truncated is True

    def test_termination_on_liquidation(self) -> None:
        """Episode should terminate on liquidation (equity < 50% initial)."""
        prices = np.array([100.0, 50.0, 10.0, 5.0, 1.0])
        env = TradingEnv(
            prices=prices,
            window_size=2,
            max_steps=10,
            initial_balance=100000.0,
        )
        env.reset()
        env.position = 1000.0  # Large long position
        env.entry_price = 100.0

        # Price crashes -> equity drops below 50%
        obs, reward, terminated, truncated, info = env.step(np.array([1.0]))
        # May or may not terminate depending on exact equity calc
        assert isinstance(terminated, bool)

    def test_no_termination_on_normal_step(self, basic_env: TradingEnv) -> None:
        """Normal step should not trigger termination."""
        basic_env.reset()
        _, _, terminated, truncated, _ = basic_env.step(np.array([0.1]))
        assert terminated is False
        assert truncated is False


# =============================================================================
# OBSERVATION
# =============================================================================


class TestObservation:
    """Test observation building."""

    def test_observation_shape_no_indicators(self, basic_env: TradingEnv) -> None:
        """Observation shape should match observation space."""
        obs, _ = basic_env.reset()
        assert obs.shape == basic_env.observation_space.shape

    def test_observation_shape_with_indicators(
        self, env_with_indicators: TradingEnv
    ) -> None:
        """Observation shape should include indicator dimensions."""
        obs, _ = env_with_indicators.reset()
        assert obs.shape == env_with_indicators.observation_space.shape

    def test_observation_values_finite(self, basic_env: TradingEnv) -> None:
        """All observation values should be finite."""
        obs, _ = basic_env.reset()
        assert np.all(np.isfinite(obs))


# =============================================================================
# UTILITY METHODS
# =============================================================================


class TestUtility:
    """Test utility methods."""

    def test_get_equity_curve(self, basic_env: TradingEnv) -> None:
        """Equity curve should return array of equity values."""
        basic_env.reset()
        basic_env.step(np.array([0.5]))
        basic_env.step(np.array([0.3]))

        curve = basic_env.get_equity_curve()
        assert isinstance(curve, np.ndarray)
        assert len(curve) == 3  # initial + 2 steps

    def test_get_trade_log(self, basic_env: TradingEnv) -> None:
        """Trade log should return list of trade records."""
        basic_env.reset()
        basic_env.step(np.array([0.5]))
        basic_env.step(np.array([-0.3]))

        log = basic_env.get_trade_log()
        assert len(log) == 2
        assert "step" in log[0]
        assert "action" in log[0]
        assert "cost" in log[0]


# =============================================================================
# TRADING STATE DATACLASS
# =============================================================================


class TestTradingState:
    """Test TradingState dataclass."""

    def test_default_values(self) -> None:
        """Default values should match initialization params."""
        state = TradingState()
        assert state.position == 0.0
        assert state.cash == 100000.0
        assert state.equity == 100000.0
        assert state.entry_price == 0.0
        assert state.step == 0
        assert state.holdings_history == []

    def test_custom_values(self) -> None:
        """Custom values should be stored correctly."""
        state = TradingState(
            position=0.5,
            cash=50000.0,
            equity=75000.0,
            entry_price=100.0,
            step=10,
            holdings_history=[0.1, 0.2, 0.3],
        )
        assert state.position == 0.5
        assert state.cash == 50000.0
        assert len(state.holdings_history) == 3
