"""Signal engine v2 tests — rejection logging, depth-gate switch, per-symbol
isolation, candle-length pre-check.

Owns: /projects/sigil/tests/test_signal_engine_v2.py (Work Unit 3, Unhinged v2).

Covers:
  - entry_rejection_log_level="info" → rejection emits at INFO.
  - depth_gate_enabled=False → gate_depth skipped, gate_results["depth"]=True.
  - depth_gate_enabled=True + ticker-proxy path → depth_gate_unreachable
    WARNING emitted once per scan.
  - Symbol with <21 candles → structured insufficient_candles rejection,
    no raise.
  - Symbol whose evaluation raises RuntimeError → loop continues,
    symbol_faults counter incremented.
"""

from __future__ import annotations

from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock

import pytest
from structlog.testing import capture_logs

from src.micro_scanner.config import MicroConfig
from src.micro_scanner.contracts import CandidateScore
from src.micro_scanner.signal_engine import MicroSignalEngine


# ---------------------------------------------------------------------------
# Fixtures / helpers
# ---------------------------------------------------------------------------


def _make_candles(
    count: int,
    *,
    all_green: bool = True,
    volume: float = 1000.0,
) -> list[dict]:
    """Build a list of OHLCV candle dicts, most-recent-last."""
    candles = []
    for i in range(count):
        if all_green:
            o, c = 100.0 + i * 0.1, 100.0 + i * 0.1 + 0.05
        else:
            o, c = 100.0 - i * 0.1, 100.0 - i * 0.1 - 0.05
        candles.append(
            {
                "timestamp": i * 60_000,
                "open": o,
                "high": max(o, c) + 0.01,
                "low": min(o, c) - 0.01,
                "close": c,
                "volume": volume,
            }
        )
    return candles


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


def _make_depth(
    *,
    bid: float = 100.0,
    ask: float = 100.05,
    bid_depth: float = 10.0,
    ask_depth: float = 5.0,
    high_24h: float = 110.0,
    from_ticker: bool = False,
) -> dict:
    d: dict = {
        "bid": bid,
        "ask": ask,
        "bid_depth": bid_depth,
        "ask_depth": ask_depth,
        "high_24h": high_24h,
    }
    if from_ticker:
        d["from_ticker"] = True
    return d


def _make_engine(**config_overrides) -> MicroSignalEngine:
    cfg = MicroConfig(**config_overrides)
    conn = MagicMock()
    return MicroSignalEngine(cfg, conn, mode="draft")


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


def test_rejection_log_level_info() -> None:
    """With entry_rejection_log_level='info', rejection emits at INFO level."""
    engine = _make_engine(
        entry_rejection_log_level="info",
        score_full_position=0.70,
    )
    # Score below threshold → triggers score-rejection path.
    score = _make_score(score=0.50)
    depth = _make_depth()
    candles = _make_candles(25)

    with capture_logs() as events:
        result = engine.evaluate_entry("BTCUSDT", candles, depth, score)

    assert result is None
    rejections = [
        e for e in events if e["event"] == "entry_rejected_score_below_threshold"
    ]
    assert len(rejections) == 1, f"expected one rejection log, got {events}"
    assert rejections[0]["log_level"] == "info"
    # gate_results contract
    assert "gate_results" in rejections[0]
    assert set(rejections[0]["gate_results"].keys()) == {
        "score",
        "stale",
        "trend",
        "volume",
        "spread",
        "rsi",
        "depth",
        "resistance",
    }


def test_rejection_log_level_debug_default() -> None:
    """Default entry_rejection_log_level='debug' emits at DEBUG."""
    engine = _make_engine()  # default debug
    score = _make_score(score=0.50)
    depth = _make_depth()
    candles = _make_candles(25)

    with capture_logs() as events:
        engine.evaluate_entry("BTCUSDT", candles, depth, score)

    rejections = [
        e for e in events if e["event"] == "entry_rejected_score_below_threshold"
    ]
    assert len(rejections) == 1
    assert rejections[0]["log_level"] == "debug"


def test_depth_gate_disabled_marks_passed() -> None:
    """depth_gate_enabled=False → gate_depth skipped, gate_results['depth']=True."""
    engine = _make_engine(
        depth_gate_enabled=False,
        # Loosen other gates so we can isolate depth behaviour.
        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,
    )
    # Bid/ask depth = 0 (would fail real gate_depth) — with switch off,
    # depth is reported as passed.
    depth = _make_depth(bid_depth=0.0, ask_depth=0.0)
    candles = _make_candles(25)
    score = _make_score(score=0.80)

    with capture_logs() as events:
        engine.evaluate_entry("BTCUSDT", candles, depth, score)

    # Either a signal was generated (all gates pass incl. depth=True) or a
    # gate-failure event was emitted; in either case, if any rejection
    # or signal event carries gate_results, depth must be True.
    gate_events = [e for e in events if "gate_results" in e]
    # Pre-gate events (stale/score/insufficient) set every gate False —
    # we don't expect to hit those here. Only the entry_gates_failed path.
    if gate_events:
        for e in gate_events:
            assert e["gate_results"]["depth"] is True, (
                f"expected depth=True when depth_gate_enabled=False, got {e}"
            )
    # No depth_gate_unreachable warning since gate is disabled entirely.
    assert not any(e["event"] == "depth_gate_unreachable" for e in events)


