"""Phase 2: Core Engine Audit - Order Lifecycle, Risk Pipeline, Positions, Fills.

Targeted audit tests for engine correctness. Covers order lifecycle (buy/sell/fill),
risk pipeline edge cases (NaN/Inf/boundary), position tracking (avg entry, PnL),
and fill processing (dedup, cap).
"""

import math
import time

import pytest

from horizon._horizon import (
    Engine,
    Fill,
    Market,
    OrderRequest,
    OrderSide,
    OrderStatus,
    OrderType,
    RiskConfig,
    Side,
    TimeInForce,
)


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

def make_engine(**overrides):
    """Create an engine with sensible audit defaults."""
    defaults = dict(
        risk_config=RiskConfig(
            max_position_per_market=100.0,
            max_portfolio_notional=1000.0,
            max_daily_drawdown_pct=10.0,
            max_order_size=50.0,
            rate_limit_burst=100,
            rate_limit_sustained=50,
            dedup_window_ms=1000,
        ),
        paper_partial_fill_ratio=1.0,
    )
    defaults.update(overrides)
    return Engine(**defaults)


def buy(market="mkt_1", price=0.55, size=10.0, **kw):
    return OrderRequest(
        market_id=market,
        side=kw.get("side", Side.Yes),
        order_side=OrderSide.Buy,
        size=size,
        price=price,
        order_type=kw.get("order_type", OrderType.Limit),
        time_in_force=kw.get("time_in_force", TimeInForce.GTC),
    )


def sell(market="mkt_1", price=0.60, size=10.0, **kw):
    return OrderRequest(
        market_id=market,
        side=kw.get("side", Side.Yes),
        order_side=OrderSide.Sell,
        size=size,
        price=price,
        order_type=kw.get("order_type", OrderType.Limit),
        time_in_force=kw.get("time_in_force", TimeInForce.GTC),
    )


def make_fill(fill_id, order_id, market="mkt_1", side=Side.Yes,
              order_side=OrderSide.Buy, price=0.55, size=10.0, fee=0.0):
    return Fill(
        fill_id=fill_id,
        order_id=order_id,
        market_id=market,
        side=side,
        order_side=order_side,
        price=price,
        size=size,
        fee=fee,
    )


# ===================================================================
# 2a. Order Lifecycle
# ===================================================================

