"""Tests for MacroRegime (WU4 — Macro Regime Overlay).

All HTTP calls are mocked via pytest-httpx or unittest.mock.patch so no
real network traffic is made.

Test spec coverage:
  - evaluate() returns PAUSE when Fear & Greed mock returns {"value": "20"}
  - evaluate() returns HALT_SESSION when btc_vol_24h_pct mock returns 6.5
  - evaluate() returns REDUCED when eth_btc_5d_slope is computed negative
  - evaluate() returns NORMAL (not ADVISORY) when only btc_dominance > 60
  - score_threshold_override() returns 0.75 in REDUCED, None in NORMAL
  - is_halted() returns True for both HALT_SESSION and PAUSE
  - On httpx failure, current_state() returns the previously set state (not raise, not reset to NORMAL)
  - regime_heartbeat exits promptly when shutdown_event is set
  - _derive_state: highest severity wins when multiple conditions are true
  - _linear_regression_slope: returns negative slope for a strictly decreasing series
"""

from __future__ import annotations

import asyncio
from unittest.mock import MagicMock, patch

import httpx
import pytest

from src.micro_scanner.regime import (
    MacroRegime,
    RegimeState,
    _linear_regression_slope,
    regime_heartbeat,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _mock_response(data: dict | list, status_code: int = 200) -> MagicMock:
    """Return a mock httpx.Response with .json() and .raise_for_status()."""
    resp = MagicMock(spec=httpx.Response)
    resp.status_code = status_code
    resp.json.return_value = data
    if status_code >= 400:
        resp.raise_for_status.side_effect = httpx.HTTPStatusError(
            message=f"HTTP {status_code}",
            request=MagicMock(),
            response=resp,
        )
    else:
        resp.raise_for_status.return_value = None
    return resp


def _fng_payload(value: int) -> dict:
    return {"data": [{"value": str(value), "value_classification": "Extreme Fear"}]}


def _coingecko_price_payload(btc_24h_change: float) -> dict:
    return {
        "bitcoin": {
            "usd": 65000.0,
            "usd_24h_change": btc_24h_change,
            "usd_24h_vol": 40_000_000_000.0,
        },
        "ethereum": {
            "usd": 3500.0,
            "usd_24h_change": 1.5,
            "usd_24h_vol": 15_000_000_000.0,
        },
    }


def _global_payload(btc_dominance: float) -> dict:
    return {
        "data": {
            "market_cap_percentage": {
                "btc": btc_dominance,
                "eth": 17.5,
            }
        }
    }


def _eth_btc_history_payload(prices: list[float]) -> dict:
    """Fake /market_chart payload with synthetic timestamps."""
    ts_base = 1_700_000_000_000
    return {"prices": [[ts_base + i * 86_400_000, p] for i, p in enumerate(prices)]}


# ---------------------------------------------------------------------------
# Patch helper: patch httpx.AsyncClient so all get() calls are controllable
# ---------------------------------------------------------------------------


class _MockAsyncClient:
    """Context-manager that returns pre-registered responses by URL substring."""

    def __init__(self, routes: dict[str, MagicMock]) -> None:
        self._routes = routes

    async def __aenter__(self) -> "_MockAsyncClient":
        return self

    async def __aexit__(self, *args: object) -> None:
        pass

    async def get(self, url: str, **kwargs: object) -> MagicMock:
        for fragment, resp in self._routes.items():
            if fragment in url:
                return resp
        raise ValueError(f"Unexpected URL in test: {url}")


def _build_client(
    fng_value: int = 50,
    btc_change: float = 1.0,
    btc_dom: float = 45.0,
    eth_btc_prices: list[float] | None = None,
    fng_error: Exception | None = None,
    btc_vol_error: Exception | None = None,
    btc_dom_error: Exception | None = None,
    eth_btc_error: Exception | None = None,
) -> _MockAsyncClient:
    if eth_btc_prices is None:
        eth_btc_prices = [0.055, 0.056, 0.057, 0.058, 0.059]  # rising (positive slope)

    routes: dict[str, MagicMock] = {}

    if fng_error:
        fng_resp = MagicMock(spec=httpx.Response)
        fng_resp.raise_for_status.side_effect = fng_error
        fng_resp.json.side_effect = fng_error
    else:
        fng_resp = _mock_response(_fng_payload(fng_value))
    routes["alternative.me"] = fng_resp

    if btc_vol_error:
        price_resp = MagicMock(spec=httpx.Response)
        price_resp.raise_for_status.side_effect = btc_vol_error
        price_resp.json.side_effect = btc_vol_error
    else:
        price_resp = _mock_response(_coingecko_price_payload(btc_change))
    routes["simple/price"] = price_resp

    if btc_dom_error:
        global_resp = MagicMock(spec=httpx.Response)
        global_resp.raise_for_status.side_effect = btc_dom_error
        global_resp.json.side_effect = btc_dom_error
    else:
        global_resp = _mock_response(_global_payload(btc_dom))
    routes["global"] = global_resp

    if eth_btc_error:
        eth_resp = MagicMock(spec=httpx.Response)
        eth_resp.raise_for_status.side_effect = eth_btc_error
        eth_resp.json.side_effect = eth_btc_error
    else:
        eth_resp = _mock_response(_eth_btc_history_payload(eth_btc_prices))
    routes["market_chart"] = eth_resp

    return _MockAsyncClient(routes)


# ---------------------------------------------------------------------------
# Unit tests: _linear_regression_slope
# ---------------------------------------------------------------------------


class TestLinearRegressionSlope:
    def test_decreasing_series_returns_negative_slope(self) -> None:
        values = [10.0, 9.0, 8.0, 7.0, 6.0]
        slope = _linear_regression_slope(values)
        assert slope is not None
        assert slope < 0.0

    def test_increasing_series_returns_positive_slope(self) -> None:
        values = [1.0, 2.0, 3.0, 4.0, 5.0]
        slope = _linear_regression_slope(values)
        assert slope is not None
        assert slope > 0.0

    def test_flat_series_returns_zero(self) -> None:
        values = [5.0, 5.0, 5.0, 5.0, 5.0]
        slope = _linear_regression_slope(values)
        assert slope == pytest.approx(0.0)

    def test_single_value_returns_none(self) -> None:
        assert _linear_regression_slope([3.14]) is None

    def test_empty_returns_none(self) -> None:
        assert _linear_regression_slope([]) is None

    def test_two_values_works(self) -> None:
        slope = _linear_regression_slope([1.0, 3.0])
        assert slope is not None
        assert slope > 0.0


# ---------------------------------------------------------------------------
# Unit tests: RegimeState enum ordering
# ---------------------------------------------------------------------------


class TestRegimeStateOrdering:
    def test_pause_is_highest_severity(self) -> None:
        assert RegimeState.PAUSE.value > RegimeState.HALT_SESSION.value
        assert RegimeState.HALT_SESSION.value > RegimeState.REDUCED.value
        assert RegimeState.REDUCED.value > RegimeState.ADVISORY.value
        assert RegimeState.ADVISORY.value > RegimeState.NORMAL.value

    def test_values_are_zero_to_four(self) -> None:
        assert RegimeState.NORMAL.value == 0
        assert RegimeState.ADVISORY.value == 1
        assert RegimeState.REDUCED.value == 2
        assert RegimeState.HALT_SESSION.value == 3
        assert RegimeState.PAUSE.value == 4


# ---------------------------------------------------------------------------
# Unit tests: Contract C synchronous methods
# ---------------------------------------------------------------------------


class TestContractCMethods:
    def test_initial_state_is_normal(self) -> None:
        regime = MacroRegime()
        assert regime.current_state() == RegimeState.NORMAL

    def test_score_threshold_override_normal_returns_none(self) -> None:
        regime = MacroRegime()
        assert regime.score_threshold_override() is None

    def test_score_threshold_override_advisory_returns_none(self) -> None:
        regime = MacroRegime()
        regime._state = RegimeState.ADVISORY
        assert regime.score_threshold_override() is None

    def test_score_threshold_override_reduced_returns_075(self) -> None:
        regime = MacroRegime()
        regime._state = RegimeState.REDUCED
        assert regime.score_threshold_override() == pytest.approx(0.75)

    def test_score_threshold_override_halt_session_returns_none(self) -> None:
        # HALT_SESSION doesn't change threshold — is_halted() blocks trading instead
        regime = MacroRegime()
        regime._state = RegimeState.HALT_SESSION
        assert regime.score_threshold_override() is None

    def test_is_halted_normal_false(self) -> None:
        regime = MacroRegime()
        assert regime.is_halted() is False

    def test_is_halted_advisory_false(self) -> None:
        regime = MacroRegime()
        regime._state = RegimeState.ADVISORY
        assert regime.is_halted() is False

    def test_is_halted_reduced_false(self) -> None:
        regime = MacroRegime()
        regime._state = RegimeState.REDUCED
        assert regime.is_halted() is False

    def test_is_halted_halt_session_true(self) -> None:
        regime = MacroRegime()
        regime._state = RegimeState.HALT_SESSION
        assert regime.is_halted() is True

    def test_is_halted_pause_true(self) -> None:
        regime = MacroRegime()
        regime._state = RegimeState.PAUSE
        assert regime.is_halted() is True


# ---------------------------------------------------------------------------
# Async tests: evaluate() — individual rule triggers
# ---------------------------------------------------------------------------


class TestEvaluate:
    @pytest.mark.asyncio
    async def test_pause_when_fear_greed_below_25(self) -> None:
        """Fear & Greed index of 20 → PAUSE."""
        regime = MacroRegime()
        mock_client = _build_client(fng_value=20, btc_change=1.0, btc_dom=45.0)
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            state = await regime.evaluate()
        assert state == RegimeState.PAUSE
        assert regime.current_state() == RegimeState.PAUSE

    @pytest.mark.asyncio
    async def test_halt_session_when_btc_vol_above_5pct(self) -> None:
        """BTC 24h change of 6.5% → HALT_SESSION."""
        regime = MacroRegime()
        mock_client = _build_client(fng_value=50, btc_change=6.5, btc_dom=45.0)
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            state = await regime.evaluate()
        assert state == RegimeState.HALT_SESSION

    @pytest.mark.asyncio
    async def test_halt_session_when_btc_vol_negative_large(self) -> None:
        """BTC 24h change of -7.0% (absolute > 5) → HALT_SESSION."""
        regime = MacroRegime()
        mock_client = _build_client(fng_value=50, btc_change=-7.0, btc_dom=45.0)
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            state = await regime.evaluate()
        assert state == RegimeState.HALT_SESSION

    @pytest.mark.asyncio
    async def test_reduced_when_eth_btc_slope_negative(self) -> None:
        """Decreasing ETH/BTC prices over 5 days → REDUCED."""
        regime = MacroRegime()
        # Strictly decreasing prices give a negative slope
        decreasing_prices = [0.059, 0.058, 0.057, 0.056, 0.055]
        mock_client = _build_client(
            fng_value=50,
            btc_change=1.0,
            btc_dom=45.0,
            eth_btc_prices=decreasing_prices,
        )
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            state = await regime.evaluate()
        assert state == RegimeState.REDUCED

    @pytest.mark.asyncio
    async def test_normal_when_only_btc_dominance_above_60(self) -> None:
        """BTC dominance > 60 triggers ADVISORY log only — evaluate returns NORMAL
        because ADVISORY is treated as a log-only signal, not a blocking state
        when it is the only condition.

        Per the plan: 'btc_dominance > 60 → ADVISORY (log only, does not affect threshold)'
        and 'evaluate() must return NORMAL (not ADVISORY) when only btc_dominance > 60'.
        """
        regime = MacroRegime()
        mock_client = _build_client(
            fng_value=50,
            btc_change=1.0,
            btc_dom=65.0,
            eth_btc_prices=[0.055, 0.056, 0.057, 0.058, 0.059],  # positive slope
        )
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            state = await regime.evaluate()
        # Test spec explicitly requires NORMAL here (advisory is log-only)
        assert state == RegimeState.NORMAL

    @pytest.mark.asyncio
    async def test_normal_when_all_conditions_benign(self) -> None:
        regime = MacroRegime()
        mock_client = _build_client(
            fng_value=60,
            btc_change=2.0,
            btc_dom=45.0,
            eth_btc_prices=[0.055, 0.056, 0.057, 0.058, 0.059],
        )
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            state = await regime.evaluate()
        assert state == RegimeState.NORMAL

    @pytest.mark.asyncio
    async def test_highest_severity_wins_when_multiple_conditions_true(self) -> None:
        """PAUSE + HALT_SESSION simultaneously → PAUSE (highest severity)."""
        regime = MacroRegime()
        mock_client = _build_client(
            fng_value=15,  # triggers PAUSE
            btc_change=8.0,  # triggers HALT_SESSION
            btc_dom=45.0,
        )
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            state = await regime.evaluate()
        assert state == RegimeState.PAUSE

    @pytest.mark.asyncio
    async def test_halt_session_over_reduced_when_both_true(self) -> None:
        """HALT_SESSION + REDUCED simultaneously → HALT_SESSION wins."""
        regime = MacroRegime()
        decreasing_prices = [0.059, 0.058, 0.057, 0.056, 0.055]
        mock_client = _build_client(
            fng_value=50,
            btc_change=7.0,  # triggers HALT_SESSION
            btc_dom=45.0,
            eth_btc_prices=decreasing_prices,  # triggers REDUCED
        )
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            state = await regime.evaluate()
        assert state == RegimeState.HALT_SESSION


# ---------------------------------------------------------------------------
# Async tests: evaluate() — HTTP failure preservation
# ---------------------------------------------------------------------------


class TestEvaluateHttpFailure:
    @pytest.mark.asyncio
    async def test_preserves_prior_state_on_total_failure(self) -> None:
        """When all HTTP calls fail, current_state() returns last known state, not NORMAL."""
        regime = MacroRegime()
        regime._state = RegimeState.REDUCED  # set a prior state

        error = httpx.ConnectError("connection refused")
        mock_client = _build_client(
            fng_error=error,
            btc_vol_error=error,
            btc_dom_error=error,
            eth_btc_error=error,
        )
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            # When all sources fail, all inputs are None.
            # _derive_state with all-None inputs returns NORMAL.
            # But the state persistence test spec says: "preserve last known state".
            # We implement this by only updating state when we have at least one
            # data point, or we always apply _derive_state and see if nothing fires.
            # Per the spec: "on failure, preserve last known state, log warning,
            # do NOT reset to NORMAL". When all inputs are None, no rule fires,
            # so _derive_state returns NORMAL — which contradicts "preserve".
            # Resolution: when all fetches return None we skip the state update.
            await regime.evaluate()

        assert regime.current_state() == RegimeState.REDUCED

    @pytest.mark.asyncio
    async def test_preserves_halt_session_on_fear_greed_failure(self) -> None:
        """Partial failure: only FnG fails. BTC vol still available."""
        regime = MacroRegime()
        regime._state = RegimeState.HALT_SESSION

        error = httpx.TimeoutException("timed out")
        # FnG fails, but btc_change=6.5 still triggers HALT_SESSION anyway
        mock_client = _build_client(
            fng_error=error,
            btc_change=6.5,
            btc_dom=45.0,
        )
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            state = await regime.evaluate()
        assert state == RegimeState.HALT_SESSION
        assert regime.current_state() == RegimeState.HALT_SESSION

    @pytest.mark.asyncio
    async def test_does_not_raise_on_timeout(self) -> None:
        """TimeoutException must not propagate; evaluate() must return gracefully."""
        regime = MacroRegime()
        error = httpx.TimeoutException("timeout")
        mock_client = _build_client(
            fng_error=error,
            btc_vol_error=error,
            btc_dom_error=error,
            eth_btc_error=error,
        )
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            result = await regime.evaluate()
        # Doesn't raise, returns a RegimeState
        assert isinstance(result, RegimeState)

    @pytest.mark.asyncio
    async def test_does_not_raise_on_connect_error(self) -> None:
        """ConnectError must not propagate."""
        regime = MacroRegime()
        error = httpx.ConnectError("refused")
        mock_client = _build_client(
            fng_error=error,
            btc_vol_error=error,
            btc_dom_error=error,
            eth_btc_error=error,
        )
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            result = await regime.evaluate()
        assert isinstance(result, RegimeState)


# ---------------------------------------------------------------------------
# Async tests: regime_heartbeat
# ---------------------------------------------------------------------------


class TestRegimeHeartbeat:
    @pytest.mark.asyncio
    async def test_exits_when_shutdown_event_set_before_first_sleep(self) -> None:
        """Heartbeat checks shutdown_event BEFORE sleeping — exits immediately."""
        regime = MacroRegime()
        shutdown = asyncio.Event()
        shutdown.set()  # already set before heartbeat starts

        # Heartbeat should return without calling evaluate at all
        evaluate_called = False

        async def mock_evaluate() -> RegimeState:
            nonlocal evaluate_called
            evaluate_called = True
            return RegimeState.NORMAL

        regime.evaluate = mock_evaluate  # type: ignore[method-assign]

        await asyncio.wait_for(regime_heartbeat(regime, shutdown), timeout=2.0)
        assert not evaluate_called

    @pytest.mark.asyncio
    async def test_calls_evaluate_once_then_exits_on_shutdown(self) -> None:
        """Heartbeat calls evaluate(), then detects shutdown during sleep."""
        regime = MacroRegime()
        shutdown = asyncio.Event()
        evaluate_count = 0

        async def mock_evaluate() -> RegimeState:
            nonlocal evaluate_count
            evaluate_count += 1
            # Set shutdown after first evaluate so the sleep loop detects it
            shutdown.set()
            return RegimeState.NORMAL

        regime.evaluate = mock_evaluate  # type: ignore[method-assign]

        await asyncio.wait_for(regime_heartbeat(regime, shutdown), timeout=5.0)
        assert evaluate_count == 1

    @pytest.mark.asyncio
    async def test_heartbeat_continues_after_evaluate_exception(self) -> None:
        """An exception from evaluate() must not kill the heartbeat loop."""
        regime = MacroRegime()
        shutdown = asyncio.Event()
        call_count = 0

        async def mock_evaluate() -> RegimeState:
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise RuntimeError("transient error")
            shutdown.set()
            return RegimeState.NORMAL

        regime.evaluate = mock_evaluate  # type: ignore[method-assign]

        await asyncio.wait_for(regime_heartbeat(regime, shutdown), timeout=5.0)
        assert call_count == 2


# ---------------------------------------------------------------------------
# Integration: evaluate() state is reflected in sync Contract C methods
# ---------------------------------------------------------------------------


class TestEvaluateUpdatesContractCState:
    @pytest.mark.asyncio
    async def test_evaluate_pause_is_reflected_in_is_halted(self) -> None:
        regime = MacroRegime()
        mock_client = _build_client(fng_value=10)  # PAUSE
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            await regime.evaluate()
        assert regime.is_halted() is True

    @pytest.mark.asyncio
    async def test_evaluate_reduced_is_reflected_in_score_threshold(self) -> None:
        regime = MacroRegime()
        decreasing_prices = [0.059, 0.058, 0.057, 0.056, 0.055]
        mock_client = _build_client(
            fng_value=50,
            btc_change=1.0,
            btc_dom=45.0,
            eth_btc_prices=decreasing_prices,
        )
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            await regime.evaluate()
        assert regime.score_threshold_override() == pytest.approx(0.75)

    @pytest.mark.asyncio
    async def test_evaluate_normal_score_threshold_is_none(self) -> None:
        regime = MacroRegime()
        mock_client = _build_client(fng_value=60, btc_change=1.0, btc_dom=45.0)
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            await regime.evaluate()
        assert regime.score_threshold_override() is None

    @pytest.mark.asyncio
    async def test_evaluate_halt_session_is_halted_true(self) -> None:
        regime = MacroRegime()
        mock_client = _build_client(fng_value=50, btc_change=8.0, btc_dom=45.0)
        with patch(
            "src.micro_scanner.regime.httpx.AsyncClient", return_value=mock_client
        ):
            await regime.evaluate()
        assert regime.is_halted() is True
