"""Unit tests for report generator orchestrator.

Tests the orchestration flow in services.reporting.generator with mocked
dependencies (collector, section builders, validator, summarizer).

Requirements validated: 5.1, 5.2, 5.3
"""
from __future__ import annotations

import uuid
from datetime import date, datetime, timezone
from unittest.mock import AsyncMock, patch

import pytest

from services.reporting.collector import CollectedData
from services.reporting.generator import (
    _in_progress_jobs,
    generate_report,
    process_report_job,
    store_report,
)
from services.reporting.models import (
    ModelQualitySection,
    ModelQualityWindow,
    PLSection,
    PositionPerformanceSection,
    RecommendationAccuracySection,
    ReportData,
    ReportType,
    RiskMetricsSection,
    ValidationStatus,
)

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


def _make_report_data(**overrides: object) -> ReportData:
    """Build a minimal valid ReportData for testing."""
    defaults = {
        "pnl": PLSection(
            realized_pnl=100.0,
            unrealized_pnl=-20.0,
            daily_return=0.01,
            cumulative_return=0.05,
            win_count=5,
            loss_count=2,
            win_rate=0.71,
            profit_factor=2.0,
            sharpe_ratio=1.2,
            summary="P&L summary",
        ),
        "recommendation_accuracy": RecommendationAccuracySection(
            total_evaluated=10,
            act_count=6,
            skip_count=4,
            acted_win_rate=0.67,
            avg_confidence_acted=0.75,
            avg_confidence_skipped=0.40,
            summary="Rec accuracy summary",
        ),
        "position_performance": PositionPerformanceSection(
            positions=[],
            summary="Position summary",
        ),
        "risk_metrics": RiskMetricsSection(
            current_risk_tier="moderate",
            portfolio_heat=0.12,
            max_drawdown=0.06,
            current_drawdown_pct=0.02,
            reserve_pool_balance=500.0,
            circuit_breaker_event_count=0,
            summary="Risk summary",
        ),
        "model_quality": ModelQualitySection(
            windows=[
                ModelQualityWindow(
                    lookback="7d",
                    win_rate=0.65,
                    directional_accuracy=0.62,
                    information_coefficient=0.08,
                    calibration_error=0.12,
                    brier_score=0.22,
                ),
            ],
            summary="Model quality summary",
        ),
        "executive_summary": "Executive summary text",
        "validation_status": ValidationStatus.PASSED,
        "generated_at": datetime(2025, 1, 15, 21, 30, tzinfo=timezone.utc),
        "period_start": date(2025, 1, 15),
        "period_end": date(2025, 1, 15),
        "report_type": ReportType.DAILY,
    }
    defaults.update(overrides)
    return ReportData(**defaults)


def _empty_collected_data() -> CollectedData:
    """Build a zero-activity CollectedData."""
    return CollectedData()


def _mock_pool() -> AsyncMock:
    """Create a mock asyncpg pool."""
    pool = AsyncMock()
    return pool


# Patch targets (all in the generator module namespace)
_PATCH_COLLECT = "services.reporting.generator.collect_report_data"
_PATCH_BUILD_PNL = "services.reporting.generator.build_pnl_section"
_PATCH_BUILD_REC = "services.reporting.generator.build_recommendation_accuracy_section"
_PATCH_BUILD_POS = "services.reporting.generator.build_position_performance_section"
_PATCH_BUILD_RISK = "services.reporting.generator.build_risk_metrics_section"
_PATCH_BUILD_MQ = "services.reporting.generator.build_model_quality_section"
_PATCH_VALIDATE_REC = "services.reporting.generator.validate_recommendation_accuracy"
_PATCH_VALIDATE_MQ = "services.reporting.generator.validate_model_quality"
_PATCH_COMPUTE_STATUS = "services.reporting.generator.compute_validation_status"
_PATCH_SUMMARIZE = "services.reporting.generator.summarize_section"
_PATCH_EXEC_SUMMARY = "services.reporting.generator.generate_executive_summary"
_PATCH_RESOLVER = "services.reporting.generator.AgentConfigResolver"