class TestOrderLifecycle:
    """Full order lifecycle: submit → tick → fill → close."""

    def test_full_buy_sell_cycle(self):
        """Buy → tick fills → sell → tick fills → position closed."""
        e = make_engine()
        # Buy 10 at 0.55
        oid = e.submit_order(buy(price=0.55, size=10.0))
        assert oid.startswith("p")

        # Tick at 0.50 (below buy limit → fills)
        fills = e.tick("mkt_1", 0.50)
        assert fills == 1

        # Position exists
        positions = e.positions()
        assert len(positions) == 1
        assert positions[0].size == 10.0
        assert positions[0].avg_entry_price == pytest.approx(0.55)

        # Sell 10 at 0.60
        oid2 = e.submit_order(sell(price=0.60, size=10.0))
        assert oid2.startswith("p")

        # Tick at 0.65 (above sell limit → fills)
        fills2 = e.tick("mkt_1", 0.65)
        assert fills2 == 1

        # Position should be closed (size ≈ 0)
        positions = e.positions()
        # positions() returns only active (size > 0)
        assert len(positions) == 0

    def test_partial_fills(self):
        """With partial_fill_ratio=0.5, orders fill partially per tick."""
        e = make_engine(paper_partial_fill_ratio=0.5)
        e.submit_order(buy(price=0.55, size=10.0))

        # First tick: fills 50%
        e.tick("mkt_1", 0.50)
        positions = e.positions()
        assert len(positions) == 1
        assert positions[0].size == pytest.approx(5.0)

        # Second tick: fills 50% of remaining
        e.tick("mkt_1", 0.50)
        positions = e.positions()
        assert positions[0].size == pytest.approx(7.5)

    def test_fok_all_or_nothing(self):
        """FOK orders fill entirely or get canceled - never partial."""
        e = make_engine(paper_partial_fill_ratio=0.5)
        oid = e.submit_order(buy(price=0.55, size=10.0, time_in_force=TimeInForce.FOK))

        # Tick at matching price - FOK ignores partial_fill_ratio
        e.tick("mkt_1", 0.50)
        positions = e.positions()
        assert len(positions) == 1
        assert positions[0].size == pytest.approx(10.0)  # Full fill, not partial

    def test_fak_fill_and_kill(self):
        """FAK fills what it can and cancels remainder."""
        e = make_engine(paper_partial_fill_ratio=0.5)
        oid = e.submit_order(buy(price=0.55, size=10.0, time_in_force=TimeInForce.FAK))

        # Tick - FAK fills partial then cancels rest
        e.tick("mkt_1", 0.50)
        positions = e.positions()
        assert len(positions) == 1
        # FAK gets partial fill, but remainder canceled
        filled_size = positions[0].size
        assert filled_size > 0
        assert filled_size <= 10.0

        # NOTE: Paper exchange cancels FAK remainder internally, but
        # Engine's OrderManager may still show it as open (by design:
        # Paper manages its own order state). This is a known discrepancy.
        # The Paper's resting_orders() would be empty.

    def test_market_order_fills_at_mid(self):
        """Market orders fill at mid_price, not order price."""
        e = make_engine()
        e.submit_order(buy(price=0.0, size=10.0, order_type=OrderType.Market))

        e.tick("mkt_1", 0.42)
        positions = e.positions()
        assert len(positions) == 1
        # Market order fills at mid_price
        assert positions[0].avg_entry_price == pytest.approx(0.42)

    def test_multi_market_isolation(self):
        """Ticking one market doesn't affect orders in another."""
        e = make_engine()
        e.submit_order(buy(market="mkt_A", price=0.55, size=10.0))
        e.submit_order(buy(market="mkt_B", price=0.55, size=10.0))

        # Only tick mkt_A
        e.tick("mkt_A", 0.50)

        # mkt_A should have filled, mkt_B still open
        positions = e.positions()
        market_ids = {p.market_id for p in positions}
        assert "mkt_A" in market_ids
        assert "mkt_B" not in market_ids

        # One order still open (mkt_B)
        assert e.status().open_orders == 1

    def test_maker_taker_fee_differentiation(self):
        """Maker and taker fees should differ when configured."""
        e = make_engine(
            paper_maker_fee_rate=0.001,
            paper_taker_fee_rate=0.005,
        )
        # Limit buy at 0.55 - when mid is 0.60 (above order price), this is a maker
        e.submit_order(buy(price=0.55, size=10.0))
        e.tick("mkt_1", 0.55)  # exact match

        fills = e.recent_fills()
        assert len(fills) >= 1
        # Fee should be non-zero
        assert fills[0].fee > 0

    def test_cancel_for_market_isolation(self):
        """cancel_market only affects the target market."""
        e = make_engine()
        e.submit_order(buy(market="mkt_A", price=0.50, size=5.0))
        e.submit_order(buy(market="mkt_A", price=0.51, size=5.0))
        e.submit_order(buy(market="mkt_B", price=0.55, size=5.0))

        cancelled = e.cancel_market("mkt_A")
        assert cancelled == 2

        # mkt_B order still open
        assert e.status().open_orders == 1


# ===================================================================
# 2b. Risk Pipeline Edge Cases
# ===================================================================

