"""Tests for Tearsheet analytics - full coverage."""

from __future__ import annotations

import math

import pytest

from horizon.analytics import (
    DrawdownRecord,
    Tearsheet,
    generate_tearsheet,
    _compute_drawdown_periods,
    _compute_monthly_returns,
    _compute_tail_ratio,
    _compute_time_of_day_returns,
    _compute_trade_stats,
    _rolling_sharpe_sortino,
    _safe_float,
)
from horizon.result import BacktestResult


# ---------------------------------------------------------------------------
# Helper: lightweight mock Fill
# ---------------------------------------------------------------------------

class MockFill:
    """Lightweight fill mock for tearsheet testing."""

    def __init__(
        self,
        market_id: str = "m1",
        order_side: str = "buy",
        price: float = 0.50,
        size: float = 10.0,
        fee: float = 0.0,
        timestamp: float = 0.0,
    ):
        self.market_id = market_id
        self.order_side = order_side
        self.price = price
        self.size = size
        self.fee = fee
        self.timestamp = timestamp


# ---------------------------------------------------------------------------
# Test: generate_tearsheet with empty data
# ---------------------------------------------------------------------------

class TestTearsheetEmpty:
    def test_empty_equity_and_trades(self):
        ts = generate_tearsheet([], [], 1000.0)
        assert isinstance(ts, Tearsheet)
        assert ts.monthly_returns == {}
        assert ts.rolling_sharpe == []
        assert ts.rolling_sortino == []
        assert ts.drawdowns == []
        assert ts.avg_win == 0.0
        assert ts.avg_loss == 0.0
        assert ts.largest_win == 0.0
        assert ts.largest_loss == 0.0
        assert ts.avg_hold_time == 0.0
        assert ts.win_streak == 0
        assert ts.loss_streak == 0
        assert ts.time_of_day == {}
        assert ts.max_consecutive_wins == 0
        assert ts.max_consecutive_losses == 0
        assert ts.tail_ratio == 0.0

    def test_single_equity_point(self):
        ts = generate_tearsheet([(1000.0, 1000.0)], [], 1000.0)
        assert ts.monthly_returns == {}  # need >= 2 points
        assert ts.rolling_sharpe == []
        assert ts.drawdowns == []
        assert ts.tail_ratio == 0.0

    def test_empty_trades_with_equity(self):
        curve = [(float(i), 1000.0 + i * 1.0) for i in range(50)]
        ts = generate_tearsheet(curve, [], 1000.0)
        assert ts.avg_win == 0.0
        assert ts.time_of_day == {}
        # But rolling stats should be populated (50 points > 30 window)
        assert len(ts.rolling_sharpe) > 0


# ---------------------------------------------------------------------------
# Test: Monthly returns
# ---------------------------------------------------------------------------

class TestMonthlyReturns:
    def test_single_month(self):
        # All timestamps in Jan 2024 (UTC)
        # Jan 1 00:00 UTC = 1704067200
        base = 1704067200.0
        curve = [
            (base, 1000.0),
            (base + 86400, 1010.0),
            (base + 2 * 86400, 1020.0),
        ]
        mr = _compute_monthly_returns(curve)
        assert "2024-01" in mr
        assert mr["2024-01"] == pytest.approx(2.0, abs=0.01)  # (1020-1000)/1000 * 100

    def test_two_months(self):
        # Jan 2024 and Feb 2024
        jan_start = 1704067200.0  # 2024-01-01
        feb_start = 1706745600.0  # 2024-02-01
        curve = [
            (jan_start, 1000.0),
            (jan_start + 86400, 1100.0),  # end of Jan data
            (feb_start, 1100.0),
            (feb_start + 86400, 1050.0),  # Feb loss
        ]
        mr = _compute_monthly_returns(curve)
        assert "2024-01" in mr
        assert "2024-02" in mr
        assert mr["2024-01"] == pytest.approx(10.0, abs=0.01)
        assert mr["2024-02"] < 0  # Loss

    def test_empty(self):
        assert _compute_monthly_returns([]) == {}

    def test_single_point_in_month(self):
        """A month with only one data point should return 0.0."""
        base = 1704067200.0
        curve = [(base, 1000.0)]
        mr = _compute_monthly_returns(curve)
        assert mr == {}  # Less than 2 points overall


