"""Unit tests for report section builders.

Tests each section builder from services.reporting.sections with known
inputs and expected outputs, including edge cases for zero-activity,
single positions, and missing portfolio snapshots.

Requirements validated: 3.1, 3.2, 3.3, 3.4, 3.5
"""
from __future__ import annotations

import uuid
from datetime import datetime, timezone

from services.reporting.collector import CollectedData
from services.reporting.models import (
    ModelQualitySection,
    PLSection,
    PositionPerformanceSection,
    RecommendationAccuracySection,
    RiskMetricsSection,
)
from services.reporting.sections import (
    build_model_quality_section,
    build_pnl_section,
    build_position_performance_section,
    build_recommendation_accuracy_section,
    build_risk_metrics_section,
)

# ── Helpers ──────────────────────────────────────────────────────────────


def _make_snapshot(**overrides: object) -> dict:
    """Build a portfolio snapshot dict with sensible defaults."""
    snap = {
        "realized_pnl": 100.0,
        "unrealized_pnl": -20.0,
        "daily_return": 0.015,
        "cumulative_return": 0.08,
        "win_count": 7,
        "loss_count": 3,
        "win_rate": 0.7,
        "sharpe_ratio": 1.5,
        "portfolio_heat": 0.12,
        "max_drawdown": 0.06,
        "current_drawdown_pct": 0.02,
        "risk_tier": "moderate",
    }
    snap.update(overrides)
    return snap


def _make_closed_position(
    ticker: str,
    entry: float,
    exit_price: float,
    realized_pnl: float,
    updated_at: datetime | None = None,
) -> dict:
    """Build a closed position dict."""
    return {
        "id": str(uuid.uuid4()),
        "ticker": ticker,
        "avg_entry_price": entry,
        "current_price": exit_price,
        "realized_pnl": realized_pnl,
        "quantity": 0,
        "updated_at": updated_at or datetime(2025, 1, 15, 20, 0, tzinfo=timezone.utc),
    }


def _make_open_position(
    ticker: str,
    entry: float,
    current: float,
    quantity: float,
    updated_at: datetime | None = None,
) -> dict:
    """Build an open position dict."""
    return {
        "id": str(uuid.uuid4()),
        "ticker": ticker,
        "avg_entry_price": entry,
        "current_price": current,
        "quantity": quantity,
        "updated_at": updated_at or datetime(2025, 1, 14, 10, 0, tzinfo=timezone.utc),
    }


# ═══════════════════════════════════════════════════════════════════════
# 1. build_pnl_section
# Requirements validated: 3.1
# ═══════════════════════════════════════════════════════════════════════