class TestRiskPipeline:
    """Tests for each of the 8 risk checks."""

    def test_nan_price_rejected(self):
        e = make_engine()
        with pytest.raises((RuntimeError, ValueError), match="(?i)price"):
            e.submit_order(buy(price=float("nan")))

    def test_nan_size_rejected(self):
        e = make_engine()
        with pytest.raises((RuntimeError, ValueError), match="(?i)size"):
            e.submit_order(buy(size=float("nan")))

    def test_inf_size_rejected(self):
        e = make_engine()
        with pytest.raises((RuntimeError, ValueError), match="(?i)size"):
            e.submit_order(buy(size=float("inf")))

    def test_negative_price_rejected(self):
        e = make_engine()
        with pytest.raises((RuntimeError, ValueError), match="(?i)price"):
            e.submit_order(buy(price=-0.5))

    def test_boundary_price_001_accepted(self):
        e = make_engine()
        oid = e.submit_order(buy(price=0.01, size=1.0))
        assert oid.startswith("p")

    def test_boundary_price_099_accepted(self):
        e = make_engine()
        oid = e.submit_order(buy(price=0.99, size=1.0))
        assert oid.startswith("p")

    def test_boundary_price_100_rejected(self):
        e = make_engine()
        with pytest.raises((RuntimeError, ValueError), match="(?i)price"):
            e.submit_order(buy(price=1.0))

    def test_boundary_price_000_rejected(self):
        e = make_engine()
        with pytest.raises((RuntimeError, ValueError), match="(?i)price"):
            e.submit_order(buy(price=0.0))

    def test_position_limit_blocks_buy(self):
        """Buying beyond position limit is rejected."""
        e = make_engine(
            risk_config=RiskConfig(max_position_per_market=10.0, max_order_size=50.0),
        )
        # Fill a position of 10
        e.process_fill(make_fill("f1", "o1", size=10.0))

        # Try to buy more - should be blocked
        with pytest.raises((RuntimeError, ValueError), match="(?i)position"):
            e.submit_order(buy(size=5.0))

    def test_position_limit_allows_reducing_sell(self):
        """Selling to reduce an existing position should be allowed even at limit."""
        e = make_engine(
            risk_config=RiskConfig(max_position_per_market=10.0, max_order_size=50.0),
        )
        # Fill a position of 10
        e.process_fill(make_fill("f1", "o1", size=10.0))

        # Sell should be allowed (reduces exposure)
        oid = e.submit_order(sell(size=5.0))
        assert oid.startswith("p")

    def test_portfolio_notional_limit(self):
        """Orders exceeding portfolio notional are rejected."""
        e = make_engine(
            risk_config=RiskConfig(
                max_portfolio_notional=50.0,
                max_position_per_market=1000.0,
                max_order_size=1000.0,
            ),
        )
        # Fill a large position: size=90 at price=0.55 → notional=49.5
        e.process_fill(make_fill("f1", "o1", size=90.0, price=0.55))

        # Try to add 50 more at 0.55 = 27.5 notional → total ~77 > 50
        with pytest.raises((RuntimeError, ValueError), match="(?i)notional"):
            e.submit_order(buy(size=50.0, price=0.55))

    def test_daily_drawdown_triggers_rejection(self):
        """Drawdown past threshold rejects orders."""
        config = RiskConfig(max_daily_drawdown_pct=5.0, max_order_size=100.0)
        e = Engine(risk_config=config)

        # Set baseline PnL and then drop
        e.set_daily_baseline(100.0)
        e.update_daily_pnl(94.0)  # 6% drawdown > 5% threshold

        with pytest.raises((RuntimeError, ValueError), match="(?i)drawdown"):
            e.submit_order(buy(size=1.0))

    def test_rate_limit_burst_exhaustion(self):
        """Burst limit blocks after too many rapid orders."""
        config = RiskConfig(
            max_order_size=100.0,
            max_position_per_market=10000.0,
            max_portfolio_notional=100000.0,
            rate_limit_burst=5,
            rate_limit_sustained=1,
        )
        e = Engine(risk_config=config)

        for i in range(5):
            e.submit_order(buy(market=f"mkt_{i}", price=0.50, size=1.0))

        # 6th order should hit rate limit
        with pytest.raises((RuntimeError, ValueError), match="(?i)rate"):
            e.submit_order(buy(market="mkt_extra", price=0.50, size=1.0))

    def test_dedup_window_catches_identical(self):
        """Identical limit orders within dedup window are rejected."""
        e = make_engine()
        e.submit_order(buy(price=0.55, size=10.0))
        with pytest.raises((RuntimeError, ValueError), match="(?i)dup"):
            e.submit_order(buy(price=0.55, size=10.0))

    def test_dedup_does_not_block_market_orders(self):
        """Market orders should bypass dedup check."""
        config = RiskConfig(
            max_order_size=100.0,
            max_position_per_market=1000.0,
            max_portfolio_notional=100000.0,
            dedup_window_ms=5000,
        )
        e = Engine(risk_config=config)
        e.submit_order(buy(size=1.0, price=0.0, order_type=OrderType.Market))
        # Second market order should NOT be deduped
        oid = e.submit_order(buy(size=1.0, price=0.0, order_type=OrderType.Market))
        assert oid.startswith("p")

    def test_event_level_position_limit(self):
        """max_position_per_event limits exposure across multi-outcome markets."""
        config = RiskConfig(
            max_position_per_market=100.0,
            max_order_size=100.0,
            max_position_per_event=20.0,
        )
        e = Engine(risk_config=config)

        # Register event with two markets
        e.register_event("evt_1", ["mkt_A", "mkt_B"])

        # Fill 15 in mkt_A
        e.process_fill(make_fill("f1", "o1", market="mkt_A", size=15.0))

        # Try to buy 10 in mkt_B - total event exposure would be 25 > 20
        with pytest.raises((RuntimeError, ValueError), match="(?i)event|position"):
            e.submit_order(buy(market="mkt_B", size=10.0))

    def test_kill_switch_manual_activation(self):
        """Manual kill switch blocks all orders."""
        e = make_engine()
        e.activate_kill_switch("audit test")

        with pytest.raises((RuntimeError, ValueError), match="(?i)kill.?switch"):
            e.submit_order(buy(size=1.0))

        e.deactivate_kill_switch()
        oid = e.submit_order(buy(size=1.0))
        assert oid.startswith("p")


