"""Unit tests for WU3 big-mover gates and indicators.

Covers the WU3 Test Spec:
  - gate_squeeze BB-inside-Keltner + vol dry-up behaviour.
  - gate_derivatives_additive (+0.15 cases; 0.0 when has_perps=False).
  - gate_tvl_additive (+0.10 above threshold; 0.0 below).
  - gate_depth_slope None-fallback to static ratio check.
  - calculate_linear_regression_slope sign on a strictly decreasing series.
  - Keltner / BB math on a known-value series.
  - evaluate_entry ath_proximity_risk 0.75x multiplier.
  - Additive bonuses cannot rescue a hard-gate failure.
  - record_depth_ratio / get_depth_slope behaviour.
"""

from __future__ import annotations

import os
import sys
import time
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock

import pytest

# Make sure the project src is importable when running from the repo root
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from src.micro_scanner import composite_observer as co
from src.micro_scanner.config import MicroConfig
from src.micro_scanner.contracts import CandidateScore
from src.micro_scanner.indicators import (
    calculate_bb_inside_keltner,
    calculate_keltner_channel,
    calculate_linear_regression_slope,
    calculate_vol_mc_ratio_spike,
)
from src.micro_scanner.signal_engine import (
    MicroSignalEngine,
    gate_depth_slope,
    gate_derivatives_additive,
    gate_squeeze,
    gate_tvl_additive,
    gate_volmc,
)
from src.micro_scanner.token_metadata import TokenMetadata


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


def _candle(
    open_: float,
    close: float,
    high: float | None = None,
    low: float | None = None,
    volume: float = 1000.0,
    ts: int = 0,
) -> dict:
    return {
        "timestamp": ts,
        "open": open_,
        "high": high if high is not None else max(open_, close) + 0.01,
        "low": low if low is not None else min(open_, close) - 0.01,
        "close": close,
        "volume": volume,
    }


def _flat_candles(
    count: int, price: float = 100.0, volume: float = 1000.0
) -> list[dict]:
    """Flat price series — BB collapses to ~zero width, KC has ATR-based width.

    BB fully inside KC holds trivially when prices are flat and ATR > 0.
    """
    out = []
    for i in range(count):
        # Tiny high/low excursions so ATR > 0 (otherwise KC width is also 0).
        out.append(
            _candle(
                open_=price,
                close=price,
                high=price + 0.5,
                low=price - 0.5,
                volume=volume,
                ts=i * 60_000,
            )
        )
    return out


def _make_score(score: float = 0.80, symbol: str = "BTCUSDT") -> CandidateScore:
    return CandidateScore(
        symbol=symbol,
        score=score,
        scored_at=datetime.now(timezone.utc) - timedelta(seconds=5),
        data_freshness={"_v": 1},
        is_valid=True,
        status="active",
    )


# ---------------------------------------------------------------------------
# calculate_linear_regression_slope
# ---------------------------------------------------------------------------


def test_linreg_slope_positive_for_increasing() -> None:
    """Strictly increasing series → positive slope."""
    values = [float(i) for i in range(30)]  # 0..29
    slope = calculate_linear_regression_slope(values, window=30)
    assert slope is not None
    # x=y so slope = 1.0
    assert slope == pytest.approx(1.0, abs=1e-9)


def test_linreg_slope_negative_for_decreasing() -> None:
    """Strictly decreasing series → negative slope (WU3 spec requirement)."""
    values = [float(30 - i) for i in range(30)]  # 30..1
    slope = calculate_linear_regression_slope(values, window=30)
    assert slope is not None
    assert slope < 0
    # slope = -1.0 exactly for y = -x + c
    assert slope == pytest.approx(-1.0, abs=1e-9)


def test_linreg_slope_zero_for_flat() -> None:
    values = [100.0] * 30
    slope = calculate_linear_regression_slope(values, window=30)
    assert slope is not None
    assert slope == pytest.approx(0.0, abs=1e-9)