# ---------------------------------------------------------------------------
# Test: Rolling Sharpe / Sortino
# ---------------------------------------------------------------------------

class TestRollingSharpe:
    def test_insufficient_data(self):
        """Fewer than 30 equity points yields empty rolling stats."""
        curve = [(float(i), 1000.0 + i) for i in range(20)]
        sharpe, sortino = _rolling_sharpe_sortino(curve, window=30)
        assert sharpe == []
        assert sortino == []

    def test_exact_window_size(self):
        """Exactly 31 equity points (30 returns) yields 1 rolling value."""
        curve = [(float(i), 1000.0 + i * 0.5) for i in range(31)]
        sharpe, sortino = _rolling_sharpe_sortino(curve, window=30)
        assert len(sharpe) == 1
        assert len(sortino) == 1

    def test_all_positive_returns(self):
        """Pure uptrend should have positive Sharpe, zero Sortino (no downside)."""
        curve = [(float(i), 1000.0 + i * 10.0) for i in range(50)]
        sharpe, sortino = _rolling_sharpe_sortino(curve, window=30)
        assert len(sharpe) > 0
        assert all(s >= 0 for _, s in sharpe)
        # Sortino should be 0 because there is no downside deviation
        assert all(s == 0.0 for _, s in sortino)

    def test_volatile_returns_finite(self):
        """Volatile equity curve should still produce finite values."""
        import random
        random.seed(42)
        curve = [(float(i), 1000.0 + random.gauss(0, 50)) for i in range(100)]
        sharpe, sortino = _rolling_sharpe_sortino(curve, window=30)
        assert all(math.isfinite(s) for _, s in sharpe)
        assert all(math.isfinite(s) for _, s in sortino)

    def test_length_matches(self):
        """Rolling Sharpe and Sortino should have same length."""
        curve = [(float(i), 1000.0 + i) for i in range(60)]
        sharpe, sortino = _rolling_sharpe_sortino(curve, window=30)
        assert len(sharpe) == len(sortino)
        assert len(sharpe) == 60 - 30  # n_returns - window + 1 = 59 - 30 + 1 = 30


# ---------------------------------------------------------------------------
# Test: Drawdown analysis
# ---------------------------------------------------------------------------

class TestDrawdowns:
    def test_no_drawdown(self):
        """Pure uptrend has no drawdowns."""
        curve = [(float(i), 1000.0 + i * 10.0) for i in range(50)]
        dds = _compute_drawdown_periods(curve)
        assert dds == []

    def test_single_drawdown(self):
        curve = [
            (0.0, 1000.0),
            (1.0, 1100.0),  # peak
            (2.0, 990.0),   # drawdown
            (3.0, 950.0),   # trough
            (4.0, 1100.0),  # recovery
            (5.0, 1200.0),
        ]
        dds = _compute_drawdown_periods(curve)
        assert len(dds) == 1
        dd = dds[0]
        assert dd.depth_pct == pytest.approx(((1100 - 950) / 1100) * 100, abs=0.01)
        assert dd.recovery_ts == 4.0
        assert dd.start_ts == 2.0

    def test_unrecovered_drawdown(self):
        """Drawdown that never recovers should have recovery_ts=None."""
        curve = [
            (0.0, 1000.0),
            (1.0, 900.0),
            (2.0, 800.0),
        ]
        dds = _compute_drawdown_periods(curve)
        assert len(dds) == 1
        assert dds[0].recovery_ts is None

    def test_multiple_drawdowns_sorted(self):
        """Multiple drawdowns should be sorted by depth descending."""
        curve = [
            (0.0, 1000.0),
            (1.0, 950.0),   # 5% dd
            (2.0, 1000.0),  # recovery
            (3.0, 800.0),   # 20% dd
            (4.0, 1000.0),  # recovery
            (5.0, 900.0),   # 10% dd
            (6.0, 1000.0),  # recovery
        ]
        dds = _compute_drawdown_periods(curve, top_n=5)
        assert len(dds) == 3
        assert dds[0].depth_pct >= dds[1].depth_pct >= dds[2].depth_pct

    def test_top_n_limit(self):
        """Should only return top N drawdowns."""
        # Create many small drawdowns
        curve = []
        eq = 1000.0
        t = 0.0
        for i in range(20):
            curve.append((t, eq))
            t += 1.0
            eq -= 5.0  # small dip
            curve.append((t, eq))
            t += 1.0
            eq += 10.0  # recovery above
            curve.append((t, eq))
            t += 1.0
        dds = _compute_drawdown_periods(curve, top_n=3)
        assert len(dds) <= 3

    def test_empty(self):
        assert _compute_drawdown_periods([]) == []
        assert _compute_drawdown_periods([(0.0, 1000.0)]) == []