# ===================================================================
# 2c. Position Tracking
# ===================================================================

class TestPositionTracking:
    """Position creation, weighted avg, PnL, epsilon guard."""

    def test_buy_creates_position(self):
        e = make_engine()
        e.process_fill(make_fill("f1", "o1", price=0.55, size=10.0))

        positions = e.positions()
        assert len(positions) == 1
        assert positions[0].market_id == "mkt_1"
        assert positions[0].size == pytest.approx(10.0)
        assert positions[0].avg_entry_price == pytest.approx(0.55)

    def test_multiple_buys_weighted_average(self):
        """Two buys at different prices → weighted avg entry."""
        e = make_engine()
        e.process_fill(make_fill("f1", "o1", price=0.50, size=10.0))
        e.process_fill(make_fill("f2", "o2", price=0.60, size=10.0))

        positions = e.positions()
        assert len(positions) == 1
        # Weighted avg: (0.50 * 10 + 0.60 * 10) / 20 = 0.55
        assert positions[0].avg_entry_price == pytest.approx(0.55)
        assert positions[0].size == pytest.approx(20.0)

    def test_sell_realized_pnl(self):
        """Selling at higher price than avg entry → positive realized PnL."""
        e = make_engine()
        # Buy 10 at 0.50
        e.process_fill(make_fill("f1", "o1", price=0.50, size=10.0))
        # Sell 5 at 0.70
        e.process_fill(make_fill("f2", "o2", price=0.70, size=5.0,
                                  order_side=OrderSide.Sell))

        positions = e.positions()
        assert len(positions) == 1
        assert positions[0].size == pytest.approx(5.0)
        # Realized PnL = (0.70 - 0.50) * 5 = 1.0
        assert positions[0].realized_pnl == pytest.approx(1.0)

    def test_full_close_zeroes_position(self):
        """Selling entire position → position removed from active list."""
        e = make_engine()
        e.process_fill(make_fill("f1", "o1", price=0.50, size=10.0))
        e.process_fill(make_fill("f2", "o2", price=0.60, size=10.0,
                                  order_side=OrderSide.Sell))

        # Active positions should be empty (size ≈ 0 due to epsilon guard)
        positions = e.positions()
        assert len(positions) == 0

    def test_netting_pairs_reduce_notional(self):
        """Netting pairs should reduce adjusted notional for hedged positions."""
        e = make_engine()
        e.set_netting_pair("mkt_A", "mkt_B")

        # Buy in both markets - these hedge each other
        e.process_fill(make_fill("f1", "o1", market="mkt_A", price=0.50, size=10.0))
        e.process_fill(make_fill("f2", "o2", market="mkt_B", price=0.50, size=10.0))

        status = e.status()
        # With netting, the effective notional should be less than 10.0
        # (min of the two exposures is subtracted)
        assert status.total_unrealized_pnl is not None  # Just verify no crash


# ===================================================================
# 2d. Fill Processing
# ===================================================================