def test_linreg_slope_none_when_insufficient() -> None:
    assert calculate_linear_regression_slope([1.0, 2.0], window=30) is None


def test_linreg_slope_known_values() -> None:
    """Verify against a hand-computed example.

    y = [2, 4, 5, 4, 5, 7, 8, 9]; least-squares slope over x=0..7.
    mean_x=3.5, mean_y=5.5. See manual calc below.
    """
    y = [2.0, 4.0, 5.0, 4.0, 5.0, 7.0, 8.0, 9.0]
    # By hand:
    # sum((x-mean_x)*(y-mean_y)) = (-3.5)(-3.5)+(-2.5)(-1.5)+(-1.5)(-0.5)+(-0.5)(-1.5)
    #   +(0.5)(-0.5)+(1.5)(1.5)+(2.5)(2.5)+(3.5)(3.5) = 12.25+3.75+0.75+0.75-0.25+2.25+6.25+12.25 = 38.0
    # sum((x-mean_x)^2) = 2*(0.5^2+1.5^2+2.5^2+3.5^2) = 2*(0.25+2.25+6.25+12.25) = 42.0
    # slope = 38/42 ≈ 0.90476
    slope = calculate_linear_regression_slope(y, window=8)
    assert slope is not None
    assert slope == pytest.approx(38.0 / 42.0, abs=1e-9)


# ---------------------------------------------------------------------------
# calculate_keltner_channel
# ---------------------------------------------------------------------------


def test_keltner_channel_flat_series() -> None:
    """Flat closes with wiggles → midline == price, width == ATR * 2."""
    candles = _flat_candles(25, price=100.0)
    kc = calculate_keltner_channel(candles, period=20, atr_multiplier=2.0)
    assert kc is not None
    upper, lower = kc
    # EMA of flat 100 is 100.
    # ATR for candles with high=100.5, low=99.5, close=100 → TR max(1, 0.5, 0.5) = 1.0.
    # ATR(20) = 1.0.
    assert upper == pytest.approx(100.0 + 2.0, abs=1e-6)
    assert lower == pytest.approx(100.0 - 2.0, abs=1e-6)


def test_keltner_channel_insufficient_data() -> None:
    candles = _flat_candles(10)
    assert calculate_keltner_channel(candles, period=20) is None


# ---------------------------------------------------------------------------
# calculate_bb_inside_keltner
# ---------------------------------------------------------------------------


def test_bb_inside_keltner_flat_market_is_inside() -> None:
    """Flat closes → BB collapses (std=0) → trivially inside KC."""
    # Need > period candles to get trailing count > 0.
    candles = _flat_candles(25, price=100.0)
    closes = [float(c["close"]) for c in candles]
    count = calculate_bb_inside_keltner(closes, candles, period=20)
    # After index 19 we have 6 trailing candles (20..24 inclusive at end=24..20)
    # Candles where BB fits inside KC: all 5 (end=24 down to end=20).
    assert count == 5