class TestBuildPnlSection:
    """Tests for build_pnl_section."""

    def test_with_portfolio_snapshot(self) -> None:
        """Section values are extracted from the portfolio snapshot."""
        snap = _make_snapshot()
        data = CollectedData(portfolio_snapshot=snap)
        section = build_pnl_section(data)

        assert isinstance(section, PLSection)
        assert section.realized_pnl == 100.0
        assert section.unrealized_pnl == -20.0
        assert section.daily_return == 0.015
        assert section.cumulative_return == 0.08
        assert section.win_count == 7
        assert section.loss_count == 3
        assert section.win_rate == 0.7
        assert section.sharpe_ratio == 1.5

    def test_no_snapshot_returns_zeros(self) -> None:
        """When no portfolio snapshot exists, all values are zero."""
        data = CollectedData(portfolio_snapshot=None)
        section = build_pnl_section(data)

        assert section.realized_pnl == 0.0
        assert section.unrealized_pnl == 0.0
        assert section.daily_return == 0.0
        assert section.cumulative_return == 0.0
        assert section.win_count == 0
        assert section.loss_count == 0
        assert section.win_rate == 0.0
        assert section.profit_factor == 0.0
        assert section.sharpe_ratio == 0.0

    def test_profit_factor_from_closed_positions(self) -> None:
        """Profit factor = sum(gains) / abs(sum(losses)) from closed positions."""
        snap = _make_snapshot()
        closed = [
            _make_closed_position("AAPL", 100.0, 110.0, 50.0),   # gain
            _make_closed_position("MSFT", 200.0, 190.0, -20.0),  # loss
            _make_closed_position("GOOG", 150.0, 160.0, 30.0),   # gain
        ]
        data = CollectedData(portfolio_snapshot=snap, closed_positions=closed)
        section = build_pnl_section(data)

        # gains = 50 + 30 = 80, losses = 20
        expected_pf = 80.0 / 20.0
        assert abs(section.profit_factor - expected_pf) < 1e-9

    def test_profit_factor_no_losses(self) -> None:
        """When there are no losses, profit factor is 0.0 (no divisor)."""
        snap = _make_snapshot()
        closed = [
            _make_closed_position("AAPL", 100.0, 110.0, 50.0),
        ]
        data = CollectedData(portfolio_snapshot=snap, closed_positions=closed)
        section = build_pnl_section(data)

        assert section.profit_factor == 0.0

    def test_profit_factor_no_closed_positions(self) -> None:
        """When there are no closed positions, profit factor is 0.0."""
        snap = _make_snapshot()
        data = CollectedData(portfolio_snapshot=snap, closed_positions=[])
        section = build_pnl_section(data)

        assert section.profit_factor == 0.0

    def test_snapshot_with_none_values(self) -> None:
        """Snapshot fields that are None are coerced to zero."""
        snap = _make_snapshot(
            realized_pnl=None,
            unrealized_pnl=None,
            daily_return=None,
            win_count=None,
        )
        data = CollectedData(portfolio_snapshot=snap)
        section = build_pnl_section(data)

        assert section.realized_pnl == 0.0
        assert section.unrealized_pnl == 0.0
        assert section.daily_return == 0.0
        assert section.win_count == 0


# ═══════════════════════════════════════════════════════════════════════
# 2. build_recommendation_accuracy_section
# Requirements validated: 3.2
# ═══════════════════════════════════════════════════════════════════════


class TestBuildRecommendationAccuracySection:
    """Tests for build_recommendation_accuracy_section."""

    def test_with_act_and_skip_decisions(self) -> None:
        """Correctly counts act/skip and computes win rate and confidence."""
        rec_id_1 = str(uuid.uuid4())
        rec_id_2 = str(uuid.uuid4())
        rec_id_3 = str(uuid.uuid4())

        data = CollectedData(
            trading_decisions=[
                {"id": "td1", "recommendation_id": rec_id_1, "decision": "act", "ticker": "AAPL"},
                {"id": "td2", "recommendation_id": rec_id_2, "decision": "skip", "ticker": "MSFT"},
                {"id": "td3", "recommendation_id": rec_id_3, "decision": "act", "ticker": "GOOG"},
            ],
            recommendations=[
                {"id": rec_id_1, "confidence": 0.8},
                {"id": rec_id_2, "confidence": 0.3},
                {"id": rec_id_3, "confidence": 0.9},
            ],
            prediction_outcomes=[
                {"ticker": "AAPL", "profitable": True, "direction_correct": True},
                {"ticker": "GOOG", "profitable": False, "direction_correct": False},
            ],
        )
        section = build_recommendation_accuracy_section(data)

        assert isinstance(section, RecommendationAccuracySection)
        assert section.total_evaluated == 3
        assert section.act_count == 2
        assert section.skip_count == 1
        # 1 win out of 2 acted with outcomes
        assert abs(section.acted_win_rate - 0.5) < 1e-9
        # avg confidence acted = (0.8 + 0.9) / 2 = 0.85
        assert abs(section.avg_confidence_acted - 0.85) < 1e-9
        # avg confidence skipped = 0.3
        assert abs(section.avg_confidence_skipped - 0.3) < 1e-9

    def test_no_decisions_returns_zeros(self) -> None:
        """When there are no trading decisions, all values are zero."""
        data = CollectedData(trading_decisions=[])
        section = build_recommendation_accuracy_section(data)

        assert section.total_evaluated == 0
        assert section.act_count == 0
        assert section.skip_count == 0
        assert section.acted_win_rate == 0.0
        assert section.avg_confidence_acted == 0.0
        assert section.avg_confidence_skipped == 0.0

    def test_all_act_decisions(self) -> None:
        """When all decisions are 'act', skip_count is 0."""
        rec_id = str(uuid.uuid4())
        data = CollectedData(
            trading_decisions=[
                {"id": "td1", "recommendation_id": rec_id, "decision": "act", "ticker": "AAPL"},
            ],
            recommendations=[
                {"id": rec_id, "confidence": 0.75},
            ],
            prediction_outcomes=[
                {"ticker": "AAPL", "profitable": True, "direction_correct": True},
            ],
        )
        section = build_recommendation_accuracy_section(data)

        assert section.act_count == 1
        assert section.skip_count == 0
        assert section.acted_win_rate == 1.0
        assert abs(section.avg_confidence_acted - 0.75) < 1e-9
        assert section.avg_confidence_skipped == 0.0

    def test_act_without_prediction_outcome(self) -> None:
        """When an acted decision has no matching prediction outcome, win rate is 0."""
        rec_id = str(uuid.uuid4())
        data = CollectedData(
            trading_decisions=[
                {"id": "td1", "recommendation_id": rec_id, "decision": "act", "ticker": "AAPL"},
            ],
            recommendations=[
                {"id": rec_id, "confidence": 0.6},
            ],
            prediction_outcomes=[],  # no outcomes
        )
        section = build_recommendation_accuracy_section(data)

        assert section.act_count == 1
        assert section.acted_win_rate == 0.0


