"""
Tests for the Replay R1 replay core (bot/replay.py) and the as_of clock
injection in BayesianStrategy.evaluate().

The central contract is round-trip fidelity: a decision recorded by R0 and
replayed through replay_cycle() with the same strategy constants must match
field-for-field (matched=True, mismatch_field=None).  Each round-trip test
produces the "archive" by running the real evaluate() with FakeNews, then
replays the drained record as if it had been read back from the signals table.
"""
import asyncio
from datetime import datetime, timedelta, timezone

import pytest

import bot.strategy.bayesian as bayesian
from bot.data.polymarket import Market, market_family_key
from bot.strategy.bayesian import BayesianStrategy, _days_to_resolution
from bot.replay import (
    ReplayNews,
    build_ext,
    build_market,
    replay_cycle,
    strategy_config_hash,
)

from tests.test_news_guardrail import FakeNews, _sentiment_for


def _end_date(days_ahead: int = 20) -> str:
    dt = datetime.now(timezone.utc) + timedelta(days=days_ahead)
    return dt.strftime("%Y-%m-%dT00:00:00Z")


def _make_market(
    yes_price: float,
    question: str = "Will John Smith win the election?",
    category: str = "politics",
    market_id: str = "mkt-replay-1",
    end_date: str = None,
) -> Market:
    return Market(
        id=market_id,
        condition_id="cond-replay-1",
        question=question,
        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=end_date if end_date is not None else _end_date(),
        active=True,
        category=category,
    )


def _snapshot(valid: bool = True) -> dict:
    """An ext_snapshots row as read back from the DB."""
    return {
        "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": valid,
    }


def _market_row(market: Market) -> dict:
    """A markets-table row for the given Market."""
    return {
        "id": market.id,
        "condition_id": market.condition_id,
        "question": market.question,
        "category": market.category,
        "end_date": market.end_date,
    }


def _record_with_live_evaluate(
    market: Market,
    news=None,
    families: set = frozenset(),
) -> dict:
    """Run the real evaluate() and return the R0 record it produced —
    the same dict save_signal_records() would have archived."""
    strategy = BayesianStrategy(news=news, manifold=None, db=None)
    asyncio.run(strategy.evaluate(market, build_ext(_snapshot()), set(families)))
    return strategy.drain_cycle_records()[0]


def _replay_one(record: dict, market: Market, snapshot: dict = None) -> dict:
    cycle_ts = datetime.now(timezone.utc)
    decisions = asyncio.run(replay_cycle(
        cycle_ts,
        snapshot or _snapshot(),
        [record],
        {market.id: _market_row(market)},
    ))
    assert len(decisions) == 1
    return decisions[0]


# ─────────────────────────────────────────────────────────────────────────────
# Clock injection
# ─────────────────────────────────────────────────────────────────────────────

def test_days_to_resolution_uses_injected_clock():
    end = "2026-08-01T00:00:00Z"
    as_of = datetime(2026, 7, 2, 12, 0, tzinfo=timezone.utc)
    assert _days_to_resolution(end, as_of) == 29
    assert _days_to_resolution(end, as_of - timedelta(days=60)) == 89


def test_default_clock_is_wall_clock():
    end = _end_date(days_ahead=40)
    assert _days_to_resolution(end) == _days_to_resolution(
        end, datetime.now(timezone.utc)
    )


def test_as_of_changes_regime_threshold():
    """Same politics market: <30 d out → regime 0.08; replayed from 60 d
    earlier → regime 0.12.  The clock, not the wall time, must decide."""
    market = _make_market(0.470)
    sentiment = _sentiment_for(0.470, 0.601)

    def _regime(as_of):
        strategy = BayesianStrategy(news=FakeNews(sentiment), manifold=None, db=None)
        asyncio.run(strategy.evaluate(
            market, build_ext(_snapshot()), set(), as_of=as_of,
        ))
        return strategy.drain_cycle_records()[0]["regime_min_edge"]

    now = datetime.now(timezone.utc)
    assert _regime(now) == pytest.approx(0.08)
    assert _regime(now - timedelta(days=60)) == pytest.approx(0.12)


# ─────────────────────────────────────────────────────────────────────────────
# Round-trip fidelity: record with live evaluate(), replay, expect match
# ─────────────────────────────────────────────────────────────────────────────