def test_depth_gate_unreachable_warning_once_per_scan() -> None:
    """depth_gate_enabled=True + from_ticker=True → WARNING once per scan."""
    engine = _make_engine(
        depth_gate_enabled=True,
        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 = _make_depth(from_ticker=True)
    candles = _make_candles(25)

    engine.begin_scan()
    with capture_logs() as events:
        # Evaluate multiple symbols in the same scan.
        for sym in ("BTCUSDT", "ETHUSDT", "SOLUSDT"):
            engine.evaluate_entry(sym, candles, depth, _make_score(symbol=sym))

    warnings = [e for e in events if e["event"] == "depth_gate_unreachable"]
    assert len(warnings) == 1, (
        f"expected exactly one depth_gate_unreachable per scan, got {len(warnings)}"
    )
    assert warnings[0]["log_level"] == "warning"

    # New scan → flag resets, warning emits again.
    engine.begin_scan()
    with capture_logs() as events2:
        engine.evaluate_entry("BTCUSDT", candles, depth, _make_score())
    warnings2 = [e for e in events2 if e["event"] == "depth_gate_unreachable"]
    assert len(warnings2) == 1


def test_insufficient_candles_short_circuits() -> None:
    """<21 candles → structured insufficient_candles rejection, no raise."""
    engine = _make_engine(score_full_position=0.50)
    candles = _make_candles(10)  # too few
    depth = _make_depth()
    score = _make_score(score=0.80)

    with capture_logs() as events:
        result = engine.evaluate_entry("NEWLIST", candles, depth, score)

    assert result is None
    rejections = [e for e in events if e["event"] == "insufficient_candles"]
    assert len(rejections) == 1, events
    r = rejections[0]
    assert r["symbol"] == "NEWLIST"
    assert r["have"] == 10
    assert r["need"] == 21
    assert "gate_results" in r
    assert r["gate_results"]["score"] is True  # score passed
    assert r["gate_results"]["stale"] is True  # freshness passed


def test_symbol_fault_isolation() -> None:
    """A RuntimeError during evaluation must not propagate — fault counter +1."""
    engine = _make_engine(score_full_position=0.50)
    candles = _make_candles(25)
    depth = _make_depth()
    score = _make_score(score=0.80)

    # Poison the depth dict so float() conversion raises inside the inner
    # evaluator (defensive: use an object whose __float__ raises).
    class Boom:
        def __float__(self) -> float:  # pragma: no cover - exercised by test
            raise RuntimeError("synthetic fault")

    poisoned_depth = dict(depth)
    poisoned_depth["bid"] = Boom()

    assert engine.symbol_faults == 0

    with capture_logs() as events:
        # First call: poisoned → must NOT raise, must increment counter.
        result1 = engine.evaluate_entry("BOOMUSDT", candles, poisoned_depth, score)
        # Second call with clean inputs: loop continues cleanly.
        result2 = engine.evaluate_entry("OKUSDT", candles, depth, score)

    assert result1 is None
    assert engine.symbol_faults == 1
    fault_events = [e for e in events if e["event"] == "symbol_fault"]
    assert len(fault_events) == 1
    assert fault_events[0]["symbol"] == "BOOMUSDT"
    # Second call proceeded past the faulting boundary — either produced a
    # signal or a normal rejection, but did NOT increment the fault counter.
    assert engine.symbol_faults == 1
    # result2 may be None (gates may reject with default thresholds); that's
    # fine. The contract is: loop continued, counter not re-incremented.
    del result2  # suppress unused-variable complaints


def test_gate_results_shape_on_entry_gates_failed() -> None:
    """entry_gates_failed must carry the full 8-key gate_results dict."""
    # Default config → strict gates → almost-certain failure on flat candles.
    engine = _make_engine(
        score_full_position=0.50,
        entry_rejection_log_level="info",
    )
    candles = _make_candles(25, all_green=False)  # red → trend fails
    depth = _make_depth()
    score = _make_score(score=0.80)

    with capture_logs() as events:
        engine.evaluate_entry("ETHUSDT", candles, depth, score)

    failures = [e for e in events if e["event"] == "entry_gates_failed"]
    if failures:
        e = failures[0]
        assert set(e["gate_results"].keys()) == {
            "score",
            "stale",
            "trend",
            "volume",
            "spread",
            "rsi",
            "depth",
            "resistance",
        }
        assert isinstance(e["failed_gates"], list)


def test_begin_scan_resets_fault_counter() -> None:
    """begin_scan() zeros the symbol_faults counter."""
    engine = _make_engine()
    engine.symbol_faults = 7
    engine.begin_scan()
    assert engine.symbol_faults == 0


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