# ---------------------------------------------------------------------------
# Test: Trade-level stats
# ---------------------------------------------------------------------------

class TestTradeStats:
    def test_basic_win_loss(self):
        fills = [
            MockFill("m1", "buy", 0.40, 10.0, timestamp=100.0),
            MockFill("m1", "sell", 0.60, 10.0, timestamp=200.0),  # Win: +2.0
            MockFill("m2", "buy", 0.70, 10.0, timestamp=300.0),
            MockFill("m2", "sell", 0.50, 10.0, timestamp=350.0),  # Loss: -2.0
        ]
        (
            avg_win, avg_loss, largest_win, largest_loss, avg_hold_time,
            win_streak, loss_streak, max_wins, max_losses,
        ) = _compute_trade_stats(fills)
        assert avg_win == pytest.approx(2.0, abs=0.01)
        assert avg_loss == pytest.approx(-2.0, abs=0.01)
        assert largest_win == pytest.approx(2.0, abs=0.01)
        assert largest_loss == pytest.approx(-2.0, abs=0.01)
        assert avg_hold_time == pytest.approx(75.0, abs=0.01)  # (100 + 50) / 2
        assert max_wins == 1
        assert max_losses == 1

    def test_empty_trades(self):
        result = _compute_trade_stats([])
        assert result == (0.0, 0.0, 0.0, 0.0, 0.0, 0, 0, 0, 0)

    def test_all_wins(self):
        fills = [
            MockFill("m1", "buy", 0.40, 10.0, timestamp=0.0),
            MockFill("m1", "sell", 0.60, 10.0, timestamp=10.0),
            MockFill("m2", "buy", 0.30, 10.0, timestamp=20.0),
            MockFill("m2", "sell", 0.50, 10.0, timestamp=30.0),
            MockFill("m3", "buy", 0.20, 10.0, timestamp=40.0),
            MockFill("m3", "sell", 0.40, 10.0, timestamp=50.0),
        ]
        (
            avg_win, avg_loss, largest_win, largest_loss, _,
            win_streak, loss_streak, max_wins, max_losses,
        ) = _compute_trade_stats(fills)
        assert avg_loss == 0.0
        assert max_wins == 3
        assert max_losses == 0
        assert largest_win == pytest.approx(2.0, abs=0.01)

    def test_all_losses(self):
        fills = [
            MockFill("m1", "buy", 0.60, 10.0, timestamp=0.0),
            MockFill("m1", "sell", 0.40, 10.0, timestamp=10.0),
            MockFill("m2", "buy", 0.50, 10.0, timestamp=20.0),
            MockFill("m2", "sell", 0.30, 10.0, timestamp=30.0),
        ]
        (
            avg_win, avg_loss, largest_win, largest_loss, _,
            win_streak, loss_streak, max_wins, max_losses,
        ) = _compute_trade_stats(fills)
        assert avg_win == 0.0
        assert max_losses == 2
        assert max_wins == 0

    def test_hold_time_single_trade(self):
        fills = [
            MockFill("m1", "buy", 0.40, 10.0, timestamp=1000.0),
            MockFill("m1", "sell", 0.60, 10.0, timestamp=1500.0),
        ]
        (_, _, _, _, avg_hold_time, _, _, _, _) = _compute_trade_stats(fills)
        assert avg_hold_time == pytest.approx(500.0)


# ---------------------------------------------------------------------------
# Test: Time-of-day returns
# ---------------------------------------------------------------------------