def test_roundtrip_confidence_skip():
    """Georgia signature: edge passes, confidence blocks — full-field match."""
    sentiment = _sentiment_for(0.470, 0.601)
    market = _make_market(0.470)
    record = _record_with_live_evaluate(market, news=FakeNews(sentiment))
    assert record["skip_reason"] == "confidence"

    decision = _replay_one(record, market)
    assert decision["matched"] is True
    assert decision["mismatch_field"] is None
    assert decision["skip_reason"] == "confidence"
    assert decision["estimated_prob"] == pytest.approx(record["estimated_prob"])
    assert decision["edge_net"] == pytest.approx(record["edge_net"])
    assert decision["confidence"] == pytest.approx(record["confidence"])
    assert decision["direction"] == record["direction"]
    assert decision["would_trade"] is False


def test_roundtrip_edge_net_skip():
    market = _make_market(0.50)
    record = _record_with_live_evaluate(market)
    assert record["skip_reason"] == "edge_net"

    decision = _replay_one(record, market)
    assert decision["matched"] is True
    assert decision["would_trade"] is False


def test_roundtrip_guardrail_clamp():
    """Clamped posterior must reproduce exactly (raw != final in archive)."""
    market = _make_market(0.845)
    record = _record_with_live_evaluate(
        market, news=FakeNews(_sentiment_for(0.845, 0.431))
    )
    assert record["guardrail_applied"] is True

    decision = _replay_one(record, market)
    assert decision["matched"] is True
    assert decision["raw_final_prob"] == pytest.approx(record["raw_final_prob"])
    assert decision["estimated_prob"] == pytest.approx(record["estimated_prob"])


def test_roundtrip_prior_extreme():
    market = _make_market(0.03)
    record = _record_with_live_evaluate(market)
    assert record["skip_reason"] == "prior_extreme"

    decision = _replay_one(record, market)
    assert decision["matched"] is True
    assert decision["skip_reason"] == "prior_extreme"


def test_roundtrip_family_skip():
    """Family-skipped rows replay with their own family injected as occupied."""
    market = _make_market(0.50)
    record = _record_with_live_evaluate(
        market, families={market_family_key(market)}
    )
    assert record["skip_reason"] == "family"

    decision = _replay_one(record, market)
    assert decision["matched"] is True
    assert decision["skip_reason"] == "family"


def test_roundtrip_unsupported():
    market = _make_market(0.50, question="Will it rain tomorrow?", category="")
    record = _record_with_live_evaluate(market)
    assert record["skip_reason"] == "unsupported"

    decision = _replay_one(record, market)
    assert decision["matched"] is True


def test_roundtrip_no_signals():
    """ext.valid=False archived → replay rebuilds the invalid snapshot."""
    market = _make_market(0.50)
    strategy = BayesianStrategy(news=None, manifold=None, db=None)
    asyncio.run(strategy.evaluate(market, build_ext(_snapshot(valid=False)), set()))
    record = strategy.drain_cycle_records()[0]
    assert record["skip_reason"] == "no_signals"

    decision = _replay_one(record, market, snapshot=_snapshot(valid=False))
    assert decision["matched"] is True


def test_roundtrip_trade_path(monkeypatch):
    """skip_reason=None (tradeable) round-trips with would_trade=True.
    Politics can't clear MIN_CONFIDENCE=0.55 (the known ceiling), so the
    gate is lowered for this test only — both record and replay see the
    same constant, which is exactly the config_hash contract."""
    monkeypatch.setattr(bayesian, "MIN_CONFIDENCE", 0.45)
    sentiment = _sentiment_for(0.470, 0.601)
    market = _make_market(0.470)
    record = _record_with_live_evaluate(market, news=FakeNews(sentiment))
    assert record["skip_reason"] is None

    decision = _replay_one(record, market)
    assert decision["matched"] is True
    assert decision["skip_reason"] is None
    assert decision["would_trade"] is True
    assert decision["direction"] == "BUY_YES"


# ─────────────────────────────────────────────────────────────────────────────
# Replay-specific semantics
# ─────────────────────────────────────────────────────────────────────────────

def test_budget_skipped_row_replays_without_news():
    """A budget-skipped archive row (sentiment 0.0) must replay to the same
    no-news decision — and never consume a replay-side budget."""
    market = _make_market(0.50)
    strategy = BayesianStrategy(news=FakeNews(0.9), manifold=None, db=None)
    strategy._news_queries_this_cycle = bayesian.MAX_NEWS_QUERIES_PER_CYCLE
    asyncio.run(strategy.evaluate(market, build_ext(_snapshot()), set()))
    record = strategy.drain_cycle_records()[0]
    assert record["news_budget_skipped"] is True
    assert record["news_sentiment"] == 0.0

    decision = _replay_one(record, market)
    assert decision["matched"] is True
    assert decision["estimated_prob"] == pytest.approx(record["estimated_prob"])