# ═══════════════════════════════════════════════════════════════════════
# 1. generate_report — orchestration flow
# Requirements validated: 5.1
# ═══════════════════════════════════════════════════════════════════════


class TestGenerateReport:
    """Tests for generate_report orchestration."""

    @pytest.mark.asyncio
    async def test_orchestration_calls_all_steps(self) -> None:
        """generate_report calls collector, builders, validators, summarizer in order."""
        pool = _mock_pool()
        collected = _empty_collected_data()

        pnl = PLSection(
            realized_pnl=0, unrealized_pnl=0, daily_return=0,
            cumulative_return=0, win_count=0, loss_count=0,
            win_rate=0, profit_factor=0, sharpe_ratio=0,
        )
        rec = RecommendationAccuracySection(
            total_evaluated=0, act_count=0, skip_count=0,
            acted_win_rate=0, avg_confidence_acted=0, avg_confidence_skipped=0,
        )
        pos = PositionPerformanceSection()
        risk = RiskMetricsSection(
            current_risk_tier="low", portfolio_heat=0, max_drawdown=0,
            current_drawdown_pct=0, reserve_pool_balance=0,
            circuit_breaker_event_count=0,
        )
        mq = ModelQualitySection()

        with (
            patch(_PATCH_COLLECT, new_callable=AsyncMock, return_value=collected) as mock_collect,
            patch(_PATCH_BUILD_PNL, return_value=pnl) as mock_pnl,
            patch(_PATCH_BUILD_REC, return_value=rec) as mock_rec,
            patch(_PATCH_BUILD_POS, return_value=pos) as mock_pos,
            patch(_PATCH_BUILD_RISK, return_value=risk) as mock_risk,
            patch(_PATCH_BUILD_MQ, return_value=mq) as mock_mq,
            patch(_PATCH_VALIDATE_REC, return_value=[]) as mock_val_rec,
            patch(_PATCH_VALIDATE_MQ, return_value=[]) as mock_val_mq,
            patch(_PATCH_COMPUTE_STATUS, return_value=ValidationStatus.PASSED) as mock_status,
            patch(_PATCH_SUMMARIZE, new_callable=AsyncMock, return_value="summary") as mock_sum,
            patch(_PATCH_EXEC_SUMMARY, new_callable=AsyncMock, return_value="exec summary") as mock_exec,
            patch(_PATCH_RESOLVER) as mock_resolver_cls,
        ):
            result = await generate_report(
                pool, ReportType.DAILY, date(2025, 1, 15), date(2025, 1, 15),
            )

            # Collector called with pool and dates
            mock_collect.assert_awaited_once_with(pool, date(2025, 1, 15), date(2025, 1, 15))

            # All section builders called with collected data
            mock_pnl.assert_called_once_with(collected)
            mock_rec.assert_called_once_with(collected)
            mock_pos.assert_called_once_with(collected)
            mock_risk.assert_called_once_with(collected)
            mock_mq.assert_called_once_with(collected)

            # Validators called
            mock_val_rec.assert_called_once_with(rec, collected.prediction_outcomes)
            mock_val_mq.assert_called_once_with(mq, collected.model_metric_snapshots)

            # Summarizer called 5 times (one per section)
            assert mock_sum.await_count == 5

            # Executive summary called
            mock_exec.assert_awaited_once()

            # Validation status computed
            mock_status.assert_called_once()

            # Result is a ReportData
            assert isinstance(result, ReportData)
            assert result.report_type == ReportType.DAILY
            assert result.period_start == date(2025, 1, 15)
            assert result.period_end == date(2025, 1, 15)
            assert result.executive_summary == "exec summary"

    @pytest.mark.asyncio
    async def test_zero_activity_report(self) -> None:
        """generate_report handles zero-activity data (empty CollectedData)."""
        pool = _mock_pool()
        collected = _empty_collected_data()

        pnl = PLSection(
            realized_pnl=0, unrealized_pnl=0, daily_return=0,
            cumulative_return=0, win_count=0, loss_count=0,
            win_rate=0, profit_factor=0, sharpe_ratio=0,
        )
        rec = RecommendationAccuracySection(
            total_evaluated=0, act_count=0, skip_count=0,
            acted_win_rate=0, avg_confidence_acted=0, avg_confidence_skipped=0,
        )
        pos = PositionPerformanceSection()
        risk = RiskMetricsSection(
            current_risk_tier="unknown", portfolio_heat=0, max_drawdown=0,
            current_drawdown_pct=0, reserve_pool_balance=0,
            circuit_breaker_event_count=0,
        )
        mq = ModelQualitySection()

        with (
            patch(_PATCH_COLLECT, new_callable=AsyncMock, return_value=collected),
            patch(_PATCH_BUILD_PNL, return_value=pnl),
            patch(_PATCH_BUILD_REC, return_value=rec),
            patch(_PATCH_BUILD_POS, return_value=pos),
            patch(_PATCH_BUILD_RISK, return_value=risk),
            patch(_PATCH_BUILD_MQ, return_value=mq),
            patch(_PATCH_VALIDATE_REC, return_value=[]),
            patch(_PATCH_VALIDATE_MQ, return_value=[]),
            patch(_PATCH_COMPUTE_STATUS, return_value=ValidationStatus.PASSED),
            patch(_PATCH_SUMMARIZE, new_callable=AsyncMock, return_value="No activity"),
            patch(_PATCH_EXEC_SUMMARY, new_callable=AsyncMock, return_value="No trading activity"),
            patch(_PATCH_RESOLVER),
        ):
            result = await generate_report(
                pool, ReportType.DAILY, date(2025, 1, 15), date(2025, 1, 15),
            )

            assert result.pnl.realized_pnl == 0.0
            assert result.pnl.win_count == 0
            assert result.recommendation_accuracy.total_evaluated == 0
            assert result.position_performance.positions == []
            assert result.risk_metrics.current_risk_tier == "unknown"
            assert result.validation_status == ValidationStatus.PASSED

    @pytest.mark.asyncio
    async def test_validation_warnings_attached(self) -> None:
        """Validation warnings from validators are attached to sections."""
        pool = _mock_pool()
        collected = _empty_collected_data()

        from services.reporting.models import ValidationWarning

        rec_warning = ValidationWarning(
            field_name="acted_win_rate",
            computed_value=0.80,
            snapshot_value=0.60,
            pct_difference=33.33,
        )

        pnl = PLSection(
            realized_pnl=0, unrealized_pnl=0, daily_return=0,
            cumulative_return=0, win_count=0, loss_count=0,
            win_rate=0, profit_factor=0, sharpe_ratio=0,
        )
        rec = RecommendationAccuracySection(
            total_evaluated=5, act_count=3, skip_count=2,
            acted_win_rate=0.80, avg_confidence_acted=0.7, avg_confidence_skipped=0.4,
        )
        pos = PositionPerformanceSection()
        risk = RiskMetricsSection(
            current_risk_tier="moderate", portfolio_heat=0.1, max_drawdown=0.05,
            current_drawdown_pct=0.02, reserve_pool_balance=100,
            circuit_breaker_event_count=0,
        )
        mq = ModelQualitySection()

        with (
            patch(_PATCH_COLLECT, new_callable=AsyncMock, return_value=collected),
            patch(_PATCH_BUILD_PNL, return_value=pnl),
            patch(_PATCH_BUILD_REC, return_value=rec),
            patch(_PATCH_BUILD_POS, return_value=pos),
            patch(_PATCH_BUILD_RISK, return_value=risk),
            patch(_PATCH_BUILD_MQ, return_value=mq),
            patch(_PATCH_VALIDATE_REC, return_value=[rec_warning]),
            patch(_PATCH_VALIDATE_MQ, return_value=[]),
            patch(_PATCH_COMPUTE_STATUS, return_value=ValidationStatus.WARNINGS),
            patch(_PATCH_SUMMARIZE, new_callable=AsyncMock, return_value="summary"),
            patch(_PATCH_EXEC_SUMMARY, new_callable=AsyncMock, return_value="exec"),
            patch(_PATCH_RESOLVER),
        ):
            result = await generate_report(
                pool, ReportType.DAILY, date(2025, 1, 15), date(2025, 1, 15),
            )

            assert result.validation_status == ValidationStatus.WARNINGS
            assert len(result.recommendation_accuracy.validation_warnings) == 1
            assert result.recommendation_accuracy.validation_warnings[0].field_name == "acted_win_rate"

    @pytest.mark.asyncio
    async def test_weekly_report_type(self) -> None:
        """generate_report correctly sets weekly report type."""
        pool = _mock_pool()
        collected = _empty_collected_data()

        pnl = PLSection(
            realized_pnl=0, unrealized_pnl=0, daily_return=0,
            cumulative_return=0, win_count=0, loss_count=0,
            win_rate=0, profit_factor=0, sharpe_ratio=0,
        )
        rec = RecommendationAccuracySection(
            total_evaluated=0, act_count=0, skip_count=0,
            acted_win_rate=0, avg_confidence_acted=0, avg_confidence_skipped=0,
        )
        pos = PositionPerformanceSection()
        risk = RiskMetricsSection(
            current_risk_tier="low", portfolio_heat=0, max_drawdown=0,
            current_drawdown_pct=0, reserve_pool_balance=0,
            circuit_breaker_event_count=0,
        )
        mq = ModelQualitySection()

        with (
            patch(_PATCH_COLLECT, new_callable=AsyncMock, return_value=collected),
            patch(_PATCH_BUILD_PNL, return_value=pnl),
            patch(_PATCH_BUILD_REC, return_value=rec),
            patch(_PATCH_BUILD_POS, return_value=pos),
            patch(_PATCH_BUILD_RISK, return_value=risk),
            patch(_PATCH_BUILD_MQ, return_value=mq),
            patch(_PATCH_VALIDATE_REC, return_value=[]),
            patch(_PATCH_VALIDATE_MQ, return_value=[]),
            patch(_PATCH_COMPUTE_STATUS, return_value=ValidationStatus.PASSED),
            patch(_PATCH_SUMMARIZE, new_callable=AsyncMock, return_value="summary"),
            patch(_PATCH_EXEC_SUMMARY, new_callable=AsyncMock, return_value="exec"),
            patch(_PATCH_RESOLVER),
        ):
            result = await generate_report(
                pool, ReportType.WEEKLY, date(2025, 1, 13), date(2025, 1, 17),
            )

            assert result.report_type == ReportType.WEEKLY
            assert result.period_start == date(2025, 1, 13)
            assert result.period_end == date(2025, 1, 17)