class TestTimeOfDay:
    def test_empty_trades(self):
        assert _compute_time_of_day_returns([]) == {}

    def test_basic_hour_attribution(self):
        """Sell at hour 12 UTC should attribute the trade PnL to hour 12."""
        # 2024-01-15 12:00 UTC = 1705320000
        sell_ts = 1705320000.0
        buy_ts = sell_ts - 3600.0  # bought 1 hour earlier

        fills = [
            MockFill("m1", "buy", 0.40, 10.0, timestamp=buy_ts),
            MockFill("m1", "sell", 0.60, 10.0, timestamp=sell_ts),
        ]
        tod = _compute_time_of_day_returns(fills)
        assert 12 in tod
        assert tod[12] == pytest.approx(2.0, abs=0.01)

    def test_only_buys_no_attribution(self):
        """Unmatched buys should not produce time-of-day entries."""
        fills = [MockFill("m1", "buy", 0.40, 10.0, timestamp=1000.0)]
        tod = _compute_time_of_day_returns(fills)
        assert tod == {}


# ---------------------------------------------------------------------------
# Test: Tail ratio
# ---------------------------------------------------------------------------

class TestTailRatio:
    def test_empty(self):
        assert _compute_tail_ratio([]) == 0.0

    def test_single_point(self):
        assert _compute_tail_ratio([(0.0, 1000.0)]) == 0.0

    def test_symmetric_returns(self):
        """Symmetric returns around zero should have tail ratio ~ 1.0."""
        # Create a perfectly symmetric equity curve
        import random
        random.seed(123)
        n = 200
        curve = [(0.0, 1000.0)]
        eq = 1000.0
        for i in range(1, n):
            change = random.choice([-10.0, 10.0])
            eq += change
            curve.append((float(i), eq))
        ratio = _compute_tail_ratio(curve)
        # Symmetric distribution => tail ratio close to 1.0
        assert 0.5 < ratio < 2.0

    def test_all_positive_returns(self):
        """All positive returns: 5th percentile is also positive, no loss tail."""
        curve = [(float(i), 1000.0 + i * 10.0) for i in range(100)]
        ratio = _compute_tail_ratio(curve)
        # p5 is positive for a pure uptrend, so ratio = p95 / abs(p5) = positive
        assert ratio > 0

    def test_finite(self):
        import random
        random.seed(99)
        curve = [(float(i), 1000.0 + random.gauss(0, 30)) for i in range(200)]
        ratio = _compute_tail_ratio(curve)
        assert math.isfinite(ratio)


# ---------------------------------------------------------------------------
# Test: safe_float helper
# ---------------------------------------------------------------------------

class TestSafeFloat:
    def test_normal_value(self):
        assert _safe_float(3.14) == 3.14

    def test_nan(self):
        assert _safe_float(float("nan")) == 0.0

    def test_inf(self):
        assert _safe_float(float("inf")) == 0.0

    def test_neg_inf(self):
        assert _safe_float(float("-inf")) == 0.0

    def test_zero(self):
        assert _safe_float(0.0) == 0.0


# ---------------------------------------------------------------------------
# Test: BacktestResult.tearsheet() integration
# ---------------------------------------------------------------------------

class TestBacktestResultTearsheet:
    def test_tearsheet_method_exists(self):
        result = BacktestResult()
        ts = result.tearsheet()
        assert isinstance(ts, Tearsheet)

    def test_tearsheet_with_data(self):
        # Jan 2024
        base_ts = 1704067200.0
        curve = [(base_ts + i * 3600, 1000.0 + i * 5.0) for i in range(100)]
        fills = [
            MockFill("m1", "buy", 0.40, 10.0, timestamp=base_ts + 1000),
            MockFill("m1", "sell", 0.60, 10.0, timestamp=base_ts + 5000),
        ]
        result = BacktestResult(
            equity_curve=curve,
            trades=fills,
            initial_capital=1000.0,
        )
        ts = result.tearsheet()
        assert isinstance(ts, Tearsheet)
        assert ts.avg_win > 0
        assert len(ts.monthly_returns) > 0
        assert len(ts.rolling_sharpe) > 0

    def test_tearsheet_with_empty_result(self):
        result = BacktestResult()
        ts = result.tearsheet()
        assert ts.monthly_returns == {}
        assert ts.drawdowns == []
        assert ts.tail_ratio == 0.0


