"""Tests: VIX/FRED fetch fallback for paper trading engine.

Covers PaperTradingEngine._fetch_fred_series_with_fallback() — the helper
introduced to bridge transient Oracle 5xx without silently letting the
macro_vix gate pass on unknown VIX.

Behavior contract:
1. Fresh fetch OK -> cache value, log paper_engine_vix_fred_ok, return value.
2. Fresh fetch fails AND cached <24h -> use cached, log
   paper_engine_vix_fred_stale_fallback, return cached value.
3. Fresh fetch fails AND no cache OR cache >=24h -> log
   paper_engine_vix_fred_unavailable_blocking, return None (selector vetoes).

The helper is engine-instance-scoped; we instantiate the engine via
``object.__new__`` to bypass the heavy ``__init__`` and only set the
fields the helper actually touches. This keeps the unit truly unit-scope
and avoids a SessionRow / Alpaca / SnapshotStore stack.
"""
from __future__ import annotations

import datetime as dt
from unittest.mock import AsyncMock, MagicMock

import pytest

from apps.paper_trader.engine import (
    PaperTradingEngine,
    _FRED_FALLBACK_MAX_AGE_SEC,
)


def _bare_engine() -> PaperTradingEngine:
    """Build a minimally initialized engine for helper-only tests.

    The cache and method under test do not depend on any other instance
    state; bypassing __init__ keeps these tests fast and decoupled from
    config/snapshot/manifest plumbing.
    """
    eng = object.__new__(PaperTradingEngine)
    eng._macro_fred_cache = {}
    return eng


def _fred_obs(value: float | None, date: str = "2026-05-08") -> MagicMock:
    obs = MagicMock()
    obs.value = value
    obs.date = date
    return obs


def _fred_resp(observations: list[MagicMock]) -> MagicMock:
    resp = MagicMock()
    resp.observations = observations
    return resp


@pytest.mark.asyncio
async def test_vix_fetch_success_uses_value():
    """Tier 1: a healthy fetch returns the latest non-null value and caches it."""
    eng = _bare_engine()
    fred_svc = MagicMock()
    fred_svc.get_observations = AsyncMock(
        return_value=_fred_resp([_fred_obs(15.5), _fred_obs(17.08)]),
    )

    result = await eng._fetch_fred_series_with_fallback(
        fred_svc, "VIXCLS", dt.date(2026, 5, 1), dt.date(2026, 5, 8),
    )

    assert result == 17.08
    assert "VIXCLS" in eng._macro_fred_cache
    cached_value, cached_at = eng._macro_fred_cache["VIXCLS"]
    assert cached_value == 17.08
    assert cached_at.tzinfo is not None  # tz-aware UTC


@pytest.mark.asyncio
async def test_vix_fetch_500_uses_last_known_within_24h():
    """Tier 2: with a recent cached value, a 5xx falls back to it (warning)."""
    eng = _bare_engine()
    # Pre-seed cache with a 6h-old value
    six_hours_ago = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=6)
    eng._macro_fred_cache["VIXCLS"] = (16.42, six_hours_ago)

    fred_svc = MagicMock()
    fred_svc.get_observations = AsyncMock(
        side_effect=Exception("Server error 500: /api/v1/fred/proxy/series/observations"),
    )

    result = await eng._fetch_fred_series_with_fallback(
        fred_svc, "VIXCLS", dt.date(2026, 5, 1), dt.date(2026, 5, 8),
    )

    # Returns the cached value rather than None — strategy keeps trading.
    assert result == 16.42
    # Cache itself is not refreshed by a fallback (only success refreshes).
    cached_value, cached_at = eng._macro_fred_cache["VIXCLS"]
    assert cached_value == 16.42
    assert cached_at == six_hours_ago


@pytest.mark.asyncio
async def test_vix_fetch_stale_more_than_24h_blocks_trades():
    """Tier 3a: a cached value older than 24h is treated as no cache -> None."""
    eng = _bare_engine()
    # Cache age > _FRED_FALLBACK_MAX_AGE_SEC
    too_old = dt.datetime.now(dt.timezone.utc) - dt.timedelta(
        seconds=_FRED_FALLBACK_MAX_AGE_SEC + 60,
    )
    eng._macro_fred_cache["VIXCLS"] = (12.0, too_old)

    fred_svc = MagicMock()
    fred_svc.get_observations = AsyncMock(
        side_effect=Exception("Server error 500"),
    )

    result = await eng._fetch_fred_series_with_fallback(
        fred_svc, "VIXCLS", dt.date(2026, 5, 1), dt.date(2026, 5, 8),
    )

    # None -> selector treats macro_vix as missing -> any engine with
    # macro_vix_max set vetoes the candidate. STRICT-CONSERVATIVE.
    assert result is None


@pytest.mark.asyncio
async def test_vix_fetch_no_cache_and_failure_blocks_trades():
    """Tier 3b: failure with empty cache returns None — never silently allows."""
    eng = _bare_engine()
    assert eng._macro_fred_cache == {}

    fred_svc = MagicMock()
    fred_svc.get_observations = AsyncMock(
        side_effect=Exception("Server error 500"),
    )

    result = await eng._fetch_fred_series_with_fallback(
        fred_svc, "VIXCLS", dt.date(2026, 5, 1), dt.date(2026, 5, 8),
    )

    assert result is None
    # Failed fetch must NOT poison the cache with bogus values.
    assert "VIXCLS" not in eng._macro_fred_cache


@pytest.mark.asyncio
async def test_vix_fetch_returns_empty_observations_does_not_silently_allow_trades():
    """200 OK with no usable observations is treated as outage, not as VIX=0.

    Without this, an upstream regression returning an empty list could
    flip the gate from "missing -> veto" to "0.0 -> always pass".
    """
    eng = _bare_engine()
    fred_svc = MagicMock()
    # Observations exist but all values are None ('.' in raw FRED == missing).
    fred_svc.get_observations = AsyncMock(
        return_value=_fred_resp([_fred_obs(None), _fred_obs(None)]),
    )

    result = await eng._fetch_fred_series_with_fallback(
        fred_svc, "VIXCLS", dt.date(2026, 5, 1), dt.date(2026, 5, 8),
    )

    assert result is None
    assert "VIXCLS" not in eng._macro_fred_cache


@pytest.mark.asyncio
async def test_vix_fetch_success_after_failure_refreshes_cache():
    """A healthy fetch after a fallback overwrites the stale cache entry."""
    eng = _bare_engine()
    older = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=12)
    eng._macro_fred_cache["VIXCLS"] = (16.42, older)

    fred_svc = MagicMock()
    fred_svc.get_observations = AsyncMock(
        return_value=_fred_resp([_fred_obs(17.08)]),
    )

    result = await eng._fetch_fred_series_with_fallback(
        fred_svc, "VIXCLS", dt.date(2026, 5, 1), dt.date(2026, 5, 8),
    )

    assert result == 17.08
    cached_value, cached_at = eng._macro_fred_cache["VIXCLS"]
    assert cached_value == 17.08
    # Cache timestamp moved forward.
    assert cached_at > older
