"""
Tests for the GNews guardrail (catastrophic fuse).

Post-mortem NVIDIA 631181: one uncorroborated signal at high weight flipped a
0.845 market to 0.431.  With Manifold observational-only and macro signals
gated behind is_non_price, GNews is the only live signal able to move politics
markets 20-30 pp against the order-book consensus.  The fuse clamps the
posterior to prior ± MAX_NEWS_ONLY_PROB_SHIFT when GNews is the ONLY material
signal (|log-odds| >= NEWS_MATERIAL_LOGODDS_THRESHOLD); any other material
signal counts as corroboration and disables the clamp.

Politics markets have no macro adjustments, so full-path tests exercise the
"GNews only" branch naturally; the corroboration branch is tested through the
pure helper apply_news_guardrail().

evaluate() emits a NEWS_MATERIAL log line for every market whose news
contribution is material (trade or skip); tests parse it via caplog.
"""
import asyncio
import logging
import math
import re

import pytest

import bot.strategy.bayesian as bayesian
from bot.data.external import ExternalSignals
from bot.data.polymarket import Market
from bot.strategy.bayesian import (
    NEWS_LOGODDS_WEIGHT,
    BayesianStrategy,
    apply_news_guardrail,
)

NEWS_MATERIAL_RE = re.compile(
    r"NEWS_MATERIAL.*raw=(\d+\.\d+) \| final=(\d+\.\d+).*"
    r"guardrail=(applied|none) \| changed_decision=(true|false)"
)


def _logodds(p: float) -> float:
    return math.log(p / (1 - p))


def _sentiment_for(prior: float, target_raw: float) -> float:
    """Sentiment that moves `prior` to exactly `target_raw` via GNews alone."""
    return (_logodds(target_raw) - _logodds(prior)) / NEWS_LOGODDS_WEIGHT


class FakeNews:
    """Deterministic NewsClient stub returning a fixed sentiment."""

    enabled = True

    def __init__(self, sentiment: float) -> None:
        self._sentiment = sentiment

    async def get_sentiment(self, question: str) -> float:
        return self._sentiment

    def get_freshness(self, question: str) -> float:
        return 1.0


def _make_market(yes_price: float) -> Market:
    return Market(
        id="mkt-guardrail-1",
        condition_id="cond-guardrail-1",
        question="Will John Smith win the election?",
        yes_token_id="yes-tok",
        no_token_id="no-tok",
        yes_price=yes_price,
        no_price=1.0 - yes_price,
        volume_24h=50_000.0,
        end_date="2026-07-15T00:00:00Z",  # politics <30 d → regime_min 0.08
        active=True,
        category="politics",
    )


def _make_signals() -> ExternalSignals:
    # Neutral macro environment; irrelevant for politics (gated) but explicit.
    return ExternalSignals(
        btc_price=100_000.0,
        btc_change_24h=0.0,
        eth_price=4_000.0,
        eth_change_24h=0.0,
        btc_dominance=50.0,
        fear_greed_index=50,
        fear_greed_label="neutral",
        total_market_cap_change=0.0,
        valid=True,
    )


def _evaluate(yes_price: float, sentiment: float, caplog) -> tuple[
    BayesianStrategy, tuple[float, float, str, str]
]:
    """Run evaluate() on a politics market and parse the NEWS_MATERIAL line."""
    strategy = BayesianStrategy(news=FakeNews(sentiment), manifold=None, db=None)
    market = _make_market(yes_price)
    with caplog.at_level(logging.INFO, logger="bot.strategy.bayesian"):
        asyncio.run(strategy.evaluate(market, _make_signals(), occupied_families=set()))
    for record in caplog.records:
        m = NEWS_MATERIAL_RE.search(record.getMessage())
        if m:
            return strategy, (
                float(m.group(1)), float(m.group(2)), m.group(3), m.group(4)
            )
    pytest.fail(
        "No NEWS_MATERIAL log line found; got: "
        f"{[r.getMessage() for r in caplog.records]}"
    )


# ─────────────────────────────────────────────────────────────────────────────
# Test 1 — extreme uncorroborated shift: clamp to prior - MAX_NEWS_ONLY_PROB_SHIFT
# ─────────────────────────────────────────────────────────────────────────────

def test_extreme_news_only_shift_is_clamped(caplog):
    """prior=0.845, raw 0.431 (NVIDIA signature) → final clamped to 0.595."""
    strategy, (raw, final, guardrail, _) = _evaluate(
        yes_price=0.845, sentiment=_sentiment_for(0.845, 0.431), caplog=caplog
    )
    assert raw == pytest.approx(0.431, abs=1e-3)
    assert guardrail == "applied"
    assert final >= 0.595
    assert final == pytest.approx(0.845 - bayesian.MAX_NEWS_ONLY_PROB_SHIFT, abs=1e-3)
    assert strategy.get_cycle_stats()["news_guardrail_applied"] == 1
    assert strategy.get_cycle_stats()["news_with_material"] == 1


