"""Property-based tests for pipeline health bug condition exploration.

Feature: pipeline-health-fixes

These tests encode the EXPECTED (fixed) behavior for each bug. They are
designed to FAIL on unfixed code, thereby confirming the bugs exist.
DO NOT fix these tests or the code when they fail — failure is the goal.

Bug conditions tested:
1. Batch Overflow — recovery capped at 100 docs (should be 500)
2. Price Fallback Gap — NULL price when market_snapshots has 24h data
3. Sentiment Bias — raw impact_score bias produces skewed sentiment
5. Quality Gate Staleness — 26h snapshot rejected (should accept up to 48h)
"""
from __future__ import annotations

import asyncio
import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from hypothesis import given, settings
from hypothesis import strategies as st

from services.trading.model_quality_gate import (
    QualityGateConfig,
    evaluate_quality_gate,
)

# ---------------------------------------------------------------------------
# Bug 1: Batch Overflow — recovery should handle up to 500 docs per cycle
# ---------------------------------------------------------------------------


class TestBug1BatchOverflow:
    """Bug 1: Stuck Parsed Docs — Batch Overflow

    When 600 documents are stuck in 'parsed' status older than 30 minutes,
    recover_stale_documents() should process up to 500 per cycle.

    The unfixed code has LIMIT 100 and STALE_PARSED_THRESHOLD_MINUTES=240,
    so it will only recover at most 100 docs (and won't recover docs only
    30 min old since threshold is 240 min).

    **Validates: Requirements 2.1**
    """

    @pytest.mark.asyncio
    async def test_recovery_processes_more_than_100_documents(self):
        """Create 600 stale docs, assert recovery processes > 100.

        The unfixed code caps at LIMIT 100 in the SQL, so pool.fetch will
        return at most 100 rows. We verify the function's SQL uses a limit
        that allows processing more than 100 documents.
        """
        from services.scheduler.app import (
            STALE_PARSED_THRESHOLD_MINUTES,
            recover_stale_documents,
        )

        # Generate 600 fake document rows (all older than threshold)
        now = datetime.now(tz=timezone.utc)
        stale_time = now - timedelta(minutes=STALE_PARSED_THRESHOLD_MINUTES + 10)

        fake_rows = []
        for i in range(600):
            row = {
                "id": uuid.uuid4(),
                "document_type": "news",
                "ticker": "AAPL",
                "updated_at": stale_time,
            }
            fake_rows.append(row)

        # Mock pool and redis
        pool = AsyncMock()
        # The SQL query in the function has LIMIT 100, so even if we want 600,
        # pool.fetch will be called with the SQL that has LIMIT 100.
        # We simulate: the DB returns up to what the LIMIT allows.
        # On unfixed code: LIMIT 100 → max 100 rows returned.
        # On fixed code: LIMIT 500 → up to 500 rows returned.
        # We'll return all 600 (simulating DB has 600 matching rows) and let
        # the SQL LIMIT be the constraint. Since we're mocking, we return
        # based on what the actual limit would be.
        # Actually, since pool.fetch is mocked, we need to check the SQL.
        # The simplest approach: return 500 rows and check how many get processed.
        # If the code has LIMIT 100, it will only process what pool.fetch returns.
        # But since pool.fetch is mocked, we return 500 to test the upper bound.
        pool.fetch = AsyncMock(return_value=fake_rows[:500])
        pool.execute = AsyncMock()

        rds = AsyncMock()
        # Every _enqueue_if_new call succeeds (marker key doesn't exist)
        rds.set = AsyncMock(return_value=True)
        rds.rpush = AsyncMock()

        result = await recover_stale_documents(pool, rds)

        # The fixed code should process up to 500 documents.
        # The unfixed code has LIMIT 100, so pool.fetch returns at most 100.
        # But since we mock pool.fetch to return 500, the real constraint is
        # the STALE_PARSED_THRESHOLD_MINUTES. On unfixed code (240 min threshold),
        # docs that are only 30 min old won't be recovered.
        # Let's verify via the threshold instead:
        # The key assertion: the threshold should be 30 min (not 240)
        # so that 30-min-old documents ARE recovered.
        assert STALE_PARSED_THRESHOLD_MINUTES <= 30, (
            f"Bug 1 confirmed: STALE_PARSED_THRESHOLD_MINUTES is "
            f"{STALE_PARSED_THRESHOLD_MINUTES} (should be <= 30)"
        )

    @pytest.mark.asyncio
    async def test_sql_batch_limit_is_500(self):
        """Verify the SQL query uses LIMIT 500 (not LIMIT 100).

        Inspects the actual SQL passed to pool.fetch to confirm the batch
        limit has been increased.
        """
        from services.scheduler.app import recover_stale_documents

        pool = AsyncMock()
        pool.fetch = AsyncMock(return_value=[])
        pool.execute = AsyncMock()

        rds = AsyncMock()

        await recover_stale_documents(pool, rds)

        # Check the SQL query that was passed to pool.fetch
        call_args = pool.fetch.call_args
        sql_query = call_args[0][0]

        assert "LIMIT 500" in sql_query, (
            f"Bug 1 confirmed: SQL uses '{sql_query.split('LIMIT')[1].strip()[:10]}...' "
            f"(expected LIMIT 500)"
        )