# ═══════════════════════════════════════════════════════════════════════
# 3. build_position_performance_section
# Requirements validated: 3.3
# ═══════════════════════════════════════════════════════════════════════


class TestBuildPositionPerformanceSection:
    """Tests for build_position_performance_section."""

    def test_with_open_positions(self) -> None:
        """Open positions are listed with computed P&L and P&L%."""
        pos = _make_open_position("AAPL", 150.0, 160.0, 10.0)
        data = CollectedData(open_positions=[pos])
        section = build_position_performance_section(data)

        assert isinstance(section, PositionPerformanceSection)
        assert len(section.positions) == 1

        p = section.positions[0]
        assert p.ticker == "AAPL"
        assert p.entry_price == 150.0
        assert p.current_or_exit_price == 160.0
        assert p.status == "open"
        # pnl = (160 - 150) * 10 = 100
        assert abs(p.pnl - 100.0) < 1e-9
        # pnl_pct = 100 / (150 * 10) * 100 = 6.666...%
        assert abs(p.pnl_pct - (100.0 / 1500.0 * 100)) < 1e-6

    def test_with_closed_positions(self) -> None:
        """Closed positions use realized_pnl directly."""
        pos = _make_closed_position("MSFT", 200.0, 210.0, 50.0)
        data = CollectedData(closed_positions=[pos])
        section = build_position_performance_section(data)

        assert len(section.positions) == 1
        p = section.positions[0]
        assert p.ticker == "MSFT"
        assert p.status == "closed"
        assert p.pnl == 50.0

    def test_empty_positions(self) -> None:
        """When there are no positions, the list is empty."""
        data = CollectedData(open_positions=[], closed_positions=[])
        section = build_position_performance_section(data)

        assert isinstance(section, PositionPerformanceSection)
        assert len(section.positions) == 0

    def test_mixed_open_and_closed(self) -> None:
        """Both open and closed positions appear in the output."""
        open_pos = _make_open_position("AAPL", 150.0, 160.0, 10.0)
        closed_pos = _make_closed_position("GOOG", 100.0, 90.0, -25.0)
        data = CollectedData(open_positions=[open_pos], closed_positions=[closed_pos])
        section = build_position_performance_section(data)

        assert len(section.positions) == 2
        tickers = {p.ticker for p in section.positions}
        assert tickers == {"AAPL", "GOOG"}

        statuses = {p.ticker: p.status for p in section.positions}
        assert statuses["AAPL"] == "open"
        assert statuses["GOOG"] == "closed"

    def test_single_position(self) -> None:
        """A single open position is handled correctly."""
        pos = _make_open_position("TSLA", 250.0, 250.0, 5.0)
        data = CollectedData(open_positions=[pos])
        section = build_position_performance_section(data)

        assert len(section.positions) == 1
        p = section.positions[0]
        # pnl = (250 - 250) * 5 = 0
        assert p.pnl == 0.0
        assert p.pnl_pct == 0.0

    def test_hold_duration_computed(self) -> None:
        """Hold duration is computed from updated_at to now."""
        # Use a fixed updated_at far enough in the past to get a positive duration
        updated = datetime(2025, 1, 10, 12, 0, tzinfo=timezone.utc)
        pos = _make_open_position("AAPL", 100.0, 110.0, 1.0, updated_at=updated)
        data = CollectedData(open_positions=[pos])
        section = build_position_performance_section(data)

        # Hold duration should be positive (since updated_at is in the past)
        assert section.positions[0].hold_duration_hours > 0.0


