"""
Tick alignment + broker-result discipline on MOVE_SL.

V16 incident 29 apr (BUG 6): BrainMR trailing computed
trailing_sl=0.7324928571428572 (16 decimals on 6C 0.00005 tick grid),
broker silently rejected, V16 ignored OrderResult.success and mutated
runtime.current_sl_price as if the move had succeeded -> next iter
recomputed and re-issued -> 20+ rejected calls in 8 minutes.

Four-layer fix verified here:
  1. Brain rounds via _round_to_tick using tech.tick_size.
  2. Orchestrator inspects OrderResult.success; on reject does NOT
     mutate runtime.
  3. Broker (topstepx_v16) defense-in-depth re-aligns to tick.
  4. Logging: move_sl_failed event with prev_sl + attempted_sl + error.
"""

from __future__ import annotations

import asyncio
import sys
import tempfile
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from broker.broker_base import OrderResult
from brain.brain_base import BrainBase


def _ok(label: str) -> None:
    print(f"  ok  {label}")


# ============================================================
# 1. Brain rounds trailing_sl to tick (Layer 1)
# ============================================================

def test_brain_round_to_tick_aligns_off_grid_price():
    """
    _round_to_tick(0.7324928571428572, 0.00005) -> 0.73250.
    The 16-decimal raw float that triggered the incident must align
    cleanly to the 6C tick grid.
    """
    raw = 0.7324928571428572
    tick = 0.00005
    aligned = BrainBase._round_to_tick(raw, tick)
    # 0.7324928... / 0.00005 = 14649.857..., round = 14650, * 0.00005 = 0.7325
    assert abs(aligned - 0.7325) < 1e-9, f"expected 0.7325, got {aligned}"
    # Idempotent: rounding an already-aligned price is a no-op.
    assert BrainBase._round_to_tick(aligned, tick) == aligned
    # tick_size=0 (defensive) -> identity
    assert BrainBase._round_to_tick(0.7324928, 0.0) == 0.7324928
    _ok("Layer 1: _round_to_tick aligns 6C 0.7324928... -> 0.7325, idempotent")


def test_brain_mr_trailing_emits_aligned_sl_price():
    """
    BrainMR.manage_exit trailing path: trailing_sl_raw is computed from
    current_price ± 1.5 * atr, then aligned via _round_to_tick(... ,
    tech.tick_size). The emitted move_sl_to MUST equal the aligned price.
    raw_sl_price preserved in metadata for forensic comparison.

    We don't import brain_mr here (pulls pandas via TechSnapshot). Instead
    we exercise the same arithmetic via _round_to_tick to verify the
    alignment math is invariant to where it lives.
    """
    # Simulated trailing computation for 6C: current=0.7340, atr=0.0011,
    # is_long=True -> trailing_sl_raw = 0.7340 - 1.5*0.0011 = 0.73235
    current_price = 0.7340
    atr = 0.0011
    trailing_dist = 1.5 * atr
    raw = current_price - trailing_dist
    aligned = BrainBase._round_to_tick(raw, 0.00005)
    # 0.73235 is exactly on grid -> aligned == raw (within float epsilon)
    assert abs(aligned - 0.73235) < 1e-9
    # Off-grid example: current=0.73402, raw = 0.73402 - 0.00165 = 0.73237
    raw2 = 0.73402 - 0.00165
    aligned2 = BrainBase._round_to_tick(raw2, 0.00005)
    # 0.73237 / 0.00005 = 14647.4, round = 14647, * 0.00005 = 0.73235
    assert abs(aligned2 - 0.73235) < 1e-9
    _ok("Layer 1 (parity): MR trailing computation -> tick-aligned SL")


# ============================================================
# 2. Orchestrator: OrderResult.success=False -> no state mutation
#    + move_sl_failed event emitted (Layers 2 + 4)
# ============================================================