def test_reentry_guard_row_is_recalibrated_not_compared():
    """record_skip() rows carry no decision fields; the replay re-evaluates
    them (calibration data) but marks them non-comparable."""
    market = _make_market(0.50)
    strategy = BayesianStrategy(news=None, manifold=None, db=None)
    strategy.record_skip(market, "reentry_guard")
    record = strategy.drain_cycle_records()[0]

    decision = _replay_one(record, market)
    assert decision["matched"] is None
    assert decision["recorded_skip_reason"] == "reentry_guard"
    # Re-evaluated on its merits: a full decision despite the recorded skip
    assert decision["estimated_prob"] is not None
    assert decision["skip_reason"] == "edge_net"


def test_missing_market_row_flagged_not_crashed():
    market = _make_market(0.50)
    record = _record_with_live_evaluate(market)

    decisions = asyncio.run(replay_cycle(
        datetime.now(timezone.utc), _snapshot(), [record], {},
    ))
    assert decisions[0]["matched"] is False
    assert decisions[0]["mismatch_field"] == "market_missing"


def test_mismatch_detected_when_config_differs(monkeypatch):
    """Counterfactual sanity: replaying under a different guardrail band
    must produce matched=False with the diverging field named."""
    market = _make_market(0.845)
    record = _record_with_live_evaluate(
        market, news=FakeNews(_sentiment_for(0.845, 0.431))
    )
    assert record["guardrail_applied"] is True

    monkeypatch.setattr(bayesian, "MAX_NEWS_ONLY_PROB_SHIFT", 0.10)
    decision = _replay_one(record, market)
    assert decision["matched"] is False
    # Tighter clamp (prior 0.845 ± 0.10 → est 0.745): edge_net drops from
    # 0.21 to 0.06 < regime 0.08, so the skip flips confidence → edge_net
    # and skip_reason is the first field _compare() sees diverge.
    assert decision["mismatch_field"] == "skip_reason"
    assert decision["skip_reason"] == "edge_net"


def test_multi_row_cycle_preserves_order_and_isolation():
    """Rows replay independently within a cycle: a family skip and a full
    evaluation with different sentiments don't bleed into each other."""
    m1 = _make_market(0.470, market_id="m1")
    m2 = _make_market(
        0.50, market_id="m2",
        question="Will Jane Doe win the Georgia Senate race?",
    )
    r1 = _record_with_live_evaluate(m1, news=FakeNews(_sentiment_for(0.470, 0.601)))
    r2 = _record_with_live_evaluate(m2)  # no news → edge_net skip

    decisions = asyncio.run(replay_cycle(
        datetime.now(timezone.utc),
        _snapshot(),
        [r1, r2],
        {"m1": _market_row(m1), "m2": _market_row(m2)},
    ))
    assert [d["market_id"] for d in decisions] == ["m1", "m2"]
    assert all(d["matched"] is True for d in decisions)
    assert decisions[0]["skip_reason"] == "confidence"
    assert decisions[1]["skip_reason"] == "edge_net"


# ─────────────────────────────────────────────────────────────────────────────
# Run tagging
# ─────────────────────────────────────────────────────────────────────────────

def test_config_hash_stable_and_sensitive(monkeypatch):
    h1 = strategy_config_hash()
    assert strategy_config_hash() == h1
    monkeypatch.setattr(bayesian, "MAX_NEWS_ONLY_PROB_SHIFT", 0.10)
    assert strategy_config_hash() != h1


def test_replay_news_returns_current_sentiment():
    news = ReplayNews()
    assert asyncio.run(news.get_sentiment("q")) == 0.0
    news.sentiment = -0.42
    assert asyncio.run(news.get_sentiment("q")) == -0.42


def test_build_market_reconstruction():
    market = _make_market(0.37)
    record = _record_with_live_evaluate(market)
    rebuilt = build_market(_market_row(market), record)
    assert rebuilt.id == market.id
    assert rebuilt.yes_price == pytest.approx(0.37)
    assert rebuilt.volume_24h == pytest.approx(market.volume_24h)
    assert rebuilt.end_date == market.end_date
    assert rebuilt.category == "politics"
    assert market_family_key(rebuilt) == market_family_key(market)