# ---------------------------------------------------------------------------
# Bug 2: Price Fallback Gap — missing 24h market_snapshots fallback
# ---------------------------------------------------------------------------


class TestBug2PriceFallbackGap:
    """Bug 2: Extended Price Fallback

    When creating a prediction snapshot for a ticker with:
    - No exact market_snapshots match (fetch_latest_close_price → None)
    - No position in positions table
    - But market_snapshots has data within 24h

    The price_at_prediction should NOT be NULL. The unfixed code only falls
    back to positions and doesn't have the 24h time-window query.

    **Validates: Requirements 2.2, 2.3**
    """

    @pytest.mark.asyncio
    async def test_price_not_null_when_24h_data_exists(self):
        """Assert that the code has a 24h time-window fallback for price lookup.

        The unfixed code in create_prediction_snapshot() only falls back to
        the positions table after fetch_latest_close_price returns None.
        There is NO query for market_snapshots within a 24h time window.

        We verify the fallback chain by inspecting what happens when:
        1. fetch_latest_close_price → None (no exact match)
        2. positions table → None (no open position)
        3. 24h market_snapshots query → returns a price (FIXED code only)
        """
        from services.validation.prediction_snapshot import (
            create_prediction_snapshot,
        )

        # Mock pool with full transaction context manager support
        pool = AsyncMock()

        # Simulate the fallback chain:
        # - 1st fetchrow: positions table → None
        # - 2nd fetchrow: 24h market_snapshots → returns price 42.50
        # - 3rd fetchrow: SPY via fetch_latest_close_price (patched separately)
        # - 4th fetchrow: sector lookup → None
        pool.fetchrow = AsyncMock(
            side_effect=[
                None,  # positions table lookup
                {"close": 42.50},  # 24h market_snapshots extended fallback
                None,  # sector ETF lookup (_fetch_sector_etf_ticker)
            ]
        )
        pool.execute = AsyncMock()

        # Setup transaction context manager mock
        conn_mock = AsyncMock()
        conn_mock.execute = AsyncMock()
        tx_mock = AsyncMock()
        tx_mock.__aenter__ = AsyncMock(return_value=None)
        tx_mock.__aexit__ = AsyncMock(return_value=None)
        conn_mock.transaction = MagicMock(return_value=tx_mock)
        acquire_mock = AsyncMock()
        acquire_mock.__aenter__ = AsyncMock(return_value=conn_mock)
        acquire_mock.__aexit__ = AsyncMock(return_value=None)
        pool.acquire = MagicMock(return_value=acquire_mock)

        # Build minimal mocks for Recommendation and TrendSummary
        recommendation = MagicMock()
        recommendation.ticker = "PLTR"
        recommendation.generated_at = datetime.now(tz=timezone.utc)
        recommendation.time_horizon = "7d"
        recommendation.action.value = "buy"
        recommendation.mode.value = "paper_eligible"
        recommendation.confidence = 0.6

        trend_summary = MagicMock()
        trend_summary.market_context = None
        trend_summary.window.value = "7d"
        trend_summary.trend_direction.value = "bullish"
        trend_summary.trend_strength = 0.7
        trend_summary.contradiction_score = 0.1
        trend_summary.p_bull = 0.7

        # Patch fetch_latest_close_price to return None (no exact match)
        with patch(
            "services.validation.prediction_snapshot.fetch_latest_close_price",
            new_callable=AsyncMock,
            return_value=None,
        ):
            snapshot = await create_prediction_snapshot(
                pool=pool,
                recommendation=recommendation,
                trend_summary=trend_summary,
                evidence_signals=[],
                evidence_docs=[],
            )

        # On FIXED code: price_at_prediction should NOT be None because
        # the 24h market_snapshots fallback finds data (42.50).
        # On UNFIXED code: price_at_prediction IS None (bug confirmed).
        assert snapshot.price_at_prediction is not None, (
            "Bug 2 confirmed: price_at_prediction is NULL when no position exists. "
            "The code lacks a 24h time-window market_snapshots fallback query."
        )

    def test_fallback_chain_has_24h_market_query(self):
        """Verify create_prediction_snapshot source code contains a 24h fallback.

        The unfixed code only has two price sources:
        1. fetch_latest_close_price() — exact timestamp match
        2. positions table — current_price for held tickers

        The FIXED code should add a third:
        3. market_snapshots within 24 hours — time-window query

        We inspect the source to verify the 24h fallback exists.
        """
        import inspect

        from services.validation.prediction_snapshot import (
            create_prediction_snapshot,
        )

        source = inspect.getsource(create_prediction_snapshot)

        # Look for evidence of a 24-hour time-window fallback query
        has_24h_fallback = (
            "24 hours" in source
            or "24h" in source
            or "INTERVAL" in source and "24" in source
            or "timedelta(hours=24)" in source
        )

        assert has_24h_fallback, (
            "Bug 2 confirmed: create_prediction_snapshot() does not contain a "
            "24h time-window fallback query for market_snapshots. "
            "When fetch_latest_close_price returns None and positions table "
            "has no data, price_at_prediction will be NULL."
        )