def test_bb_inside_keltner_high_volatility_not_inside() -> None:
    """Strong trend → BB std dominates, BB wider than KC → not inside."""
    # Strong ramp from 100 to 200 over 25 candles. Intra-bar range tiny so
    # ATR stays low, but close-to-close moves keep ATR moderate. BB std is
    # also high on the ramp, but the *ramp offsets* the BB and KC differently
    # because the EMA (KC midline) lags a strong trend more than the SMA
    # (BB midline) does — and SMA+-2std on a linear ramp grows quadratically
    # in the window while EMA+-2ATR stays bounded. Easier check: use a
    # non-monotone series that creates high std AND low ATR.
    # Strategy: 19 flat candles at 100 with tiny ATR, then one candle at 130.
    # std over 20 window now pops high (one outlier at 130 vs 19 at 100),
    # but ATR still reflects that jump. Use 19 flat + final flat so std
    # includes the earlier step-up impact from a pre-warmup jump.
    candles = []
    for _ in range(5):
        candles.append(_candle(open_=100.0, close=100.0, high=100.01, low=99.99))
    # Outlier mid-series to pump BB std without pumping recent ATR too much.
    candles.append(_candle(open_=100.0, close=130.0, high=130.1, low=100.0))
    candles.append(_candle(open_=130.0, close=100.0, high=130.0, low=99.9))
    # 18 more flat candles so BB over last 20 spans [close=130, 100, 100×18]
    # giving large std while recent ATR over period=20 averages down.
    for _ in range(18):
        candles.append(_candle(open_=100.0, close=100.0, high=100.01, low=99.99))
    closes = [float(c["close"]) for c in candles]
    count = calculate_bb_inside_keltner(closes, candles, period=20)
    # With one outlier in the window, BB std ≈ 6+, BB width ≈ 24.
    # ATR over period=20: one TR ≈ 30 (the jump), 19 TRs ≈ 0.02.
    # ATR ≈ 30/20 = 1.5. KC width = 2*1.5 = 3 above+below EMA ≈ [97, 103].
    # BB ≈ [97, 121] — bb_upper (~121) > kc_upper (~103) → NOT inside.
    assert count == 0


def test_bb_inside_keltner_insufficient_data() -> None:
    assert calculate_bb_inside_keltner([1.0] * 5, _flat_candles(5), period=20) == 0


# ---------------------------------------------------------------------------
# calculate_vol_mc_ratio_spike
# ---------------------------------------------------------------------------


def test_vol_mc_spike_triggers() -> None:
    # 2_000_000 / 10_000_000 = 0.20 > 0.15, prior 0.03 < 0.05
    assert calculate_vol_mc_ratio_spike(2_000_000.0, 10_000_000.0, 0.03) is True


def test_vol_mc_spike_no_prior_quiet() -> None:
    # Current ratio fine but prior average too high → not a spike.
    assert calculate_vol_mc_ratio_spike(2_000_000.0, 10_000_000.0, 0.10) is False


def test_vol_mc_spike_current_below_threshold() -> None:
    assert calculate_vol_mc_ratio_spike(1_000_000.0, 10_000_000.0, 0.03) is False


def test_vol_mc_spike_zero_market_cap() -> None:
    assert calculate_vol_mc_ratio_spike(1_000_000.0, 0.0, 0.03) is False


# ---------------------------------------------------------------------------
# gate_squeeze
# ---------------------------------------------------------------------------


def test_gate_squeeze_bb_inside_for_5_with_vol_dryup() -> None:
    """BB inside KC for 5 candles AND vol < 40% avg for 3 of them → True."""
    # First 20 candles: flat price with normal volume 1000.
    # Last 5 candles: same flat price, volumes 100 (10% of avg) for 3 of them.
    candles = _flat_candles(20, volume=1000.0)
    # Append 5 "quiet" candles where 3 have very low volume.
    low_vols = [100.0, 100.0, 100.0, 1000.0, 1000.0]
    for v in low_vols:
        candles.append(
            _candle(open_=100.0, close=100.0, high=100.5, low=99.5, volume=v)
        )
    closes = [float(c["close"]) for c in candles]
    assert (
        gate_squeeze(closes, candles, min_bb_inside_candles=4, vol_pct_threshold=0.40)
        is True
    )


def test_gate_squeeze_only_2_inside_fails() -> None:
    """BB inside KC for only 2 consecutive candles → False even with dry vol."""
    # Build: first 22 flat candles inside squeeze, then 3 candles with a big
    # price excursion to break BB-inside-KC for those tail 3, leaving only
    # the earlier ones "inside" but the most-recent-trailing-count = 0.
    # Simpler: start with wobbling prices to prevent squeeze then end with flat.
    candles = []
    # 23 wobbling candles: std high → BB wider than KC → not inside.
    for i in range(23):
        price = 100.0 if i % 2 == 0 else 110.0
        candles.append(
            _candle(open_=price, close=price, high=price + 0.01, low=price - 0.01)
        )
    # Now 2 flat candles at 100 — BB over last 20 is still wide (includes wobbles).
    for _ in range(2):
        candles.append(
            _candle(open_=100.0, close=100.0, high=100.01, low=99.99, volume=100.0)
        )
    closes = [float(c["close"]) for c in candles]
    # With the first 20-bar window still dominated by wobbles, BB remains
    # wider than KC → consecutive inside count stays low (< 4).
    assert gate_squeeze(closes, candles, min_bb_inside_candles=4) is False