class TestFillProcessing:
    """Fill dedup and cap enforcement."""

    def test_fill_dedup_same_id(self):
        """Processing same fill_id twice should not double-count."""
        e = make_engine()
        fill = make_fill("f1", "o1", price=0.50, size=10.0)
        e.process_fill(fill)
        e.process_fill(fill)  # Duplicate

        positions = e.positions()
        assert len(positions) == 1
        assert positions[0].size == pytest.approx(10.0)  # Not 20.0

    def test_fill_cap_at_1000(self):
        """Engine should keep at most 1000 recent fills."""
        e = make_engine(
            risk_config=RiskConfig(
                max_position_per_market=100000.0,
                max_portfolio_notional=1000000.0,
                max_order_size=100000.0,
            ),
        )

        for i in range(1100):
            e.process_fill(make_fill(
                f"f{i}", f"o{i}",
                market=f"mkt_{i % 100}",
                price=0.50,
                size=0.01,
            ))

        fills = e.recent_fills()
        assert len(fills) <= 1000

    def test_fill_dedup_different_ids_accepted(self):
        """Fills with different IDs should both be processed."""
        e = make_engine()
        e.process_fill(make_fill("f1", "o1", price=0.50, size=5.0))
        e.process_fill(make_fill("f2", "o2", price=0.55, size=5.0))

        positions = e.positions()
        assert len(positions) == 1
        assert positions[0].size == pytest.approx(10.0)

    def test_engine_status_reflects_fills(self):
        """Engine status should show active positions after fills."""
        e = make_engine()
        e.process_fill(make_fill("f1", "o1", price=0.50, size=10.0))

        status = e.status()
        assert status.active_positions >= 1
        assert status.total_realized_pnl == pytest.approx(0.0)


# ===================================================================
# Additional Edge Cases
# ===================================================================

class TestEdgeCases:
    """Miscellaneous edge cases discovered during audit."""

    def test_submit_to_nonexistent_exchange(self):
        """Submitting to an unknown exchange raises ValueError."""
        e = make_engine()
        with pytest.raises((ValueError, RuntimeError)):
            e.submit_order(buy(), exchange="nonexistent")

    def test_cancel_nonexistent_order(self):
        """Canceling a nonexistent order should not crash."""
        e = make_engine()
        # Should not raise - just returns 0 or silently skips
        try:
            e.cancel("nonexistent_id")
        except Exception:
            pass  # Acceptable - just should not panic/crash

    def test_tick_with_no_orders(self):
        """Ticking with no open orders should return 0 fills."""
        e = make_engine()
        fills = e.tick("mkt_1", 0.50)
        assert fills == 0

    def test_paper_order_id_format(self):
        """Paper exchange order IDs should be p1, p2, etc."""
        e = make_engine()
        id1 = e.submit_order(buy(market="mkt_1", price=0.50, size=1.0))
        id2 = e.submit_order(buy(market="mkt_2", price=0.51, size=1.0))
        assert id1.startswith("p")
        assert id2.startswith("p")
        # Sequential
        num1 = int(id1[1:])
        num2 = int(id2[1:])
        assert num2 == num1 + 1

    def test_multiple_markets_concurrent(self):
        """Multiple markets can have independent order books and positions."""
        e = make_engine()
        for i in range(5):
            e.submit_order(buy(market=f"mkt_{i}", price=0.50, size=5.0))

        assert e.status().open_orders == 5

        # Fill only mkt_0
        e.tick("mkt_0", 0.45)

        assert e.status().open_orders == 4
        assert e.status().active_positions == 1

    def test_engine_exchange_name(self):
        """Engine should report its exchange name."""
        e = make_engine()
        assert e.exchange_name() == "paper"

    def test_risk_config_validation_rejects_negative(self):
        """RiskConfig constructor rejects negative values."""
        with pytest.raises((ValueError, RuntimeError)):
            RiskConfig(max_position_per_market=-1.0)

    def test_risk_config_validation_rejects_zero_burst(self):
        """RiskConfig constructor rejects zero rate limit burst."""
        with pytest.raises((ValueError, RuntimeError)):
            RiskConfig(rate_limit_burst=0)

    def test_risk_config_validation_rejects_nan(self):
        """RiskConfig constructor rejects NaN values."""
        with pytest.raises((ValueError, RuntimeError)):
            RiskConfig(max_position_per_market=float("nan"))

    def test_cancel_all_returns_count(self):
        """cancel_all returns number of cancelled orders."""
        e = make_engine()
        for i in range(5):
            e.submit_order(buy(market=f"m{i}", price=0.50, size=1.0))

        count = e.cancel_all()
        assert count == 5
        assert e.status().open_orders == 0

    def test_sell_without_position_risk_check(self):
        """Selling without a position may be allowed (reduces to 0 exposure)."""
        e = make_engine()
        # Sell with no existing position - should pass risk (exposure goes to 0)
        oid = e.submit_order(sell(size=5.0, price=0.60))
        assert oid.startswith("p")