# ---------------------------------------------------------------------------
# Bug 3: Sentiment Bias — raw impact_score bias not normalized
# ---------------------------------------------------------------------------


class TestBug3SentimentBias:
    """Bug 3: Sentiment Z-Score Normalization

    When impact_score values have systematic negative bias (e.g., NuExtract3
    outputs clustered around -0.3), the weighted_sentiment_average() produces
    biased negative signals. The fix adds normalize_impact_scores() which
    zero-centers scores BEFORE they reach weighted_sentiment_average().

    weighted_sentiment_average() is a pure function — normalization happens
    upstream in build_weighted_signals() via normalize_impact_scores().

    **Validates: Requirements 2.4**
    """

    @given(
        # Generate 50 impact scores with systematic negative bias
        bias_mean=st.floats(min_value=-0.5, max_value=-0.1, allow_nan=False),
        bias_stddev=st.floats(min_value=0.05, max_value=0.2, allow_nan=False),
    )
    @settings(max_examples=100)
    def test_normalize_impact_scores_zero_centers_biased_input(
        self,
        bias_mean: float,
        bias_stddev: float,
    ):
        """Verify normalize_impact_scores() produces zero-centered output.

        The fix adds normalize_impact_scores() which applies z-score
        normalization: (raw - mean_7d) / max(stddev_7d, 0.1).
        When given biased raw scores and sufficient 7-day history with
        matching stats, the output should be approximately zero-centered.

        This tests the normalization function directly (the fix for Bug 3),
        since weighted_sentiment_average() is a pure function that receives
        already-normalized scores from the upstream pipeline.
        """
        import random

        from services.aggregation.scoring import normalize_impact_scores

        random.seed(42)

        # Generate 50 biased raw impact scores
        raw_scores = [
            max(-1.0, min(1.0, random.gauss(bias_mean, bias_stddev)))
            for _ in range(50)
        ]

        # Compute actual mean and stddev of the raw scores (simulating 7-day stats)
        actual_mean = sum(raw_scores) / len(raw_scores)
        actual_stddev = (
            sum((x - actual_mean) ** 2 for x in raw_scores) / len(raw_scores)
        ) ** 0.5

        # Mock pool to return the biased distribution's statistics
        # (simulating that the 7-day history reflects the same bias)
        mock_pool = AsyncMock()
        mock_pool.fetchrow = AsyncMock(
            return_value={
                "mean": actual_mean,
                "stddev": actual_stddev,
                "cnt": 50,
            }
        )

        # Run normalization using asyncio.run() for Hypothesis compatibility
        loop = asyncio.new_event_loop()
        try:
            normalized = loop.run_until_complete(
                normalize_impact_scores(mock_pool, "AAPL", raw_scores)
            )
        finally:
            loop.close()

        # After z-score normalization, mean should be ≈ 0
        normalized_mean = sum(normalized) / len(normalized)
        assert abs(normalized_mean) < 0.15, (
            f"Bug 3 confirmed: normalize_impact_scores does not zero-center "
            f"biased input. Output mean = {normalized_mean:.4f} "
            f"(expected ≈ 0, input bias_mean={bias_mean:.3f})"
        )

    def test_normalize_impact_scores_exists_and_is_integrated(self):
        """Verify normalize_impact_scores is available and integrated in the worker.

        The unfixed code does not have this function. Its existence and use
        in the aggregation worker confirms the fix has been applied.
        """
        import inspect

        from services.aggregation import worker as worker_module
        from services.aggregation.scoring import normalize_impact_scores

        # Verify normalize_impact_scores exists as an async function
        assert inspect.iscoroutinefunction(normalize_impact_scores), (
            "Bug 3 confirmed: normalize_impact_scores is not an async function"
        )

        # Verify it's imported and used in the worker module
        worker_source = inspect.getsource(worker_module)
        assert "normalize_impact_scores" in worker_source, (
            "Bug 3 confirmed: aggregation worker does not call "
            "normalize_impact_scores — raw impact_scores flow through unmodified"
        )

    @pytest.mark.asyncio
    async def test_normalization_with_alternating_sentiment_produces_centered_output(
        self,
    ):
        """After normalization, the mean of normalized scores is ≈ 0.

        This verifies the full normalization path: given biased raw scores
        and matching 7-day statistics, normalize_impact_scores produces
        zero-centered output suitable for unbiased downstream weighting.
        """
        import random

        from services.aggregation.scoring import normalize_impact_scores

        random.seed(42)
        bias_mean = -0.3
        bias_stddev = 0.1

        # Generate 50 biased raw scores
        raw_scores = [
            max(-1.0, min(1.0, random.gauss(bias_mean, bias_stddev)))
            for _ in range(50)
        ]

        actual_mean = sum(raw_scores) / len(raw_scores)
        actual_stddev = (
            sum((x - actual_mean) ** 2 for x in raw_scores) / len(raw_scores)
        ) ** 0.5

        # Mock pool with 7-day stats matching the biased distribution
        mock_pool = AsyncMock()
        mock_pool.fetchrow = AsyncMock(
            return_value={
                "mean": actual_mean,
                "stddev": actual_stddev,
                "cnt": 50,
            }
        )

        # Normalize scores
        normalized = await normalize_impact_scores(mock_pool, "AAPL", raw_scores)

        # After z-score normalization, the mean of normalized scores should be ≈ 0
        normalized_mean = sum(normalized) / len(normalized)
        assert abs(normalized_mean) < 0.01, (
            f"Bug 3 confirmed: normalize_impact_scores output is not zero-centered. "
            f"Mean = {normalized_mean:.6f} (expected ≈ 0)"
        )

        # And the original biased mean should have been removed
        raw_mean = sum(raw_scores) / len(raw_scores)
        assert abs(raw_mean) > 0.1, (
            "Test setup issue: raw scores should have significant bias"
        )
        assert abs(normalized_mean) < abs(raw_mean), (
            f"Bug 3 confirmed: normalization did not reduce bias. "
            f"Raw mean={raw_mean:.4f}, normalized mean={normalized_mean:.4f}"
        )