def test_gate_squeeze_insufficient_candles() -> None:
    candles = _flat_candles(10)
    closes = [float(c["close"]) for c in candles]
    assert gate_squeeze(closes, candles) is False


def test_gate_squeeze_inside_but_volume_not_dry() -> None:
    """BB inside KC for >=4 candles but no vol dry-up → False."""
    # 25 flat candles, all volume 1000 — no vol dry-up.
    candles = _flat_candles(25, volume=1000.0)
    closes = [float(c["close"]) for c in candles]
    assert gate_squeeze(closes, candles, vol_pct_threshold=0.40) is False


# ---------------------------------------------------------------------------
# gate_volmc
# ---------------------------------------------------------------------------


def test_gate_volmc_passes() -> None:
    assert gate_volmc(2_000_000.0, 10_000_000.0, 0.03) is True


def test_gate_volmc_fails() -> None:
    assert gate_volmc(1_000_000.0, 10_000_000.0, 0.10) is False


# ---------------------------------------------------------------------------
# gate_depth_slope
# ---------------------------------------------------------------------------


def test_gate_depth_slope_passes_with_positive_slope_and_ratio() -> None:
    assert (
        gate_depth_slope(0.02, min_slope=0.01, min_ratio=1.3, current_ratio=1.5) is True
    )


def test_gate_depth_slope_fails_low_slope() -> None:
    assert (
        gate_depth_slope(0.001, min_slope=0.01, min_ratio=1.3, current_ratio=1.5)
        is False
    )


def test_gate_depth_slope_fails_low_ratio() -> None:
    assert (
        gate_depth_slope(0.05, min_slope=0.01, min_ratio=1.3, current_ratio=1.2)
        is False
    )


def test_gate_depth_slope_none_falls_back_to_static() -> None:
    """WU3 spec: if depth_slope is None, use static ratio check only."""
    assert (
        gate_depth_slope(None, min_slope=0.01, min_ratio=1.3, current_ratio=1.5) is True
    )
    assert (
        gate_depth_slope(None, min_slope=0.01, min_ratio=1.3, current_ratio=1.2)
        is False
    )


# ---------------------------------------------------------------------------
# gate_derivatives_additive (NOT a hard gate — returns float)
# ---------------------------------------------------------------------------


def test_gate_derivatives_returns_015_on_futures_spot_ratio() -> None:
    """WU3 spec: futures_vol/spot_vol = 4.0 → +0.15."""
    data = {
        "has_perps": True,
        "futures_volume": 400.0,
        "spot_volume": 100.0,
        "oi_pct_change_2h": 0.0,
        "funding_rate": 0.0,
        "price_change_1h": 0.0,
    }
    assert gate_derivatives_additive(data) == pytest.approx(0.15)


def test_gate_derivatives_returns_015_on_oi_plus_neutral_funding() -> None:
    data = {
        "has_perps": True,
        "futures_volume": 0.0,
        "spot_volume": 100.0,
        "oi_pct_change_2h": 20.0,
        "funding_rate": 0.0,
        "price_change_1h": 0.0,
    }
    assert gate_derivatives_additive(data) == pytest.approx(0.15)


