"""
test_persistence_gap_bugfix.py

Regression test for the "stale persistence survives an evaluation gap"
bug found in production logs on 2026-09-14. fast_entry_gate.py's
persistence_state only ever stored {"since": timestamp}, and every call
just computed `now - since` with no check that the symbol had actually
been re-evaluated on every poll in between. Three real, distinct causes
of a gap all produced the same result -- a BUY firing off a stale "since"
with zero continuous re-confirmation across the gap:

  - ALLT: direction flipped UP at 09:49:25, got benched (5 consecutive
    non-confirms -> 180s off the shortlist) at 09:49:35, then bought
    instantly at 09:53:21 on the very first re-check (held=236.2s).
  - JBS: direction flipped UP at 09:50:08, health dropped to STALE at
    09:50:16 (dropping it out of the health-eligible pool entirely),
    then bought instantly at 09:53:28 once health recovered
    (held=199.5s).
  - FUBO: carried a since-timestamp across a 2h26m (8804.6s) health-STALE
    gap and bought the instant health recovered, then repeated a
    rapid-refire pattern (bought/exited/re-bought within seconds,
    since being open/closing also excludes a symbol from re-evaluation
    without clearing its persistence timer) -- same root cause as BTE's
    5 back-to-back buy/momentum_fade_exit cycles in under a minute.

Fix: persistence_state also tracks "last_seen" (the as_of of the last
call that reached the UP-tracking branch). If the gap between "now" and
"last_seen" exceeds max_evaluation_gap_seconds (config default 15s --
enough to tolerate one or two missed ~5s poll ticks, not a 180s bench or
a multi-minute/hour health gap), the old "since" is discarded as stale
and persistence restarts from zero.

Run:
    python -m pytest tests/test_persistence_gap_bugfix.py -v
or:
    python tests/test_persistence_gap_bugfix.py
"""

import sys
import os
import unittest
from datetime import datetime, timedelta, timezone

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from fast_entry_gate import evaluate_fast_entry, STATE_BUY, STATE_WAIT  # noqa: E402
from fast_prediction_engine import FastPredictionReading, DIRECTION_UP  # noqa: E402
from stream_features import StreamFeatures  # noqa: E402


def _up_prediction():
    return FastPredictionReading(
        symbol="TEST", direction=DIRECTION_UP, confidence=80.0,
        momentum="Strong", volume_state="Steady", vwap_state="Bullish",
        ema9_state="Bullish", resistance="SAFE", extension="NORMAL",
        reasons=["momentum=Strong"],
    )


def _clean_features(as_of):
    return StreamFeatures(
        symbol="TEST", computed_at=as_of,
        price={"last": 10.0},
        acceleration={"momentum_acceleration": 0.5},
        volume={"relative_volume": 2.0},
        vwap={"value": 9.5, "slope": 0.1},
        quote={"spread_pct": 0.1},
    )


class TestPersistenceGapBugfix(unittest.TestCase):
    def test_continuous_evaluation_still_buys_after_persistence_window(self):
        """Sanity check: normal back-to-back ~5s polls with no gap should
        still accumulate persistence and fire BUY once >= 18s, unaffected
        by the gap-detection fix."""
        prediction = _up_prediction()
        t0 = datetime(2026, 9, 14, 9, 49, 25, tzinfo=timezone.utc)
        state = {}
        decision = None
        for i in range(5):  # 0s, 5s, 10s, 15s, 20s
            now = t0 + timedelta(seconds=5 * i)
            decision = evaluate_fast_entry("TEST", prediction, _clean_features(now), state, as_of=now)
            state = decision.persistence
        self.assertEqual(decision.state, STATE_BUY)
        self.assertTrue(decision.should_enter)

    def test_gap_from_benching_resets_persistence(self):
        """Reproduces the ALLT case: UP flips, then a long gap (simulating
        a 180s bench) where the symbol isn't evaluated at all, then it
        comes back still UP. Before the fix this fired BUY instantly off
        the stale "since". After the fix it must restart from zero."""
        prediction = _up_prediction()
        t_up_start = datetime(2026, 9, 14, 9, 49, 25, tzinfo=timezone.utc)
        state = {}
        decision = evaluate_fast_entry("TEST", prediction, _clean_features(t_up_start), state, as_of=t_up_start)
        state = decision.persistence
        self.assertEqual(decision.state, STATE_WAIT)

        # Simulate a 180s bench: no calls happen in between (the gap
        # itself, not a series of intervening ticks) -- then the symbol
        # comes off the bench and is re-evaluated, still UP, 236s after
        # the original flip (matching the real ALLT log line exactly).
        t_after_bench = t_up_start + timedelta(seconds=236.2)
        decision = evaluate_fast_entry("TEST", prediction, _clean_features(t_after_bench), state, as_of=t_after_bench)

        self.assertEqual(decision.state, STATE_WAIT,
                          "a since-timestamp that survived a long unevaluated gap must not "
                          "instantly satisfy the persistence window")
        self.assertLess(decision.persistence_seconds_elapsed, 1.0,
                         "persistence must restart from ~0s, not carry over the 236s gap")

    def test_short_gap_within_tolerance_does_not_reset(self):
        """A single missed poll tick (e.g. a slow cycle) shouldn't punish
        a genuinely continuous setup -- only gaps beyond
        max_evaluation_gap_seconds (15s default) should reset."""
        prediction = _up_prediction()
        t0 = datetime(2026, 9, 14, 9, 49, 25, tzinfo=timezone.utc)
        state = {}
        decision = evaluate_fast_entry("TEST", prediction, _clean_features(t0), state, as_of=t0)
        state = decision.persistence

        t1 = t0 + timedelta(seconds=10)  # within the 15s tolerance
        decision = evaluate_fast_entry("TEST", prediction, _clean_features(t1), state, as_of=t1)

        self.assertAlmostEqual(decision.persistence_seconds_elapsed, 10.0, delta=0.01)


if __name__ == "__main__":
    unittest.main()