# ─────────────────────────────────────────────────────────────────────────────
# Test 2 — moderate shift inside the band: passes through untouched
# ─────────────────────────────────────────────────────────────────────────────

def test_moderate_news_shift_inside_band_not_clamped(caplog):
    """prior=0.50, raw 0.62 → within ±0.25 band → final=0.62, no clamp."""
    strategy, (raw, final, guardrail, _) = _evaluate(
        yes_price=0.50, sentiment=_sentiment_for(0.50, 0.62), caplog=caplog
    )
    assert raw == pytest.approx(0.62, abs=1e-3)
    assert final == pytest.approx(0.62, abs=1e-3)
    assert guardrail == "none"
    assert strategy.get_cycle_stats()["news_guardrail_applied"] == 0
    # Still counted as a material-news market for the NEWS SUMMARY.
    assert strategy.get_cycle_stats()["news_with_material"] == 1


# ─────────────────────────────────────────────────────────────────────────────
# Test 3 — corroboration: any other material signal disables the fuse
# ─────────────────────────────────────────────────────────────────────────────

def test_corroborated_news_not_clamped():
    """GNews material + another signal >= threshold → raw passes without clamp."""
    news_lo = _logodds(0.20) - _logodds(0.50)  # ≈ -1.386, clearly material
    final, applied = apply_news_guardrail(
        prior=0.50,
        raw_final_prob=0.20,
        feat_news_lo=news_lo,
        other_feats_lo=(0.0, 0.15, 0.0, 0.0),  # one corroborating signal
    )
    assert final == 0.20
    assert applied is False


def test_corroboration_threshold_is_inclusive():
    """|other| == threshold exactly counts as corroboration (>=, not >)."""
    final, applied = apply_news_guardrail(
        prior=0.50,
        raw_final_prob=0.20,
        feat_news_lo=-1.386,
        other_feats_lo=(bayesian.NEWS_MATERIAL_LOGODDS_THRESHOLD, 0.0, 0.0, 0.0),
    )
    assert final == 0.20
    assert applied is False


def test_uncorroborated_helper_clamps():
    """Same shift with only noise elsewhere → clamped to prior - 0.25."""
    final, applied = apply_news_guardrail(
        prior=0.50,
        raw_final_prob=0.20,
        feat_news_lo=-1.386,
        other_feats_lo=(0.05, -0.09, 0.0, 0.0),  # all below threshold → noise
    )
    assert final == pytest.approx(0.25)
    assert applied is True


def test_sub_material_news_never_clamped():
    """|news_lo| below threshold → fuse not armed, whatever the shift."""
    final, applied = apply_news_guardrail(
        prior=0.50,
        raw_final_prob=0.10,
        feat_news_lo=0.09,
        other_feats_lo=(0.0, 0.0, 0.0, 0.0),
    )
    assert final == 0.10
    assert applied is False


def test_guardrail_disabled_passthrough(monkeypatch):
    monkeypatch.setattr(bayesian, "NEWS_GUARDRAIL_ENABLED", False)
    final, applied = apply_news_guardrail(
        prior=0.845,
        raw_final_prob=0.431,
        feat_news_lo=-1.974,
        other_feats_lo=(0.0, 0.0, 0.0, 0.0),
    )
    assert final == 0.431
    assert applied is False


# ─────────────────────────────────────────────────────────────────────────────
# Test 4 — changed_decision: the clamp moves the edge from tradeable to not
# ─────────────────────────────────────────────────────────────────────────────

def test_guardrail_changed_trade_decision(monkeypatch, caplog):
    """
    With max_shift=0.10 the clamped edge (0.10 gross, 0.06 net) falls below the
    politics <30 d regime gate (0.08) while the raw edge (0.414 gross, 0.374
    net) crossed it → the fuse prevented the trade → changed_decision=true.

    (With the default 0.25 the clamped edge_net is 0.21, above every regime
    minimum, so the flag can only fire with a tighter configured band.)
    """
    monkeypatch.setattr(bayesian, "MAX_NEWS_ONLY_PROB_SHIFT", 0.10)
    strategy, (raw, final, guardrail, changed) = _evaluate(
        yes_price=0.845, sentiment=_sentiment_for(0.845, 0.431), caplog=caplog
    )
    assert raw == pytest.approx(0.431, abs=1e-3)
    assert final == pytest.approx(0.745, abs=1e-3)
    assert guardrail == "applied"
    assert changed == "true"
    stats = strategy.get_cycle_stats()
    assert stats["news_changed_decisions"] == 1
    assert stats["news_guardrail_applied"] == 1


def test_default_band_does_not_change_decision(caplog):
    """Default 0.25 band: clamp binds but edge_net 0.21 still crosses the gate."""
    _, (_, _, guardrail, changed) = _evaluate(
        yes_price=0.845, sentiment=_sentiment_for(0.845, 0.431), caplog=caplog
    )
    assert guardrail == "applied"
    assert changed == "false"