def test_gate_derivatives_returns_015_on_negative_funding_with_price_up() -> None:
    data = {
        "has_perps": True,
        "futures_volume": 0.0,
        "spot_volume": 100.0,
        "oi_pct_change_2h": 0.0,
        "funding_rate": -0.001,
        "price_change_1h": 0.5,
    }
    assert gate_derivatives_additive(data) == pytest.approx(0.15)


def test_gate_derivatives_returns_zero_when_has_perps_false() -> None:
    """WU3 spec: has_perps=False → 0.0 regardless of other fields."""
    data = {
        "has_perps": False,
        "futures_volume": 1000.0,
        "spot_volume": 100.0,
        "oi_pct_change_2h": 50.0,
        "funding_rate": -0.01,
        "price_change_1h": 5.0,
    }
    assert gate_derivatives_additive(data) == 0.0


def test_gate_derivatives_returns_zero_on_none() -> None:
    assert gate_derivatives_additive(None) == 0.0


def test_gate_derivatives_no_condition_met() -> None:
    data = {
        "has_perps": True,
        "futures_volume": 100.0,
        "spot_volume": 100.0,  # ratio = 1.0
        "oi_pct_change_2h": 5.0,  # < 15
        "funding_rate": 0.0001,  # > -0.0005
        "price_change_1h": 0.0,
    }
    assert gate_derivatives_additive(data) == 0.0


# ---------------------------------------------------------------------------
# gate_tvl_additive
# ---------------------------------------------------------------------------


def test_gate_tvl_returns_010_above_threshold() -> None:
    """WU3 spec: tvl_change_7d_pct=35 → +0.10."""
    assert gate_tvl_additive({"tvl_change_7d_pct": 35.0}) == pytest.approx(0.10)


def test_gate_tvl_returns_zero_below_threshold() -> None:
    """WU3 spec: tvl_change_7d_pct=25 → 0.0."""
    assert gate_tvl_additive({"tvl_change_7d_pct": 25.0}) == 0.0


def test_gate_tvl_returns_zero_on_none() -> None:
    assert gate_tvl_additive(None) == 0.0


def test_gate_tvl_returns_zero_on_missing_field() -> None:
    assert gate_tvl_additive({}) == 0.0


# ---------------------------------------------------------------------------
# composite_observer depth history
# ---------------------------------------------------------------------------


def test_record_and_get_depth_slope_positive() -> None:
    # Clear any history for this symbol (tests run in shared module state).
    co._depth_ratio_history.pop("TESTUSDT", None)
    # Record rising ratios 1.0, 1.1, ..., 1.9
    for i in range(10):
        co.record_depth_ratio("TESTUSDT", bid_depth=10.0 + i, ask_depth=10.0)
    slope = co.get_depth_slope("TESTUSDT", window_minutes=60)
    assert slope is not None
    # Ratios are 1.0, 1.1, ..., 1.9 — slope ≈ 0.1 per step.
    assert slope == pytest.approx(0.1, abs=1e-6)


def test_get_depth_slope_none_on_missing() -> None:
    assert co.get_depth_slope("NEVERUSDT") is None


def test_record_depth_ratio_skips_invalid_ask() -> None:
    co._depth_ratio_history.pop("BADUSDT", None)
    co.record_depth_ratio("BADUSDT", bid_depth=10.0, ask_depth=0.0)
    co.record_depth_ratio("BADUSDT", bid_depth=10.0, ask_depth=-1.0)
    # No entries recorded at all → slope is None.
    assert co.get_depth_slope("BADUSDT") is None


def test_depth_history_is_deque_with_maxlen() -> None:
    """WU3 spec: _depth_ratio_history uses collections.deque(maxlen=30)."""
    co._depth_ratio_history.pop("MAXLENUSDT", None)
    for i in range(50):  # > 30
        co.record_depth_ratio("MAXLENUSDT", bid_depth=10.0, ask_depth=10.0)
    buf = co._depth_ratio_history["MAXLENUSDT"]
    assert buf.maxlen == 30
    assert len(buf) == 30