# ═══════════════════════════════════════════════════════════════════════
# 4. build_risk_metrics_section
# Requirements validated: 3.4
# ═══════════════════════════════════════════════════════════════════════


class TestBuildRiskMetricsSection:
    """Tests for build_risk_metrics_section."""

    def test_with_snapshot(self) -> None:
        """Risk metrics are extracted from the portfolio snapshot."""
        snap = _make_snapshot(
            risk_tier="high",
            portfolio_heat=0.25,
            max_drawdown=0.10,
            current_drawdown_pct=0.05,
        )
        data = CollectedData(
            portfolio_snapshot=snap,
            reserve_pool_balance=500.0,
            circuit_breaker_events=[{"id": "cb1"}, {"id": "cb2"}],
        )
        section = build_risk_metrics_section(data)

        assert isinstance(section, RiskMetricsSection)
        assert section.current_risk_tier == "high"
        assert section.portfolio_heat == 0.25
        assert section.max_drawdown == 0.10
        assert section.current_drawdown_pct == 0.05
        assert section.reserve_pool_balance == 500.0
        assert section.circuit_breaker_event_count == 2

    def test_no_snapshot(self) -> None:
        """When no snapshot exists, risk tier is 'unknown' and metrics are zero."""
        data = CollectedData(
            portfolio_snapshot=None,
            reserve_pool_balance=300.0,
            circuit_breaker_events=[],
        )
        section = build_risk_metrics_section(data)

        assert section.current_risk_tier == "unknown"
        assert section.portfolio_heat == 0.0
        assert section.max_drawdown == 0.0
        assert section.current_drawdown_pct == 0.0
        assert section.reserve_pool_balance == 300.0
        assert section.circuit_breaker_event_count == 0

    def test_circuit_breaker_count(self) -> None:
        """Circuit breaker event count matches the number of events."""
        events = [{"id": f"cb{i}"} for i in range(5)]
        data = CollectedData(
            portfolio_snapshot=_make_snapshot(),
            circuit_breaker_events=events,
            reserve_pool_balance=0.0,
        )
        section = build_risk_metrics_section(data)

        assert section.circuit_breaker_event_count == 5

    def test_zero_circuit_breaker_events(self) -> None:
        """Zero circuit breaker events when list is empty."""
        data = CollectedData(
            portfolio_snapshot=_make_snapshot(),
            circuit_breaker_events=[],
            reserve_pool_balance=100.0,
        )
        section = build_risk_metrics_section(data)

        assert section.circuit_breaker_event_count == 0


# ═══════════════════════════════════════════════════════════════════════
# 5. build_model_quality_section
# Requirements validated: 3.5
# ═══════════════════════════════════════════════════════════════════════