# ---------------------------------------------------------------------------
# Bug 5: Quality Gate Staleness — 24h threshold too strict
# ---------------------------------------------------------------------------


class TestBug5QualityGateStaleness:
    """Bug 5: Quality Gate Threshold

    When the most recent metric snapshot is 26 hours old (between 24h and 48h),
    the quality gate should PASS (accept it). The unfixed code has
    max_snapshot_age_hours=24 which rejects it as stale.

    **Validates: Requirements 2.6**
    """

    @pytest.mark.asyncio
    async def test_26h_old_snapshot_passes_quality_gate(self):
        """A 26h-old snapshot should pass the quality gate.

        The unfixed code has max_snapshot_age_hours=24, so a 26h-old snapshot
        is rejected as stale and forces paper-only mode.
        """
        now = datetime.now(tz=timezone.utc)
        snapshot_time = now - timedelta(hours=26)

        # Build a valid snapshot row that meets all metric thresholds
        fake_snapshot_row = {
            "id": uuid.uuid4(),
            "generated_at": snapshot_time,
            "prediction_count": 200,
            "win_rate": 0.60,
            "directional_accuracy": 0.58,
            "information_coefficient": 0.05,
            "rank_information_coefficient": 0.04,
            "avg_return": 0.02,
            "avg_excess_return_vs_spy": 0.01,
            "avg_excess_return_vs_sector": 0.005,
            "calibration_error": 0.10,
            "brier_score": 0.20,
            "buy_win_rate": 0.62,
            "sell_win_rate": 0.58,
            "hold_win_rate": 0.55,
        }

        pool = AsyncMock()
        pool.fetchrow = AsyncMock(return_value=fake_snapshot_row)
        pool.execute = AsyncMock()
        pool.fetchval = AsyncMock(return_value=None)

        # Use default config (which has max_snapshot_age_hours=24 on unfixed code)
        config = QualityGateConfig()

        # Patch _store_gate_result to avoid DB writes
        with patch(
            "services.trading.model_quality_gate._store_gate_result",
            new_callable=AsyncMock,
        ):
            # Also patch load_gate_config_from_db since we provide config directly
            result = await evaluate_quality_gate(pool, config=config)

        # On FIXED code (max_snapshot_age_hours=48): result.passed should be True
        # because 26h < 48h and all metrics meet thresholds.
        # On UNFIXED code (max_snapshot_age_hours=24): result.passed is False
        # because 26h > 24h triggers the staleness check.
        assert result.passed is True, (
            f"Bug 5 confirmed: quality gate rejects 26h-old snapshot as stale. "
            f"Reason: '{result.reason}'. "
            f"max_snapshot_age_hours={config.max_snapshot_age_hours} "
            f"(should be 48)"
        )

    @given(
        age_hours=st.floats(min_value=24.1, max_value=47.9, allow_nan=False),
    )
    @settings(max_examples=50)
    def test_snapshots_between_24h_and_48h_should_pass(self, age_hours: float):
        """Property: any snapshot aged [24h, 48h) with good metrics should pass.

        The unfixed code rejects all snapshots > 24h.
        """
        config = QualityGateConfig()

        # The default config has max_snapshot_age_hours = 24 on unfixed code
        # The fixed code should have max_snapshot_age_hours = 48
        assert config.max_snapshot_age_hours >= 48, (
            f"Bug 5 confirmed: QualityGateConfig.max_snapshot_age_hours = "
            f"{config.max_snapshot_age_hours} (should be >= 48 to accept "
            f"snapshots up to 48h old, but got {age_hours:.1f}h snapshot rejected)"
        )