# ---------------------------------------------------------------------------
# Test: Full generate_tearsheet integration
# ---------------------------------------------------------------------------

class TestGenerateTearsheetIntegration:
    def test_all_fields_finite(self):
        """All float fields in the tearsheet should be finite."""
        import random
        random.seed(42)
        base_ts = 1704067200.0
        n = 200
        curve = [(base_ts + i * 3600, 1000.0 + random.gauss(0, 50)) for i in range(n)]

        fills = []
        for i in range(0, 20, 2):
            buy_ts = base_ts + i * 7200
            sell_ts = buy_ts + 3600
            fills.append(MockFill("m1", "buy", 0.40 + i * 0.01, 10.0, timestamp=buy_ts))
            fills.append(MockFill("m1", "sell", 0.50 + i * 0.005, 10.0, timestamp=sell_ts))

        ts = generate_tearsheet(curve, fills, 1000.0)

        # Check all float fields are finite
        assert math.isfinite(ts.avg_win)
        assert math.isfinite(ts.avg_loss)
        assert math.isfinite(ts.largest_win)
        assert math.isfinite(ts.largest_loss)
        assert math.isfinite(ts.avg_hold_time)
        assert math.isfinite(ts.tail_ratio)

        for _, sharpe in ts.rolling_sharpe:
            assert math.isfinite(sharpe)
        for _, sortino in ts.rolling_sortino:
            assert math.isfinite(sortino)
        for dd in ts.drawdowns:
            assert math.isfinite(dd.depth_pct)
            assert math.isfinite(dd.duration_secs)
        for _, ret in ts.monthly_returns.items():
            assert math.isfinite(ret)
        for _, ret in ts.time_of_day.items():
            assert math.isfinite(ret)

    def test_single_trade(self):
        """Single round-trip trade should produce valid tearsheet."""
        base_ts = 1704067200.0
        curve = [
            (base_ts, 1000.0),
            (base_ts + 3600, 1002.0),
        ]
        fills = [
            MockFill("m1", "buy", 0.40, 10.0, timestamp=base_ts),
            MockFill("m1", "sell", 0.60, 10.0, timestamp=base_ts + 3600),
        ]
        ts = generate_tearsheet(curve, fills, 1000.0)
        assert ts.avg_win == pytest.approx(2.0, abs=0.01)
        assert ts.largest_win == pytest.approx(2.0, abs=0.01)
        assert ts.max_consecutive_wins == 1
        assert ts.max_consecutive_losses == 0

    def test_consecutive_streak_tracking(self):
        """Verify win/loss streak computation across many trades."""
        base_ts = 1704067200.0
        fills = []
        # 3 wins then 2 losses then 1 win
        trades_data = [
            ("m1", 0.40, 0.60),  # win
            ("m2", 0.30, 0.50),  # win
            ("m3", 0.20, 0.40),  # win
            ("m4", 0.60, 0.40),  # loss
            ("m5", 0.70, 0.50),  # loss
            ("m6", 0.30, 0.50),  # win
        ]
        t = base_ts
        for mid, buy_p, sell_p in trades_data:
            fills.append(MockFill(mid, "buy", buy_p, 10.0, timestamp=t))
            t += 100
            fills.append(MockFill(mid, "sell", sell_p, 10.0, timestamp=t))
            t += 100

        ts = generate_tearsheet([], fills, 1000.0)
        assert ts.max_consecutive_wins == 3
        assert ts.max_consecutive_losses == 2

    def test_drawdown_record_dataclass(self):
        """DrawdownRecord should have all expected fields."""
        dd = DrawdownRecord(
            start_ts=100.0,
            end_ts=200.0,
            recovery_ts=300.0,
            depth_pct=15.5,
            duration_secs=200.0,
        )
        assert dd.start_ts == 100.0
        assert dd.recovery_ts == 300.0
        assert dd.depth_pct == 15.5