class TestBuildModelQualitySection:
    """Tests for build_model_quality_section."""

    def test_with_all_windows(self) -> None:
        """Model quality section extracts metrics for 7d, 30d, 90d windows."""
        snapshots = [
            {
                "lookback_window": "7d",
                "generated_at": "2025-01-15T20:00:00Z",
                "win_rate": 0.65,
                "directional_accuracy": 0.62,
                "information_coefficient": 0.08,
                "calibration_error": 0.12,
                "brier_score": 0.22,
            },
            {
                "lookback_window": "30d",
                "generated_at": "2025-01-15T20:00:00Z",
                "win_rate": 0.60,
                "directional_accuracy": 0.58,
                "information_coefficient": 0.06,
                "calibration_error": 0.15,
                "brier_score": 0.25,
            },
            {
                "lookback_window": "90d",
                "generated_at": "2025-01-15T20:00:00Z",
                "win_rate": 0.55,
                "directional_accuracy": 0.53,
                "information_coefficient": 0.04,
                "calibration_error": 0.18,
                "brier_score": 0.28,
            },
        ]
        data = CollectedData(model_metric_snapshots=snapshots)
        section = build_model_quality_section(data)

        assert isinstance(section, ModelQualitySection)
        assert len(section.windows) == 3

        by_lookback = {w.lookback: w for w in section.windows}
        assert by_lookback["7d"].win_rate == 0.65
        assert by_lookback["7d"].directional_accuracy == 0.62
        assert by_lookback["7d"].information_coefficient == 0.08
        assert by_lookback["7d"].calibration_error == 0.12
        assert by_lookback["7d"].brier_score == 0.22

        assert by_lookback["30d"].win_rate == 0.60
        assert by_lookback["90d"].win_rate == 0.55

    def test_no_snapshots(self) -> None:
        """When there are no model metric snapshots, windows list is empty."""
        data = CollectedData(model_metric_snapshots=[])
        section = build_model_quality_section(data)

        assert isinstance(section, ModelQualitySection)
        assert len(section.windows) == 0

    def test_partial_windows(self) -> None:
        """When only some lookback windows are present, missing ones get None values."""
        snapshots = [
            {
                "lookback_window": "7d",
                "generated_at": "2025-01-15T20:00:00Z",
                "win_rate": 0.70,
                "directional_accuracy": 0.68,
                "information_coefficient": 0.10,
                "calibration_error": 0.08,
                "brier_score": 0.18,
            },
        ]
        data = CollectedData(model_metric_snapshots=snapshots)
        section = build_model_quality_section(data)

        assert len(section.windows) == 3
        by_lookback = {w.lookback: w for w in section.windows}

        # 7d has values
        assert by_lookback["7d"].win_rate == 0.70

        # 30d and 90d have None values
        assert by_lookback["30d"].win_rate is None
        assert by_lookback["30d"].directional_accuracy is None
        assert by_lookback["90d"].win_rate is None
        assert by_lookback["90d"].brier_score is None

    def test_takes_latest_snapshot_per_window(self) -> None:
        """When multiple snapshots exist for a window, the first (latest) is used."""
        snapshots = [
            {
                "lookback_window": "7d",
                "generated_at": "2025-01-15T20:00:00Z",
                "win_rate": 0.70,
                "directional_accuracy": None,
                "information_coefficient": None,
                "calibration_error": None,
                "brier_score": None,
            },
            {
                "lookback_window": "7d",
                "generated_at": "2025-01-14T20:00:00Z",
                "win_rate": 0.50,
                "directional_accuracy": None,
                "information_coefficient": None,
                "calibration_error": None,
                "brier_score": None,
            },
        ]
        data = CollectedData(model_metric_snapshots=snapshots)
        section = build_model_quality_section(data)

        by_lookback = {w.lookback: w for w in section.windows}
        # Collector orders by generated_at DESC, so first entry (0.70) is latest
        assert by_lookback["7d"].win_rate == 0.70

    def test_none_metric_values(self) -> None:
        """Snapshot with None metric values produces None in the window."""
        snapshots = [
            {
                "lookback_window": "7d",
                "generated_at": "2025-01-15T20:00:00Z",
                "win_rate": None,
                "directional_accuracy": None,
                "information_coefficient": None,
                "calibration_error": None,
                "brier_score": None,
            },
        ]
        data = CollectedData(model_metric_snapshots=snapshots)
        section = build_model_quality_section(data)

        w = section.windows[0]
        assert w.win_rate is None
        assert w.directional_accuracy is None
        assert w.information_coefficient is None
        assert w.calibration_error is None
        assert w.brier_score is None