def test_orchestrator_handle_move_sl_skips_state_mutation_on_broker_reject():
    """
    When broker.modify_stop returns OrderResult(success=False), the
    orchestrator must NOT mutate active.runtime.current_sl_price and
    must emit a move_sl_failed event with prev_sl + attempted_sl + error.
    """
    from core.config import RuntimeConfig, RunMode, AccountKind
    from core.contracts import (
        BrainDecision, TradeAction, TradeEntry, TradeRuntime, utc_now,
    )
    from persistence.state_store import (
        ActiveTrade, SessionState, StateStore,
    )
    from orchestrator import Orchestrator

    # Lightweight FakeBroker — modify_stop returns success=False
    class FakeBroker:
        async def modify_stop(self, symbol, order_id, new_sl_price):
            return OrderResult(
                success=False,
                error="adapter modify_sl returned False",
            )

    class JsonlSink:
        def __init__(self): self.events = []
        def write(self, event_name, **kw):
            self.events.append({"event": event_name, **kw})

    class FakeLogger:
        def __init__(self):
            import logging
            self.system = logging.getLogger("test")
            self.brain_log = JsonlSink()
        def log_error(self, **kw): pass

    cfg = RuntimeConfig(mode=RunMode.DRY, account=AccountKind.INELIGIBLE)
    cfg.asset_filter = ["6C"]
    state = SessionState()

    entry = TradeEntry(
        symbol="6C", brain_name="MR", direction="BUY",
        contracts=1, entry_price=0.7340,
        sl_price=0.7320, tp_price=0.7380,
        opened_at=utc_now(),
        rsi_m5_at_entry=25.0, rsi_h1_at_entry=30.0, rsi_h4_at_entry=40.0,
        atr_ratio_at_entry=1.0,
        market_structure_at_entry="RANGING",
        regime_at_entry="RANGING",
        h1_compat_at_entry=0.5,
        confidence_at_entry=70,
        stop_order_id="12345",
    )
    runtime = TradeRuntime()
    runtime.current_sl_price = 0.7320  # baseline pre-move
    state.active_trades["6C"] = ActiveTrade(entry=entry, runtime=runtime)

    logger = FakeLogger()
    with tempfile.TemporaryDirectory() as tmp:
        store = StateStore(Path(tmp) / "state.json")
        orch = Orchestrator(
            config=cfg, ai_client=None, market_data_provider=None,
            state=state, store=store, logger=logger,
            broker=FakeBroker(),
            max_iterations=0,
        )

        decision = BrainDecision(
            action=TradeAction.MOVE_SL.value,
            reason="trailing test",
            move_sl_to=0.7325,           # aligned target (post-Layer-1)
            metadata={"sl_target": "trailing", "raw_sl_price": 0.7324928},
        )
        active = state.active_trades["6C"]
        asyncio.run(orch._handle_move_sl("6C", active, decision))

    # Layer 2: state NOT mutated on broker reject
    assert active.runtime.current_sl_price == 0.7320, (
        f"current_sl_price must remain 0.7320, got {active.runtime.current_sl_price}"
    )
    # Layer 4: move_sl_failed emitted with diagnostics
    failed = [e for e in logger.brain_log.events if e["event"] == "move_sl_failed"]
    assert len(failed) == 1, f"expected 1 move_sl_failed, got {len(failed)}"
    ev = failed[0]
    assert ev["symbol"] == "6C"
    assert ev["current_sl_price"] == 0.7320
    assert ev["attempted_sl_price"] == 0.7325
    assert "modify_sl returned False" in ev["error"]
    assert ev["target"] == "trailing"
    # And NO move_sl success event must have been emitted
    success_events = [e for e in logger.brain_log.events if e["event"] == "move_sl"]
    assert success_events == [], "move_sl success event must NOT fire on reject"
    _ok("Layer 2+4: broker reject -> runtime untouched, move_sl_failed emitted")


