"""Integration tests for the weekly entry cycle.

Every external service is mocked via ``pytest-httpx``. The cycle
exercises the production code paths end-to-end: snapshot collection,
entry validation, bias, strike selection, liquidity, sizing, combo
order placement, and persistence.
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Any
from uuid import uuid4

import pytest
from pytest_httpx import HTTPXMock

from cerbero_bite.config import StrategyConfig, golden_config
from cerbero_bite.config.mcp_endpoints import load_endpoints
from cerbero_bite.runtime import build_runtime
from cerbero_bite.runtime.entry_cycle import run_entry_cycle
from cerbero_bite.state import (
    PositionRecord,
    connect,
    transaction,
)
from cerbero_bite.state import connect as connect_state

pytestmark = pytest.mark.httpx_mock(assert_all_responses_were_requested=False)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def now() -> datetime:
    return datetime(2026, 4, 27, 14, 0, tzinfo=UTC)


@pytest.fixture
def cfg() -> StrategyConfig:
    return golden_config()


@pytest.fixture
def runtime_paths(tmp_path: Path) -> tuple[Path, Path]:
    return tmp_path / "state.sqlite", tmp_path / "audit.log"


def _ctx(
    cfg: StrategyConfig,
    runtime_paths: tuple[Path, Path],
    now: datetime,
):
    db, audit = runtime_paths
    return build_runtime(
        cfg=cfg,
        endpoints=load_endpoints(env={}),
        token="t",
        db_path=db,
        audit_path=audit,
        retry_max=1,
        clock=lambda: now,
    )


def _option_name(strike: int, opt: str = "P", expiry: str = "15MAY26") -> str:
    return f"ETH-{expiry}-{strike}-{opt}"


def _wire_market_snapshot(
    httpx_mock: HTTPXMock,
    *,
    spot: float = 3000.0,
    dvol: float = 50.0,
    funding_perp_hourly: float = 0.0,
    funding_cross_period: float = 0.0001,
    macro_events: list[dict[str, Any]] | None = None,
    eth_pct: float = 0.10,
    portfolio_eur: float | Decimal = 5000.0,
    dealer_total_net_gamma: float = 12345.6,
    liquidation_long_risk: str = "low",
    liquidation_short_risk: str = "low",
) -> None:
    """Stub every MCP endpoint queried during the snapshot stage."""
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/get_ticker",
        json={"instrument_name": "ETH-PERPETUAL", "mark_price": spot},
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/get_dvol",
        json={"currency": "ETH", "latest": dvol, "candles": []},
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/get_historical",
        json={"candles": [{"close": spot * 0.95}]},
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/get_technical_indicators",
        json={"adx": [{"value": 22.0}]},
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/get_dealer_gamma_profile",
        json={
            "spot_price": spot,
            "total_net_dealer_gamma": dealer_total_net_gamma,
            "gamma_flip_level": spot * 0.99,
            "strikes_analyzed": 18,
            "by_strike": [],
        },
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/get_realized_vol",
        json={
            "currency": "ETH",
            "realized_vol_pct": {"14d": 30.0, "30d": 30.0},
            "iv_current_pct": 38.0,
            "iv_minus_rv_pct": {"14d": 8.0, "30d": 8.0},
        },
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-sentiment:9014/tools/get_liquidation_heatmap",
        json={
            "asset": "ETH",
            "avg_funding_rate": funding_cross_period,
            "oi_delta_pct_4h": 1.0,
            "oi_delta_pct_24h": 1.0,
            "long_squeeze_risk": liquidation_long_risk,
            "short_squeeze_risk": liquidation_short_risk,
        },
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-hyperliquid:9012/tools/get_funding_rate",
        json={"asset": "ETH", "current_funding_rate": funding_perp_hourly},
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding",
        json={
            "snapshot": {
                "ETH": {
                    "binance": funding_cross_period,
                    "bybit": funding_cross_period,
                    "okx": funding_cross_period,
                    "hyperliquid": None,
                }
            }
        },
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-macro:9013/tools/get_macro_calendar",
        json={"events": macro_events or []},
        is_reusable=True,
    )
    # In-process portfolio aggregator: wire the underlying exchange and
    # macro endpoints so total_equity_eur and asset_pct_of_portfolio
    # produce the requested ``portfolio_eur`` and ``eth_pct``.
    # FX rate fixed at 1.0 → EUR amount equals USD amount in tests.
    portfolio_eur_f = float(portfolio_eur)
    httpx_mock.add_response(
        url="http://mcp-macro:9013/tools/get_asset_price",
        json={"ticker": "EURUSD", "price": 1.0},
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/get_account_summary",
        json={"equity": portfolio_eur_f, "currency": "USDC"},
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/get_positions",
        json=[
            {
                "instrument_name": "ETH-15MAY26-2475-P",
                "notional_usd": portfolio_eur_f * eth_pct,
            }
        ],
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-hyperliquid:9012/tools/get_account_summary",
        json={"equity": 0.0},
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-hyperliquid:9012/tools/get_positions",
        json=[],
        is_reusable=True,
    )


def _wire_chain_and_quotes(
    httpx_mock: HTTPXMock,
    *,
    short_strike: int = 2475,
    long_strike: int = 2350,
    short_mid: float = 0.020,
    long_mid: float = 0.005,
    short_delta: float = -0.12,
    long_delta: float = -0.08,
) -> None:
    """Stub the option chain → quotes → orderbook flow.

    The two strikes returned satisfy the golden config gates by default:
    OTM range, delta range, width 4% × 3000 = 120, credit 0.015 ETH × 3000
    = 45 USD vs width 125 USD ≈ 36% (≥ 30% gate), liquidity OK.
    """
    short_name = _option_name(short_strike)
    long_name = _option_name(long_strike)
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/get_instruments",
        json={
            "instruments": [
                {"name": short_name, "open_interest": 500, "tick_size": 0.0005},
                {"name": long_name, "open_interest": 400, "tick_size": 0.0005},
            ]
        },
        is_reusable=True,
    )
    # Use tight 1% bid-ask spread relative to mid so the liquidity gate
    # passes regardless of strike (otherwise the long leg's spread
    # blows past the 15% cap on small premiums).
    short_half = short_mid * 0.005
    long_half = long_mid * 0.005
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/get_ticker_batch",
        json={
            "tickers": [
                {
                    "instrument_name": short_name,
                    "bid": short_mid - short_half,
                    "ask": short_mid + short_half,
                    "mark_price": short_mid,
                    "volume_24h": 200,
                    "greeks": {
                        "delta": short_delta,
                        "gamma": 0.001,
                        "theta": -0.0005,
                        "vega": 0.10,
                    },
                },
                {
                    "instrument_name": long_name,
                    "bid": long_mid - long_half,
                    "ask": long_mid + long_half,
                    "mark_price": long_mid,
                    "volume_24h": 150,
                    "greeks": {
                        "delta": long_delta,
                        "gamma": 0.001,
                        "theta": -0.0003,
                        "vega": 0.07,
                    },
                },
            ],
            "errors": [],
        },
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/get_orderbook",
        json={"bids": [[1, 50]], "asks": [[2, 50]]},
        is_reusable=True,
    )


def _wire_combo_order(
    httpx_mock: HTTPXMock, *, state: str = "filled"
) -> None:
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/place_combo_order",
        json={
            "combo_instrument": "ETH-15MAY26-2475P_2350P",
            "order_id": "ord-1",
            "state": state,
            "average_price": 0.005,
            "filled_amount": 2,
        },
        is_reusable=True,
    )


def _wire_telegram_notify_position_opened(httpx_mock: HTTPXMock) -> None:
    """No-op: Telegram is now an in-process client with disabled mode in tests.

    Kept for call-site compatibility; the function used to register an MCP
    notify mock but post-refactor there is no HTTP endpoint to mock when
    the bot has no Telegram credentials configured.
    """


# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_happy_path_places_combo_and_records_open_position(
    cfg: StrategyConfig,
    runtime_paths: tuple[Path, Path],
    now: datetime,
    httpx_mock: HTTPXMock,
) -> None:
    # bull bias requires bull-trend AND bull-funding.
    # Bull funding cross threshold = 0.20 annualised. Period rate × 1095
    # → 0.20/1095 ≈ 0.000183 per period.
    _wire_market_snapshot(
        httpx_mock,
        portfolio_eur=Decimal("3500"),
        funding_cross_period=0.0002,
    )
    _wire_chain_and_quotes(httpx_mock)
    _wire_combo_order(httpx_mock, state="filled")
    _wire_telegram_notify_position_opened(httpx_mock)

    # Bypass bias requirement: stub trend == bull by overriding the
    # spot snapshot with a value > +5% vs entry. Since the entry cycle
    # currently uses spot==spot (no historical data wired), it falls
    # into the "neutral trend" branch. To make a directional bias fire
    # we use iron_condor: trend neutral + funding neutral + DVOL ≥ 55
    # + ADX < 20. But ADX is hard-coded 25 in the cycle for now, so
    # instead we set funding to land in bull territory and accept the
    # neutral-vs-bull mismatch which the cycle resolves to "no bias"
    # — we bypass via configuration.
    # In practice the orchestrator will provide eth_30d_ago; for this
    # smoke test we widen bias acceptance with a config override.
    bull_cfg = golden_config(
        entry=type(cfg.entry)(
            **{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")}
        ),
    )
    ctx = _ctx(bull_cfg, runtime_paths, now)
    res = await run_entry_cycle(
        ctx, eur_to_usd_rate=Decimal("1.075"), now=now
    )

    assert res.status == "entry_placed", res.reason
    assert res.proposal is not None
    assert res.order is not None
    assert res.order.combo_instrument == "ETH-15MAY26-2475P_2350P"
    assert res.proposal.spread_type == "bull_put"

    db_path, _ = runtime_paths
    conn = connect(db_path)
    try:
        positions = ctx.repository.list_positions(conn)
    finally:
        conn.close()
    assert len(positions) == 1
    assert positions[0].status == "open"


# ---------------------------------------------------------------------------
# Reject paths
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_kill_switch_short_circuits_cycle(
    cfg: StrategyConfig,
    runtime_paths: tuple[Path, Path],
    now: datetime,
    httpx_mock: HTTPXMock,
) -> None:
    ctx = _ctx(cfg, runtime_paths, now)
    ctx.kill_switch.arm(reason="test", source="manual")
    res = await run_entry_cycle(ctx, eur_to_usd_rate=Decimal("1.075"), now=now)
    assert res.status == "kill_switch_armed"


@pytest.mark.asyncio
async def test_below_capital_minimum_returns_no_entry(
    cfg: StrategyConfig,
    runtime_paths: tuple[Path, Path],
    now: datetime,
    httpx_mock: HTTPXMock,
) -> None:
    # 500 EUR × 1.075 = 537 USD < 720 cfg minimum
    _wire_market_snapshot(httpx_mock, portfolio_eur=500.0)
    ctx = _ctx(cfg, runtime_paths, now)
    res = await run_entry_cycle(
        ctx, eur_to_usd_rate=Decimal("1.075"), now=now
    )
    assert res.status == "no_entry"
    assert "capital" in (res.reason or "").lower()


@pytest.mark.asyncio
async def test_macro_event_within_dte_blocks_entry(
    cfg: StrategyConfig,
    runtime_paths: tuple[Path, Path],
    now: datetime,
    httpx_mock: HTTPXMock,
) -> None:
    macro_events = [
        {
            "name": "FOMC",
            "country_code": "US",
            "importance": "high",
            "datetime_utc": (now + timedelta(days=5)).isoformat(),
        }
    ]
    _wire_market_snapshot(httpx_mock, macro_events=macro_events, portfolio_eur=3500)
    ctx = _ctx(cfg, runtime_paths, now)
    res = await run_entry_cycle(
        ctx, eur_to_usd_rate=Decimal("1.075"), now=now
    )
    assert res.status == "no_entry"
    assert "macro" in (res.reason or "").lower()


@pytest.mark.asyncio
async def test_no_bias_returns_no_entry(
    cfg: StrategyConfig,
    runtime_paths: tuple[Path, Path],
    now: datetime,
    httpx_mock: HTTPXMock,
) -> None:
    # Funding cross neutral (=0) and DVOL 40 → no IC, no directional;
    # entry validates clean otherwise.
    _wire_market_snapshot(
        httpx_mock,
        portfolio_eur=3500,
        dvol=40.0,
        funding_cross_period=0.0,
    )
    ctx = _ctx(cfg, runtime_paths, now)
    res = await run_entry_cycle(
        ctx, eur_to_usd_rate=Decimal("1.075"), now=now
    )
    assert res.status == "no_entry"
    assert res.reason == "no_bias"


@pytest.mark.asyncio
async def test_undersize_returns_no_entry(
    cfg: StrategyConfig,
    runtime_paths: tuple[Path, Path],
    now: datetime,
    httpx_mock: HTTPXMock,
) -> None:
    """Capital that produces n_contracts < 1 yields no_entry/undersize."""
    # Capital just above minimum (720 USD ≈ 670 EUR) but with high
    # max_loss/contract → sizing returns 0.
    _wire_market_snapshot(
        httpx_mock,
        portfolio_eur=670.0,
        funding_cross_period=0.0002,
    )
    _wire_chain_and_quotes(httpx_mock, short_strike=2400, long_strike=2150)
    bull_cfg = golden_config(
        entry=type(cfg.entry)(
            **{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")}
        ),
    )
    ctx = _ctx(bull_cfg, runtime_paths, now)
    res = await run_entry_cycle(ctx, eur_to_usd_rate=Decimal("1.075"), now=now)
    assert res.status == "no_entry"
    assert res.reason in {"undersize", "no_strike", "illiquid"}


@pytest.mark.asyncio
async def test_no_strike_when_chain_is_empty(
    cfg: StrategyConfig,
    runtime_paths: tuple[Path, Path],
    now: datetime,
    httpx_mock: HTTPXMock,
) -> None:
    _wire_market_snapshot(
        httpx_mock, portfolio_eur=3500.0, funding_cross_period=0.0002
    )
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/get_instruments",
        json={"instruments": []},
        is_reusable=True,
    )
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/get_ticker_batch",
        json={"tickers": [], "errors": []},
        is_reusable=True,
    )
    bull_cfg = golden_config(
        entry=type(cfg.entry)(
            **{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")}
        ),
    )
    ctx = _ctx(bull_cfg, runtime_paths, now)
    res = await run_entry_cycle(ctx, eur_to_usd_rate=Decimal("1.075"), now=now)
    assert res.status == "no_entry"
    assert res.reason == "no_strike"


@pytest.mark.asyncio
async def test_broker_reject_marks_position_cancelled(
    cfg: StrategyConfig,
    runtime_paths: tuple[Path, Path],
    now: datetime,
    httpx_mock: HTTPXMock,
) -> None:
    _wire_market_snapshot(
        httpx_mock, portfolio_eur=3500.0, funding_cross_period=0.0002
    )
    _wire_chain_and_quotes(httpx_mock)
    httpx_mock.add_response(
        url="http://mcp-deribit:9011/tools/place_combo_order",
        json={
            "combo_instrument": "ETH-15MAY26-2475P_2350P",
            "order_id": None,
            "state": "rejected",
            "average_price": None,
            "filled_amount": 0,
        },
        is_reusable=True,
    )
    bull_cfg = golden_config(
        entry=type(cfg.entry)(
            **{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")}
        ),
    )
    ctx = _ctx(bull_cfg, runtime_paths, now)
    res = await run_entry_cycle(ctx, eur_to_usd_rate=Decimal("1.075"), now=now)
    assert res.status == "broker_reject"
    db_path, _ = runtime_paths
    conn = connect(db_path)
    try:
        positions = ctx.repository.list_positions(conn)
    finally:
        conn.close()
    assert positions[0].status == "cancelled"
    assert ctx.kill_switch.is_armed() is True


@pytest.mark.asyncio
async def test_dealer_short_gamma_blocks_entry(
    cfg: StrategyConfig,
    runtime_paths: tuple[Path, Path],
    now: datetime,
    httpx_mock: HTTPXMock,
) -> None:
    _wire_market_snapshot(
        httpx_mock,
        portfolio_eur=3500,
        funding_cross_period=0.0002,
        dealer_total_net_gamma=-42000.0,
    )
    bull_cfg = golden_config(
        entry=type(cfg.entry)(
            **{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")}
        )
    )
    ctx = _ctx(bull_cfg, runtime_paths, now)
    res = await run_entry_cycle(
        ctx, eur_to_usd_rate=Decimal("1.075"), now=now
    )
    assert res.status == "no_entry"
    assert "dealer short-gamma" in (res.reason or "")


@pytest.mark.asyncio
async def test_liquidation_high_risk_blocks_entry(
    cfg: StrategyConfig,
    runtime_paths: tuple[Path, Path],
    now: datetime,
    httpx_mock: HTTPXMock,
) -> None:
    _wire_market_snapshot(
        httpx_mock,
        portfolio_eur=3500,
        funding_cross_period=0.0002,
        liquidation_long_risk="high",
    )
    bull_cfg = golden_config(
        entry=type(cfg.entry)(
            **{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")}
        )
    )
    ctx = _ctx(bull_cfg, runtime_paths, now)
    res = await run_entry_cycle(
        ctx, eur_to_usd_rate=Decimal("1.075"), now=now
    )
    assert res.status == "no_entry"
    assert "liquidation squeeze" in (res.reason or "")


@pytest.mark.asyncio
async def test_already_open_position_skips_cycle(
    cfg: StrategyConfig,
    runtime_paths: tuple[Path, Path],
    now: datetime,
    httpx_mock: HTTPXMock,
) -> None:
    ctx = _ctx(cfg, runtime_paths, now)
    # Pre-seed an open position
    record = PositionRecord(
        proposal_id=uuid4(),
        spread_type="bull_put",
        expiry=now + timedelta(days=18),
        short_strike=Decimal("2475"),
        long_strike=Decimal("2350"),
        short_instrument="X",
        long_instrument="Y",
        n_contracts=1,
        spread_width_usd=Decimal("125"),
        spread_width_pct=Decimal("0.04"),
        credit_eth=Decimal("0.015"),
        credit_usd=Decimal("45"),
        max_loss_usd=Decimal("80"),
        spot_at_entry=Decimal("3000"),
        dvol_at_entry=Decimal("50"),
        delta_at_entry=Decimal("-0.12"),
        eth_price_at_entry=Decimal("3000"),
        proposed_at=now,
        status="open",
        created_at=now,
        updated_at=now,
    )
    db_path, _ = runtime_paths
    conn = connect_state(db_path)
    try:
        with transaction(conn):
            ctx.repository.create_position(conn, record)
    finally:
        conn.close()

    res = await run_entry_cycle(
        ctx, eur_to_usd_rate=Decimal("1.075"), now=now
    )
    assert res.status == "has_open_position"