def test_get_depth_slope_trims_by_window_minutes() -> None:
    """Old samples outside the window must be ignored."""
    co._depth_ratio_history.pop("WINDOWUSDT", None)
    now = time.time()
    buf = co.deque(maxlen=30)
    # 3 ancient samples (2h old) then 3 recent.
    buf.append((now - 7200, 1.0))
    buf.append((now - 7200, 2.0))
    buf.append((now - 7200, 3.0))
    buf.append((now - 60, 1.0))
    buf.append((now - 30, 1.5))
    buf.append((now, 2.0))
    co._depth_ratio_history["WINDOWUSDT"] = buf
    # 30-minute window → only the last 3 samples count.
    slope = co.get_depth_slope("WINDOWUSDT", window_minutes=30)
    assert slope is not None
    # Ratios 1.0, 1.5, 2.0 → slope = 0.5 per step.
    assert slope == pytest.approx(0.5, abs=1e-6)


# ---------------------------------------------------------------------------
# evaluate_entry integration
# ---------------------------------------------------------------------------


def _make_engine(**overrides: object) -> MicroSignalEngine:
    cfg = MicroConfig(**overrides)  # type: ignore[arg-type]
    conn = MagicMock()
    return MicroSignalEngine(cfg, conn, mode="draft")


def _loose_candles(count: int = 25) -> list[dict]:
    """Candles that pass the baseline 6 gates (all green, volume surge, etc.)."""
    candles = []
    for i in range(count - 1):
        candles.append(
            _candle(open_=100.0 + i * 0.1, close=100.0 + i * 0.1 + 0.05, volume=1000.0)
        )
    # Final candle: green + volume surge (> 3x avg of prior 20).
    candles.append(
        _candle(
            open_=100.0 + (count - 1) * 0.1,
            close=100.0 + (count - 1) * 0.1 + 0.05,
            volume=10_000.0,
        )
    )
    return candles


def _loose_depth() -> dict:
    return {
        "bid": 100.0,
        "ask": 100.05,
        "bid_depth": 10.0,
        "ask_depth": 5.0,
        "high_24h": 200.0,  # well above current price
    }


def _squeeze_friendly_candles() -> list[dict]:
    """Build a 25-candle series that passes the 6 baseline gates AND the new
    gate_squeeze / gate_depth_slope hard gates.

    - Tiny close-to-close deltas → BB std is small.
    - Wide-ish high/low ranges → ATR is moderate (KC width > BB width).
    - Mixed +/- deltas → RSI(14) ~ 50 (well inside 40-70).
    - Final bar green → gate_trend passes at min_consecutive=1.
    - Last 3 bars have low volume → gate_squeeze vol-dryup condition hits.
    """
    rng: list[dict] = []
    price = 100.0
    deltas = [0.001, -0.001, 0.001, -0.001] * 6  # 24 entries
    deltas.append(0.002)  # final bar green
    for i, d in enumerate(deltas):
        new_price = price + d
        vol = 100.0 if i >= len(deltas) - 3 else 1000.0
        rng.append(
            _candle(
                open_=price,
                close=new_price,
                high=max(price, new_price) + 0.5,
                low=min(price, new_price) - 0.5,
                volume=vol,
            )
        )
        price = new_price
    return rng


def test_evaluate_entry_ath_proximity_risk_applies_075x() -> None:
    """WU3 spec: ath_proximity_risk=True → suggested_quantity_pct = base * 0.75."""
    engine = _make_engine(
        # Loosen all baseline gates so entry reaches the build step.
        min_consecutive_candles=1,
        volume_surge_threshold=0.0,
        spread_max_pct=5.0,
        rsi_entry_min=0.0,
        rsi_entry_max=100.0,
        resistance_threshold_pct=0.0,
        score_full_position=0.50,
        depth_gate_enabled=False,
    )
    depth = _loose_depth()
    score = _make_score(score=0.80)
    # token_meta with NO market_cap so volmc gate is skipped. Current depth
    # ratio bid_depth/ask_depth = 2.0 >= 1.3, slope=None → gate_depth_slope
    # falls back to static check and passes. gate_squeeze passes because the
    # series is tight-BB / wide-KC with vol dry-up on the tail.
    meta = TokenMetadata(
        symbol="BTCUSDT",
        ath_proximity_risk=True,
    )
    candles = _squeeze_friendly_candles()
    base_qty = engine._config.base_position_pct
    result = engine.evaluate_entry("BTCUSDT", candles, depth, score, token_meta=meta)
    assert result is not None, "expected a signal when all loose gates pass"
    assert result.suggested_quantity_pct == pytest.approx(base_qty * 0.75, abs=1e-9)