def test_orchestrator_handle_move_sl_logs_move_sl_failed_event():
    """
    Counterpart of the test above: when broker raises an exception
    (network/transient), move_sl_failed is also emitted with
    error='raised: ...'.
    """
    from core.config import RuntimeConfig, RunMode, AccountKind
    from core.contracts import (
        BrainDecision, TradeAction, TradeEntry, TradeRuntime, utc_now,
    )
    from persistence.state_store import ActiveTrade, SessionState, StateStore
    from orchestrator import Orchestrator

    class RaisingBroker:
        async def modify_stop(self, symbol, order_id, new_sl_price):
            raise ConnectionError("websocket dropped")

    class JsonlSink:
        def __init__(self): self.events = []
        def write(self, event_name, **kw):
            self.events.append({"event": event_name, **kw})

    class FakeLogger:
        def __init__(self):
            import logging
            self.system = logging.getLogger("test")
            self.brain_log = JsonlSink()
        def log_error(self, **kw): pass

    cfg = RuntimeConfig(mode=RunMode.DRY, account=AccountKind.INELIGIBLE)
    cfg.asset_filter = ["6C"]
    state = SessionState()
    entry = TradeEntry(
        symbol="6C", brain_name="MR", direction="BUY",
        contracts=1, entry_price=0.7340,
        sl_price=0.7320, tp_price=0.7380,
        opened_at=utc_now(),
        rsi_m5_at_entry=25.0, rsi_h1_at_entry=30.0, rsi_h4_at_entry=40.0,
        atr_ratio_at_entry=1.0,
        market_structure_at_entry="RANGING",
        regime_at_entry="RANGING",
        h1_compat_at_entry=0.5, confidence_at_entry=70,
        stop_order_id="12345",
    )
    runtime = TradeRuntime()
    runtime.current_sl_price = 0.7320
    state.active_trades["6C"] = ActiveTrade(entry=entry, runtime=runtime)

    logger = FakeLogger()
    with tempfile.TemporaryDirectory() as tmp:
        store = StateStore(Path(tmp) / "state.json")
        orch = Orchestrator(
            config=cfg, ai_client=None, market_data_provider=None,
            state=state, store=store, logger=logger,
            broker=RaisingBroker(), max_iterations=0,
        )

        decision = BrainDecision(
            action=TradeAction.MOVE_SL.value,
            reason="trailing on raise",
            move_sl_to=0.7325,
            metadata={"sl_target": "trailing"},
        )
        active = state.active_trades["6C"]
        asyncio.run(orch._handle_move_sl("6C", active, decision))

    assert active.runtime.current_sl_price == 0.7320
    failed = [e for e in logger.brain_log.events if e["event"] == "move_sl_failed"]
    assert len(failed) == 1
    ev = failed[0]
    assert ev["current_sl_price"] == 0.7320
    assert ev["attempted_sl_price"] == 0.7325
    assert "websocket dropped" in ev["error"]
    assert ev["error"].startswith("raised: ")
    _ok("Layer 2+4: broker raise -> runtime untouched, move_sl_failed emitted")


# ============================================================
# 4. Broker safety net: topstepx_v16.modify_stop re-aligns to tick
# ============================================================

def test_broker_topstepx_modify_stop_aligns_to_tick_as_safety_net():
    """
    Even if upstream fails to round, topstepx_v16.modify_stop must
    align using ASSETS_MAP[symbol]['tick_size'] before calling the SDK
    adapter. We pass an off-grid price and verify the adapter receives
    the aligned value AND OrderResult.sl_price reflects the aligned value.
    """
    from broker.topstepx_v16 import TopstepXBroker

    captured: dict = {}

    class FakeAdapter:
        def __init__(self):
            self._instrument_cache = {"6C": {"tick_size": 0.00005}}
        async def modify_sl(self, *, symbol, stop_order_id, new_sl):
            captured["symbol"] = symbol
            captured["stop_order_id"] = stop_order_id
            captured["new_sl"] = new_sl
            return True

    broker = TopstepXBroker.__new__(TopstepXBroker)
    broker._adapter = FakeAdapter()

    raw = 0.7324928571428572   # the offending value from the 29-apr incident
    result = asyncio.run(broker.modify_stop("6C", "12345", raw))

    assert result.success is True
    assert abs(captured["new_sl"] - 0.7325) < 1e-9, (
        f"adapter should receive aligned 0.7325, got {captured['new_sl']}"
    )
    assert abs(result.sl_price - 0.7325) < 1e-9, (
        f"OrderResult.sl_price should reflect aligned 0.7325, got {result.sl_price}"
    )
    _ok("Layer 3: topstepx_v16.modify_stop re-aligns 0.7324928... -> 0.7325 (safety net)")


# ============================================================
# MAIN
# ============================================================

def main() -> int:
    print("test_move_sl_tick_alignment.py")
    test_brain_round_to_tick_aligns_off_grid_price()
    test_brain_mr_trailing_emits_aligned_sl_price()
    test_orchestrator_handle_move_sl_skips_state_mutation_on_broker_reject()
    test_orchestrator_handle_move_sl_logs_move_sl_failed_event()
    test_broker_topstepx_modify_stop_aligns_to_tick_as_safety_net()
    print("ALL 5 TESTS PASSED")
    return 0


if __name__ == "__main__":
    sys.exit(main())