# ═══════════════════════════════════════════════════════════════════════
# 2. store_report — upsert behavior
# Requirements validated: 5.2, 5.3
# ═══════════════════════════════════════════════════════════════════════


class TestStoreReport:
    """Tests for store_report upsert behavior."""

    @pytest.mark.asyncio
    async def test_store_calls_upsert_sql(self) -> None:
        """store_report calls pool.fetchrow with the upsert SQL and correct params."""
        pool = _mock_pool()
        report_id = str(uuid.uuid4())
        pool.fetchrow = AsyncMock(return_value={"id": report_id})

        report = _make_report_data()
        result = await store_report(pool, report)

        assert result == report_id
        pool.fetchrow.assert_awaited_once()

        call_args = pool.fetchrow.call_args
        sql = call_args[0][0]
        assert "INSERT INTO trading_reports" in sql
        assert "ON CONFLICT" in sql
        assert "DO UPDATE" in sql

        # Verify the positional parameters
        assert call_args[0][1] == report.report_type.value
        assert call_args[0][2] == report.period_start
        assert call_args[0][3] == report.period_end
        # param 4 is the JSON string
        assert call_args[0][4] == report.model_dump_json()
        assert call_args[0][5] == report.validation_status.value
        assert call_args[0][6] == report.generated_at

    @pytest.mark.asyncio
    async def test_store_returns_uuid_string(self) -> None:
        """store_report returns the UUID as a string."""
        pool = _mock_pool()
        expected_id = str(uuid.uuid4())
        pool.fetchrow = AsyncMock(return_value={"id": expected_id})

        report = _make_report_data()
        result = await store_report(pool, report)

        assert isinstance(result, str)
        assert result == expected_id

    @pytest.mark.asyncio
    async def test_store_upsert_regeneration(self) -> None:
        """store_report handles regeneration (upsert) for existing period."""
        pool = _mock_pool()
        report_id = str(uuid.uuid4())
        pool.fetchrow = AsyncMock(return_value={"id": report_id})

        # First store
        report1 = _make_report_data()
        result1 = await store_report(pool, report1)

        # Second store (regeneration) — same period, different data
        report2 = _make_report_data(
            executive_summary="Updated executive summary",
            generated_at=datetime(2025, 1, 15, 22, 0, tzinfo=timezone.utc),
        )
        result2 = await store_report(pool, report2)

        # Both calls succeed (upsert handles the conflict)
        assert result1 == report_id
        assert result2 == report_id
        assert pool.fetchrow.await_count == 2