def test_evaluate_entry_no_ath_risk_keeps_base_qty() -> None:
    engine = _make_engine(
        min_consecutive_candles=1,
        volume_surge_threshold=0.0,
        spread_max_pct=5.0,
        rsi_entry_min=0.0,
        rsi_entry_max=100.0,
        resistance_threshold_pct=0.0,
        score_full_position=0.50,
        depth_gate_enabled=False,
    )
    rng = _squeeze_friendly_candles()
    depth = _loose_depth()
    score = _make_score(score=0.80)
    meta = TokenMetadata(symbol="BTCUSDT", ath_proximity_risk=False)
    base_qty = engine._config.base_position_pct
    result = engine.evaluate_entry("BTCUSDT", rng, depth, score, token_meta=meta)
    assert result is not None
    assert result.suggested_quantity_pct == pytest.approx(base_qty, abs=1e-9)


def test_evaluate_entry_additive_bonuses_do_not_rescue_hard_gate_fail() -> None:
    """WU3 spec: additive bonuses must not cause an entry that would fail the hard gates."""
    engine = _make_engine(
        # Loosen baseline 6 gates so they don't reject.
        min_consecutive_candles=1,
        volume_surge_threshold=0.0,
        spread_max_pct=5.0,
        rsi_entry_min=0.0,
        rsi_entry_max=100.0,
        resistance_threshold_pct=0.0,
        score_full_position=0.50,
        depth_gate_enabled=False,
    )
    # Fake coinglass client that ALWAYS returns the max bonus.
    coinglass = MagicMock()
    coinglass.get_derivatives.return_value = {
        "has_perps": True,
        "futures_volume": 1000.0,
        "spot_volume": 100.0,  # ratio 10 → bonus +0.15
        "oi_pct_change_2h": 0.0,
        "funding_rate": 0.0,
        "price_change_1h": 0.0,
    }
    defillama = MagicMock()
    defillama.get_tvl_data.return_value = {"tvl_change_7d_pct": 40.0}  # +0.10
    engine._coinglass_client = coinglass
    engine._defillama_client = defillama

    # Use candles that WILL FAIL gate_squeeze (high-vol, no dry-up).
    # BB inside KC requires flat-ish series; a wobbling series fails.
    wobble = []
    for i in range(25):
        p = 100.0 + (5.0 if i % 2 == 0 else -5.0)
        wobble.append(
            _candle(open_=p, close=p, high=p + 0.1, low=p - 0.1, volume=1000.0)
        )
    depth = _loose_depth()
    score = _make_score(score=0.80)
    meta = TokenMetadata(
        symbol="BTCUSDT",
        market_cap_usd=10_000_000.0,
        ath_proximity_risk=False,
    )
    result = engine.evaluate_entry("BTCUSDT", wobble, depth, score, token_meta=meta)
    # Even with +0.25 in additive bonuses, a failed hard gate blocks entry.
    assert result is None


def test_evaluate_entry_no_token_meta_preserves_legacy_shape() -> None:
    """Legacy callers (no token_meta) see the original 8-key gate_results.

    Ensures we didn't break backward compatibility for WU2 / WU5 integration.
    """
    from structlog.testing import capture_logs

    engine = _make_engine(
        score_full_position=0.70,
        entry_rejection_log_level="info",
    )
    score = _make_score(score=0.50)  # below threshold → pre-gate rejection
    with capture_logs() as events:
        engine.evaluate_entry("BTCUSDT", _loose_candles(25), _loose_depth(), score)

    rejections = [
        e for e in events if e["event"] == "entry_rejected_score_below_threshold"
    ]
    assert rejections
    # Still 8 keys — new gates did not pollute legacy rejections.
    assert set(rejections[0]["gate_results"].keys()) == {
        "score",
        "stale",
        "trend",
        "volume",
        "spread",
        "rsi",
        "depth",
        "resistance",
    }


def test_evaluate_entry_additive_bonus_rescues_below_threshold_score() -> None:
    """Additive bonuses can push a low score past score_full_position."""
    engine = _make_engine(
        min_consecutive_candles=1,
        volume_surge_threshold=0.0,
        spread_max_pct=5.0,
        rsi_entry_min=0.0,
        rsi_entry_max=100.0,
        resistance_threshold_pct=0.0,
        score_full_position=0.70,
        depth_gate_enabled=False,
    )
    coinglass = MagicMock()
    coinglass.get_derivatives.return_value = {
        "has_perps": True,
        "futures_volume": 1000.0,
        "spot_volume": 100.0,
        "oi_pct_change_2h": 0.0,
        "funding_rate": 0.0,
        "price_change_1h": 0.0,
    }
    engine._coinglass_client = coinglass
    # Score 0.58 + 0.15 = 0.73 > 0.70.
    score = _make_score(score=0.58)
    meta = TokenMetadata(symbol="BTCUSDT", ath_proximity_risk=False)
    candles = _squeeze_friendly_candles()
    result = engine.evaluate_entry(
        "BTCUSDT", candles, _loose_depth(), score, token_meta=meta
    )
    assert result is not None


def test_derivatives_and_tvl_not_in_gate_results() -> None:
    """WU3 critical constraint: additive bonuses must NOT pollute gate_results.

    Force an entry-gates-failure and inspect the emitted gate_results dict.
    """
    from structlog.testing import capture_logs

    engine = _make_engine(
        score_full_position=0.50,
        entry_rejection_log_level="info",
    )
    # Inject additive clients — they must not leak keys into gate_results.
    engine._coinglass_client = MagicMock()
    engine._coinglass_client.get_derivatives.return_value = {
        "has_perps": True,
        "futures_volume": 1000.0,
        "spot_volume": 100.0,
        "oi_pct_change_2h": 0.0,
        "funding_rate": 0.0,
        "price_change_1h": 0.0,
    }
    engine._defillama_client = MagicMock()
    engine._defillama_client.get_tvl_data.return_value = {"tvl_change_7d_pct": 40.0}

    # Force gate_squeeze to fail by using wobbling candles.
    wobble = []
    for i in range(25):
        p = 100.0 + (5.0 if i % 2 == 0 else -5.0)
        wobble.append(
            _candle(open_=p, close=p, high=p + 0.1, low=p - 0.1, volume=1000.0)
        )
    depth = _loose_depth()
    score = _make_score(score=0.80)
    meta = TokenMetadata(symbol="BTCUSDT", market_cap_usd=10_000_000.0)

    with capture_logs() as events:
        engine.evaluate_entry("BTCUSDT", wobble, depth, score, token_meta=meta)

    failures = [e for e in events if e["event"] == "entry_gates_failed"]
    assert failures, "expected a gate-failure event"
    gate_keys = set(failures[0]["gate_results"].keys())
    # New HARD gates ARE present...
    assert "squeeze" in gate_keys
    # ...but additive bonuses are NOT (critical constraint).
    assert "derivatives" not in gate_keys
    assert "tvl" not in gate_keys
    assert "derivatives_additive" not in gate_keys
    assert "tvl_additive" not in gate_keys


if __name__ == "__main__":
    pytest.main([__file__, "-v"])