# ═══════════════════════════════════════════════════════════════════════
# 3. process_report_job — job processing
# Requirements validated: 5.1, 5.3
# ═══════════════════════════════════════════════════════════════════════


class TestProcessReportJob:
    """Tests for process_report_job."""

    @pytest.mark.asyncio
    async def test_valid_job_calls_generate_and_store(self) -> None:
        """A valid job payload triggers generate_report and store_report."""
        pool = _mock_pool()
        report = _make_report_data()

        with (
            patch(
                "services.reporting.generator.generate_report",
                new_callable=AsyncMock,
                return_value=report,
            ) as mock_gen,
            patch(
                "services.reporting.generator.store_report",
                new_callable=AsyncMock,
                return_value=str(uuid.uuid4()),
            ) as mock_store,
        ):
            job = {
                "report_type": "daily",
                "period_start": "2025-01-15",
                "period_end": "2025-01-15",
            }
            await process_report_job(pool, job)

            mock_gen.assert_awaited_once_with(
                pool, ReportType.DAILY, date(2025, 1, 15), date(2025, 1, 15),
            )
            mock_store.assert_awaited_once_with(pool, report)

    @pytest.mark.asyncio
    async def test_invalid_report_type_returns_early(self) -> None:
        """An invalid report_type in the job payload causes early return."""
        pool = _mock_pool()

        with (
            patch(
                "services.reporting.generator.generate_report",
                new_callable=AsyncMock,
            ) as mock_gen,
        ):
            job = {
                "report_type": "invalid_type",
                "period_start": "2025-01-15",
                "period_end": "2025-01-15",
            }
            await process_report_job(pool, job)

            mock_gen.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_invalid_date_returns_early(self) -> None:
        """An invalid date in the job payload causes early return."""
        pool = _mock_pool()

        with (
            patch(
                "services.reporting.generator.generate_report",
                new_callable=AsyncMock,
            ) as mock_gen,
        ):
            job = {
                "report_type": "daily",
                "period_start": "not-a-date",
                "period_end": "2025-01-15",
            }
            await process_report_job(pool, job)

            mock_gen.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_missing_fields_returns_early(self) -> None:
        """Missing fields in the job payload causes early return."""
        pool = _mock_pool()

        with (
            patch(
                "services.reporting.generator.generate_report",
                new_callable=AsyncMock,
            ) as mock_gen,
        ):
            job = {}
            await process_report_job(pool, job)

            mock_gen.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_duplicate_job_rejected(self) -> None:
        """A duplicate in-progress job is rejected without calling generate_report."""
        pool = _mock_pool()
        key = "daily:2025-01-20:2025-01-20"

        # Simulate an in-progress job
        _in_progress_jobs.add(key)
        try:
            with (
                patch(
                    "services.reporting.generator.generate_report",
                    new_callable=AsyncMock,
                ) as mock_gen,
            ):
                job = {
                    "report_type": "daily",
                    "period_start": "2025-01-20",
                    "period_end": "2025-01-20",
                }
                await process_report_job(pool, job)

                mock_gen.assert_not_awaited()
        finally:
            _in_progress_jobs.discard(key)

    @pytest.mark.asyncio
    async def test_job_cleans_up_in_progress_on_success(self) -> None:
        """After successful completion, the job key is removed from _in_progress_jobs."""
        pool = _mock_pool()
        report = _make_report_data(
            period_start=date(2025, 1, 21),
            period_end=date(2025, 1, 21),
        )
        key = "daily:2025-01-21:2025-01-21"

        with (
            patch(
                "services.reporting.generator.generate_report",
                new_callable=AsyncMock,
                return_value=report,
            ),
            patch(
                "services.reporting.generator.store_report",
                new_callable=AsyncMock,
                return_value=str(uuid.uuid4()),
            ),
        ):
            job = {
                "report_type": "daily",
                "period_start": "2025-01-21",
                "period_end": "2025-01-21",
            }
            await process_report_job(pool, job)

            assert key not in _in_progress_jobs

    @pytest.mark.asyncio
    async def test_job_cleans_up_in_progress_on_failure(self) -> None:
        """After all retries fail, the job key is still removed from _in_progress_jobs."""
        pool = _mock_pool()
        key = "daily:2025-01-22:2025-01-22"

        with (
            patch(
                "services.reporting.generator.generate_report",
                new_callable=AsyncMock,
                side_effect=RuntimeError("DB down"),
            ),
            patch("asyncio.sleep", new_callable=AsyncMock),
        ):
            job = {
                "report_type": "daily",
                "period_start": "2025-01-22",
                "period_end": "2025-01-22",
            }
            await process_report_job(pool, job)

            assert key not in _in_progress_jobs

    @pytest.mark.asyncio
    async def test_retries_on_failure(self) -> None:
        """process_report_job retries up to 3 times on failure."""
        pool = _mock_pool()
        report = _make_report_data(
            period_start=date(2025, 1, 23),
            period_end=date(2025, 1, 23),
        )

        call_count = 0

        async def _gen_side_effect(*args, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count < 3:
                raise RuntimeError("Transient error")
            return report

        with (
            patch(
                "services.reporting.generator.generate_report",
                new_callable=AsyncMock,
                side_effect=_gen_side_effect,
            ),
            patch(
                "services.reporting.generator.store_report",
                new_callable=AsyncMock,
                return_value=str(uuid.uuid4()),
            ) as mock_store,
            patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
        ):
            job = {
                "report_type": "daily",
                "period_start": "2025-01-23",
                "period_end": "2025-01-23",
            }
            await process_report_job(pool, job)

            # generate_report called 3 times (2 failures + 1 success)
            assert call_count == 3
            # store_report called once on success
            mock_store.assert_awaited_once()
            # sleep called twice (between retries)
            assert mock_sleep.await_count == 2

    @pytest.mark.asyncio
    async def test_weekly_job(self) -> None:
        """A weekly job payload is processed correctly."""
        pool = _mock_pool()
        report = _make_report_data(
            report_type=ReportType.WEEKLY,
            period_start=date(2025, 1, 13),
            period_end=date(2025, 1, 17),
        )

        with (
            patch(
                "services.reporting.generator.generate_report",
                new_callable=AsyncMock,
                return_value=report,
            ) as mock_gen,
            patch(
                "services.reporting.generator.store_report",
                new_callable=AsyncMock,
                return_value=str(uuid.uuid4()),
            ),
        ):
            job = {
                "report_type": "weekly",
                "period_start": "2025-01-13",
                "period_end": "2025-01-17",
            }
            await process_report_job(pool, job)

            mock_gen.assert_awaited_once_with(
                pool, ReportType.WEEKLY, date(2025, 1, 13), date(2025, 1, 17),
            )
