"""Pipeline runner and strategy composition."""

from __future__ import annotations

import inspect
import logging
import os
import signal
import time
from datetime import datetime
from typing import Any, Callable

logger = logging.getLogger("horizon")

from horizon._horizon import Engine, Event, Market, Quote, RiskConfig, Side
from horizon.constants import GAMMA_API_URL as _GAMMA_API_URL, KALSHI_API_URL as _KALSHI_API_URL
from horizon.context import Context, FeedData, InventorySnapshot
from horizon.exchanges import Kalshi, Polymarket
from horizon.feeds import (
    BinanceWS, KalshiBook, PolymarketBook, RESTFeed,
    PredictItFeed, ManifoldFeed, ESPNFeed, NWSFeed, RESTJsonPathFeed,
    ChainlinkFeed,
)
from horizon.risk import Risk

# CLI can set overrides here before the strategy module calls run()
_cli_overrides: dict[str, Any] = {}


def run(
    name: str,
    exchange: Any = None,
    exchanges: list[Any] | None = None,
    markets: list[str] | None = None,
    events: list[Event] | None = None,
    feeds: dict[str, Any] | None = None,
    pipeline: list[Callable] | dict[str, list[Callable]] | None = None,
    risk: Risk | RiskConfig | None = None,
    interval: float = 0.5,
    mode: str = "paper",
    dashboard: bool = False,
    params: dict[str, Any] | None = None,
    db_path: str | None = ...,
    netting_pairs: list[tuple[str, str]] | None = None,
    api_key: str | None = None,
) -> None:
    """Single entry point to run a strategy.

    Args:
        name: Strategy name (shown in dashboard).
        exchange: Exchange config (Polymarket(...), Kalshi(...), or None for paper).
                  For single-exchange mode (backward compatible).
        exchanges: List of exchange configs for multi-exchange mode.
                   e.g., [Polymarket(...), Kalshi(...)].
                   Mutually exclusive with `exchange`.
        markets: List of market slugs or tickers.
        events: List of Event objects for multi-outcome event trading.
                Each event's outcomes are converted to Markets and iterated.
        feeds: Dict of feed name -> feed config (e.g., {"btc": BinanceWS("btcusdt")}).
        pipeline: List of functions chained together, or a dict mapping
                  market_id -> pipeline list ("*" = default pipeline).
        risk: Risk config (Risk(...) or RiskConfig(...)).
        interval: Quote interval in seconds.
        mode: "paper" or "live".
        dashboard: Whether to show the TUI dashboard.
        params: Arbitrary parameters passed to pipeline functions via Context.
        db_path: Path to SQLite database for persistence.
                 Default: HORIZON_DB env var or "./{name}.db".
                 Set to None to disable persistence.
        netting_pairs: List of (market_a, market_b) tuples whose positions offset
                       for risk purposes. Used for cross-exchange hedging.
        api_key: Horizon API key. Falls back to HORIZON_API_KEY env var.
    """
    # Ensure log messages are visible (only if user hasn't configured logging)
    if not logging.root.handlers:
        logging.basicConfig(level=logging.INFO, format="%(message)s")
    if logger.getEffectiveLevel() > logging.INFO:
        logger.setLevel(logging.INFO)

    # Resolve Horizon API key
    horizon_key = api_key or os.environ.get("HORIZON_API_KEY")

    # Apply CLI overrides
    mode = _cli_overrides.get("mode", mode)
    dashboard = _cli_overrides.get("dashboard", dashboard)

    if pipeline is None:
        pipeline = []
    if markets is None:
        markets = []
    if feeds is None:
        feeds = {}
    if params is None:
        params = {}

    # Resolve database path for persistence
    # Sentinel `...` = use default; explicit None = disabled
    if db_path is ...:
        db_path = os.environ.get("HORIZON_DB", f"./{name}.db")

    # Input validation
    if exchange is not None and exchanges is not None:
        raise ValueError("Cannot specify both 'exchange' and 'exchanges'. Use one or the other.")
    if isinstance(pipeline, dict):
        if not pipeline:
            raise ValueError("pipeline dict must not be empty")
        for key, pl in pipeline.items():
            if not isinstance(pl, list) or not pl:
                raise ValueError(f"pipeline['{key}'] must be a non-empty list")
    elif not pipeline:
        raise ValueError("pipeline must contain at least one function")
    if interval <= 0:
        raise ValueError("interval must be positive")

    # Normalize to list of exchanges
    exchange_list: list[Any] = []
    if exchanges is not None:
        exchange_list = list(exchanges)
    elif exchange is not None:
        exchange_list = [exchange]

    # Build risk config
    if isinstance(risk, Risk):
        risk_config = risk.to_config()
    elif isinstance(risk, RiskConfig):
        risk_config = risk
    else:
        risk_config = None

    # Auto-derive API credentials and validate for all exchanges
    for exch in exchange_list:
        if mode == "live" and isinstance(exch, Polymarket):
            if exch.private_key and not exch.api_key:
                try:
                    exch.derive_api_credentials()
                except Exception as e:
                    raise RuntimeError(
                        f"Failed to derive Polymarket API credentials: {e}. "
                        "Provide api_key/api_secret/api_passphrase directly, or "
                        "install eth-account (`pip install eth-account`)."
                    ) from e
            if not exch.api_key and not exch.private_key:
                raise ValueError(
                    "Polymarket requires either api_key or private_key for live trading"
                )
        elif mode == "live" and isinstance(exch, Kalshi):
            if not exch.api_key and not (exch.email and exch.password):
                raise ValueError(
                    "Kalshi requires either api_key or email+password for live trading"
                )

    # Create engine with appropriate exchange backend(s) + persistence
    engine = _create_engine(exchange_list, risk_config, mode, db_path, horizon_key=horizon_key)

    # Register netting pairs for cross-exchange hedging
    if netting_pairs:
        for market_a, market_b in netting_pairs:
            engine.set_netting_pair(market_a, market_b)

    # Recover state from database (before exchange sync)
    if engine.has_persistence():
        recovered = engine.recover_state()
        if recovered > 0:
            logger.info("Recovered %d positions from database", recovered)
        # Detect orphaned orders from previous run
        orphaned = engine.db_open_order_ids()
        if orphaned:
            logger.warning(
                "Found %d potentially orphaned orders from previous run: %s",
                len(orphaned), orphaned[:5],
            )

    # Create market objects and resolve metadata for live trading
    market_objs = []
    for slug in markets:
        m = Market(id=slug, name=slug, slug=slug)
        market_objs.append(m)

    # Expand events into markets and register event groupings
    event_list: list[Event] = events or []
    for event in event_list:
        event_market_ids = [o.market_id for o in event.outcomes]
        engine.register_event(event.id, event_market_ids)
        for m in event.to_markets():
            market_objs.append(m)

    if mode == "live" and market_objs:
        # Resolve markets against all configured exchanges
        _resolve_markets_multi(market_objs, exchange_list)

        # Validate all Polymarket markets have token_id after resolution
        for m in market_objs:
            if m.exchange == "polymarket" and not m.yes_token_id:
                raise RuntimeError(
                    f"Market '{m.id}' was not resolved. Check network and market slug."
                )

    # Start feeds
    _start_feeds(engine, feeds)

    # Sync positions from each exchange on startup (live mode only)
    if mode == "live":
        for exch_name in engine.exchange_names():
            if exch_name == "paper":
                continue
            try:
                count = engine.sync_positions(exchange=exch_name)
                if count > 0:
                    logger.info("Synced %d positions from %s", count, exch_name)
            except Exception as e:
                logger.error("Position sync failed on %s: %s", exch_name, e)
                raise RuntimeError(
                    f"Position sync failed on {exch_name}: {e}. "
                    "Cannot start live trading without accurate position state."
                ) from e

    # Record strategy run start
    engine.start_run(name)

    # Build event lookup for context injection
    _event_by_market: dict[str, Event] = {}
    for event in event_list:
        for outcome in event.outcomes:
            _event_by_market[outcome.market_id] = event

    logger.info("[horizon] Starting strategy '%s'", name)
    logger.info("[horizon] Mode: %s", mode)
    logger.info("[horizon] Exchanges: %s", engine.exchange_names())
    logger.info("[horizon] Markets: %s", [m.id for m in market_objs])
    if event_list:
        logger.info("[horizon] Events: %s", [e.id for e in event_list])
    logger.info("[horizon] Feeds: %s", list(feeds.keys()))
    if isinstance(pipeline, dict):
        for pkey, pfns in pipeline.items():
            logger.info("[horizon] Pipeline[%s]: %s", pkey, [f.__name__ for f in pfns])
    else:
        logger.info("[horizon] Pipeline: %s", [f.__name__ for f in pipeline])
    logger.info("[horizon] Interval: %ss", interval)
    if netting_pairs:
        logger.info("[horizon] Netting pairs: %s", netting_pairs)
    if engine.has_persistence():
        logger.info("[horizon] Database: %s", db_path)

    try:
        if dashboard:
            _run_with_dashboard(name, engine, market_objs, feeds, pipeline, interval, params,
                                event_by_market=_event_by_market)
        else:
            _run_loop(name, engine, market_objs, feeds, pipeline, interval, params,
                      event_by_market=_event_by_market)
    finally:
        # Snapshot positions + end run + cancel orders + stop feeds
        engine.snapshot_positions()
        engine.end_run()
        _shutdown_with_timeout(engine, timeout=5.0)


def _shutdown_with_timeout(engine: Engine, timeout: float = 5.0) -> None:
    """Gracefully shut down: cancel all orders with a timeout to avoid hanging."""
    import threading

    def _cancel():
        try:
            engine.cancel_all()
            logger.info("Canceled all orders on shutdown")
        except Exception as e:
            logger.warning("Failed to cancel orders on shutdown: %s", e)

    cancel_thread = threading.Thread(target=_cancel, daemon=True)
    cancel_thread.start()
    cancel_thread.join(timeout=timeout)
    if cancel_thread.is_alive():
        logger.error(
            "Shutdown cancel_all timed out after %.1fs  - orders may remain open!", timeout
        )
    engine.stop_feeds()


def _create_engine(
    exchange_list: list[Any],
    risk_config: RiskConfig | None,
    mode: str,
    db_path: str | None = None,
    horizon_key: str | None = None,
) -> Engine:
    """Create the engine with appropriate exchange backend(s).

    The first exchange in the list becomes the primary exchange.
    Additional exchanges are added via add_exchange().
    """
    if mode != "live" or not exchange_list:
        # Default to paper
        return Engine(risk_config=risk_config, db_path=db_path, api_key=horizon_key)

    primary = exchange_list[0]

    # Create engine with primary exchange
    if isinstance(primary, Polymarket):
        engine = Engine(
            risk_config=risk_config,
            exchange_type="polymarket",
            exchange_key=primary.api_key,
            exchange_secret=primary.api_secret,
            exchange_passphrase=primary.api_passphrase,
            clob_url=primary.clob_url,
            private_key=primary.private_key,
            db_path=db_path,
            api_key=horizon_key,
        )
    elif isinstance(primary, Kalshi):
        engine = Engine(
            risk_config=risk_config,
            exchange_type="kalshi",
            email=primary.email,
            password=primary.password,
            exchange_key=primary.api_key,
            api_url=primary.api_url,
            db_path=db_path,
            api_key=horizon_key,
        )
    else:
        engine = Engine(risk_config=risk_config, db_path=db_path, api_key=horizon_key)

    # Add secondary exchanges
    for exch in exchange_list[1:]:
        if isinstance(exch, Polymarket):
            engine.add_exchange(
                exchange_type="polymarket",
                exchange_key=exch.api_key,
                exchange_secret=exch.api_secret,
                exchange_passphrase=exch.api_passphrase,
                clob_url=exch.clob_url,
                private_key=exch.private_key,
            )
        elif isinstance(exch, Kalshi):
            engine.add_exchange(
                exchange_type="kalshi",
                email=exch.email,
                password=exch.password,
                exchange_key=exch.api_key,
                api_url=exch.api_url,
            )
        else:
            logger.warning("Unknown exchange type: %s, skipping", type(exch).__name__)

    return engine


def _start_feeds(engine: Engine, feeds: dict[str, Any]) -> None:
    """Start all configured feeds on the engine's feed manager."""
    import json as _json

    for feed_name, feed_config in feeds.items():
        if isinstance(feed_config, BinanceWS):
            engine.start_feed(feed_name, "binance_ws", symbol=feed_config.symbol)
        elif isinstance(feed_config, PolymarketBook):
            engine.start_feed(feed_name, "polymarket_book", symbol=feed_config.market_slug)
        elif isinstance(feed_config, KalshiBook):
            engine.start_feed(feed_name, "kalshi_book", symbol=feed_config.ticker)
        elif isinstance(feed_config, RESTFeed):
            engine.start_feed(
                feed_name, "rest", url=feed_config.url, interval=feed_config.interval
            )
        elif isinstance(feed_config, PredictItFeed):
            cfg = {"market_id": feed_config.market_id, "interval": feed_config.interval}
            if feed_config.contract_id is not None:
                cfg["contract_id"] = feed_config.contract_id
            engine.start_feed(
                feed_name, "predictit", interval=feed_config.interval,
                config_json=_json.dumps(cfg),
            )
        elif isinstance(feed_config, ManifoldFeed):
            engine.start_feed(
                feed_name, "manifold", interval=feed_config.interval,
                config_json=_json.dumps({
                    "slug": feed_config.slug,
                    "interval": feed_config.interval,
                }),
            )
        elif isinstance(feed_config, ESPNFeed):
            cfg = {
                "sport": feed_config.sport,
                "league": feed_config.league,
                "interval": feed_config.interval,
            }
            if feed_config.event_id is not None:
                cfg["event_id"] = feed_config.event_id
            engine.start_feed(
                feed_name, "espn", interval=feed_config.interval,
                config_json=_json.dumps(cfg),
            )
        elif isinstance(feed_config, NWSFeed):
            engine.start_feed(
                feed_name, "nws", interval=feed_config.interval,
                config_json=_json.dumps({
                    "mode": feed_config.mode,
                    "office": feed_config.office,
                    "grid_x": feed_config.grid_x,
                    "grid_y": feed_config.grid_y,
                    "state": feed_config.state,
                    "user_agent": feed_config.user_agent,
                    "interval": feed_config.interval,
                }),
            )
        elif isinstance(feed_config, RESTJsonPathFeed):
            cfg: dict[str, Any] = {
                "url": feed_config.url,
                "interval": feed_config.interval,
            }
            if feed_config.price_path is not None:
                cfg["price_path"] = feed_config.price_path
            if feed_config.bid_path is not None:
                cfg["bid_path"] = feed_config.bid_path
            if feed_config.ask_path is not None:
                cfg["ask_path"] = feed_config.ask_path
            if feed_config.volume_path is not None:
                cfg["volume_path"] = feed_config.volume_path
            engine.start_feed(
                feed_name, "rest_json_path", interval=feed_config.interval,
                config_json=_json.dumps(cfg),
            )
        elif isinstance(feed_config, ChainlinkFeed):
            engine.start_feed(
                feed_name, "chainlink", interval=feed_config.interval,
                config_json=_json.dumps({
                    "contract_address": feed_config.contract_address,
                    "rpc_url": feed_config.rpc_url,
                    "decimals": feed_config.decimals,
                    "interval": feed_config.interval,
                }),
            )
        else:
            logger.warning("Unknown feed config type for '%s': %s", feed_name, type(feed_config).__name__)


def _resolve_markets(markets: list[Market], exchange: Any) -> None:
    """Resolve market metadata (single exchange, backward compat)."""
    if isinstance(exchange, Polymarket):
        _resolve_polymarket_markets(markets, exchange)
    elif isinstance(exchange, Kalshi):
        _resolve_kalshi_markets(markets)


def _resolve_markets_multi(markets: list[Market], exchange_list: list[Any]) -> None:
    """Resolve market metadata against all configured exchanges.

    Markets with exchange already set are resolved against the matching exchange.
    Markets without exchange are resolved against the first matching exchange.
    """
    poly_exchanges = [e for e in exchange_list if isinstance(e, Polymarket)]
    kalshi_exchanges = [e for e in exchange_list if isinstance(e, Kalshi)]

    for market in markets:
        if market.exchange == "polymarket" and poly_exchanges:
            _resolve_polymarket_markets([market], poly_exchanges[0])
        elif market.exchange == "kalshi" and kalshi_exchanges:
            _resolve_kalshi_markets([market])
        elif market.exchange in ("", "paper"):
            # Try Polymarket first, then Kalshi
            if poly_exchanges:
                _resolve_polymarket_markets([market], poly_exchanges[0])
            elif kalshi_exchanges:
                _resolve_kalshi_markets([market])


def _resolve_polymarket_markets(markets: list[Market], exchange: Polymarket) -> None:
    """Fetch Polymarket market metadata from the Gamma API."""
    import requests

    gamma_url = getattr(exchange, "gamma_url", _GAMMA_API_URL)

    for market in markets:
        if market.yes_token_id:
            continue  # Already resolved

        try:
            # Try slug-based lookup first
            resp = requests.get(
                f"{gamma_url}/markets",
                params={"slug": market.slug or market.id},
                timeout=10,
            )
            if not resp.ok:
                logger.warning(
                    "Polymarket market resolution failed for %s: %s %s",
                    market.id, resp.status_code, resp.text[:200],
                )
                continue

            data = resp.json()
            # Response may be a list or a single object
            if isinstance(data, list):
                if not data:
                    logger.warning("Polymarket: no market found for slug '%s'", market.slug)
                    continue
                data = data[0]

            # Extract token IDs from the market or its tokens array
            tokens = data.get("tokens", [])
            for token in tokens:
                outcome = token.get("outcome", "").lower()
                token_id = token.get("token_id")
                if outcome == "yes" and token_id:
                    market.yes_token_id = token_id
                elif outcome == "no" and token_id:
                    market.no_token_id = token_id

            # Fallback: direct fields
            clob_ids = data.get("clobTokenIds") or []
            if isinstance(clob_ids, str):
                try:
                    import json as _json_resolve
                    clob_ids = _json_resolve.loads(clob_ids)
                except (ValueError, TypeError):
                    clob_ids = []
            if not isinstance(clob_ids, list):
                clob_ids = []
            if not market.yes_token_id:
                market.yes_token_id = data.get("yes_token_id") or (str(clob_ids[0]) if len(clob_ids) > 0 else None)
            if not market.no_token_id:
                market.no_token_id = data.get("no_token_id") or (str(clob_ids[1]) if len(clob_ids) > 1 else None)

            market.condition_id = data.get("condition_id") or data.get("conditionId")
            market.neg_risk = bool(data.get("neg_risk") or data.get("negRisk", False))
            market.exchange = "polymarket"

            if market.yes_token_id:
                logger.info(
                    "Resolved Polymarket market '%s': yes_token=%s, neg_risk=%s",
                    market.id, market.yes_token_id[:16] + "...", market.neg_risk,
                )
            else:
                raise RuntimeError(
                    f"Could not resolve token_id for market '{market.id}'. "
                    "Check that the market slug is correct."
                )

        except RuntimeError:
            raise
        except Exception as e:
            logger.warning("Polymarket market resolution error for %s: %s", market.id, e)


def _resolve_kalshi_markets(markets: list[Market]) -> None:
    """Set ticker for Kalshi markets (ticker = market ID/slug)."""
    for market in markets:
        if not market.ticker:
            market.ticker = market.id.upper()
        market.exchange = "kalshi"
        logger.info("Resolved Kalshi market '%s': ticker=%s", market.id, market.ticker)


def _run_loop(
    name: str,
    engine: Engine,
    markets: list[Market],
    feeds: dict[str, Any],
    pipeline: list[Callable] | dict[str, list[Callable]],
    interval: float,
    params: dict[str, Any],
    event_by_market: dict[str, Event] | None = None,
) -> None:
    """Simple synchronous run loop (no dashboard)."""
    running = True

    def handle_signal(sig, frame):
        nonlocal running
        logger.info("Shutting down...")
        running = False

    signal.signal(signal.SIGINT, handle_signal)
    signal.signal(signal.SIGTERM, handle_signal)

    cycle = 0
    is_live = any(name != "paper" for name in engine.exchange_names())

    # Task 3: Wire drawdown  - set daily baseline on start
    status = engine.status()
    baseline = status.total_realized_pnl + status.total_unrealized_pnl
    engine.set_daily_baseline(baseline)
    logger.info("[horizon] Set daily baseline to %.2f", baseline)

    # Wire market expiry timestamps for lifecycle management
    for market in markets:
        if hasattr(market, "expiry") and market.expiry:
            try:
                expiry_ts = datetime.fromisoformat(str(market.expiry)).timestamp()
            except (ValueError, TypeError):
                expiry_ts = float(market.expiry)
            engine.set_market_expiry(market.id, expiry_ts)

    # Feed staleness threshold (seconds)
    feed_stale_threshold = params.get("feed_stale_threshold", 30.0)

    while running:
        _cycle_start = time.monotonic()
        cycle += 1

        # For live exchanges, poll fills each cycle
        if is_live:
            engine.poll_fills()

        # Task 3: Update daily P&L every tick for drawdown tracking
        # Cache status for reuse (avoids multiple PyO3 crossings per cycle)
        cycle_status = engine.status()
        current_pnl = cycle_status.total_realized_pnl + cycle_status.total_unrealized_pnl
        engine.update_daily_pnl(current_pnl)

        _event_lookup = event_by_market or {}
        for market in markets:
            try:
                event = _event_lookup.get(market.id)
                ctx = _build_context(engine, market, feeds, params,
                                     cached_status=cycle_status, event=event)

                # Task 13: Feed staleness check  - skip quoting if any feed is stale
                stale_feeds = [
                    fname for fname, fdata in ctx.feeds.items()
                    if fdata.is_stale(feed_stale_threshold)
                ]
                if stale_feeds and is_live:
                    logger.warning(
                        "Skipping quotes for %s: stale feeds %s",
                        market.id, stale_feeds,
                    )
                    continue

                # Resolve per-market pipeline (multi-strategy support)
                if isinstance(pipeline, dict):
                    market_pipeline = pipeline.get(market.id, pipeline.get("*"))
                    if market_pipeline is None:
                        continue
                else:
                    market_pipeline = pipeline

                result = _run_pipeline(market_pipeline, ctx)

                if result is not None:
                    _process_result(engine, market, result, ctx)
            except Exception as e:
                logger.error("Pipeline error for market %s: %s", market.id, e)

        # Feed guard: auto kill-switch on total feed failure
        feed_health = params.get("feed_health")
        if feed_health is not None:
            if feed_health.should_kill:
                logger.critical(
                    "All feeds stale for %.1fs  - activating kill switch",
                    feed_health.all_stale_duration_secs,
                )
                engine.activate_kill_switch("all_feeds_stale")
            elif feed_health.should_recover:
                logger.info("Feeds recovered  - deactivating kill switch")
                engine.deactivate_kill_switch()

        # Lifecycle check: cancel orders for markets approaching expiry (every 10 cycles)
        if cycle % 10 == 0:
            acted = engine.check_lifecycle()
            for mid in acted:
                logger.info("Lifecycle: canceled orders for expiring market %s", mid)

        # Record feed ticks to DB for historical analysis (every 5 cycles)
        if cycle % 5 == 0 and engine.has_persistence():
            for feed_name in feeds:
                for market in markets:
                    engine.record_tick(feed_name, market.id)

        # Evict stale terminal orders periodically (every 100 cycles)
        if cycle % 100 == 0:
            engine.evict_stale_orders(300.0)

        # Purge old tick history periodically (every 1000 cycles, keep 24h)
        if cycle % 1000 == 0 and engine.has_persistence():
            engine.purge_old_ticks(86400.0)

        # Snapshot positions to DB periodically (every 50 cycles)
        if cycle % 50 == 0:
            engine.snapshot_positions()

        # Log status periodically (use fresh status for accuracy after pipeline runs)
        if cycle % 10 == 0:
            log_status = engine.status()
            logger.info(
                "[%s] cycle=%d orders=%d positions=%d pnl=%.2f",
                name, cycle, log_status.open_orders, log_status.active_positions,
                log_status.total_pnl(),
            )
            cycle_status = log_status  # Refresh cached status

        # Sleep only the remaining time after cycle work
        elapsed = time.monotonic() - _cycle_start
        sleep_time = max(0.0, interval - elapsed)
        if sleep_time > 0:
            time.sleep(sleep_time)

    logger.info("Strategy '%s' stopped.", name)


def _run_with_dashboard(
    name: str,
    engine: Engine,
    markets: list[Market],
    feeds: dict[str, Any],
    pipeline: list[Callable] | dict[str, list[Callable]],
    interval: float,
    params: dict[str, Any],
    event_by_market: dict[str, Any] | None = None,
) -> None:
    """Run with TUI dashboard."""
    try:
        from horizon.dashboard import create_app

        app = create_app(name, engine, markets, feeds, pipeline, interval, params,
                         event_by_market=event_by_market)
        app.run()
    except ImportError:
        logger.warning("Dashboard requires 'textual'. Falling back to console mode.")
        _run_loop(name, engine, markets, feeds, pipeline, interval, params,
                  event_by_market=event_by_market)


def _build_context(
    engine: Engine,
    market: Market,
    feeds: dict[str, Any],
    params: dict[str, Any],
    cached_status: Any = None,
    event: Event | None = None,
) -> Context:
    """Build the context object for pipeline functions.

    Reads live feed snapshots from the engine's feed manager.
    Accepts cached_status to avoid redundant PyO3 boundary crossings.
    """
    feed_data = {}
    for feed_name in feeds:
        snapshot = engine.feed_snapshot(feed_name)
        if snapshot is not None:
            feed_data[feed_name] = FeedData(
                price=snapshot.price,
                timestamp=snapshot.timestamp,
                bid=snapshot.bid,
                ask=snapshot.ask,
                volume_24h=snapshot.volume_24h,
                source=snapshot.source,
                last_trade_size=getattr(snapshot, "last_trade_size", 0.0),
                last_trade_is_buy=getattr(snapshot, "last_trade_is_buy", False),
            )
        else:
            feed_data[feed_name] = FeedData()

    # Shallow copy params to avoid per-market mutation leaking between markets
    ctx_params = dict(params)

    # Inject recent fills for execution tracker
    ctx_params["_recent_fills"] = engine.recent_fills()

    # Inject fills grouped by market for adaptive_spread
    recent = ctx_params["_recent_fills"]
    fills_this_cycle: dict[str, list] = {}
    for fill in recent:
        mid = getattr(fill, "market_id", "")
        fills_this_cycle.setdefault(mid, []).append(fill)
    ctx_params["fills_this_cycle"] = fills_this_cycle

    # Inject orderbook snapshots for L2 pipeline components
    orderbooks = {}
    for feed_name in feeds:
        ob = engine.orderbook_snapshot(feed_name)
        if ob is not None:
            orderbooks[feed_name] = ob
    if orderbooks:
        ctx_params["orderbooks"] = orderbooks

    # Inject engine reference for arb_scanner and advanced pipeline functions
    ctx_params["engine"] = engine

    return Context(
        feeds=feed_data,
        inventory=InventorySnapshot(
            positions=engine.positions_for_market(market.id) if market else engine.positions()
        ),
        market=market,
        event=event,
        status=cached_status if cached_status is not None else engine.status(),
        params=ctx_params,
    )


def _get_n_params(fn: Callable) -> int:
    """Get parameter count for a callable, caching on the function object."""
    n = getattr(fn, "_hz_nparams", None)
    if n is not None:
        return n
    n = len(inspect.signature(fn).parameters)
    try:
        fn._hz_nparams = n  # type: ignore[attr-defined]
    except (AttributeError, TypeError):
        pass  # Some callables don't support attribute assignment
    return n


def _run_pipeline(pipeline: list[Callable], ctx: Context) -> Any:
    """Run the pipeline functions in sequence.

    Each function receives the context plus the outputs of all previous functions.
    The final function's return value is the result.
    """
    prev_outputs: list[Any] = []

    for fn in pipeline:
        n_params = _get_n_params(fn)

        if n_params == 1:
            result = fn(ctx)
        elif n_params == 2:
            result = fn(ctx, prev_outputs[-1] if prev_outputs else None)
        elif n_params == 3 and len(prev_outputs) >= 2:
            result = fn(ctx, prev_outputs[-2], prev_outputs[-1])
        else:
            if prev_outputs:
                args = [ctx] + prev_outputs[-(n_params - 1):]
            else:
                args = [ctx] + [None] * (n_params - 1)
            result = fn(*args[:n_params])

        prev_outputs.append(result)

    return prev_outputs[-1] if prev_outputs else None


def _process_result(
    engine: Engine, market: Market, result: Any, ctx: Context | None = None,
) -> None:
    """Process the pipeline result  - cancel existing orders, then submit new quotes.

    Routes orders to the correct exchange based on market.exchange field.
    """
    if isinstance(result, Quote):
        result = [result]
    elif not isinstance(result, list):
        logger.warning(
            "Pipeline returned unrecognized type %s for %s", type(result).__name__, market.id
        )
        return
    if isinstance(result, list):
        # Cancel existing orders for this market before placing new ones
        engine.cancel_market(market.id)

        # Determine target exchange from market metadata (None = primary)
        target_exchange = market.exchange if market.exchange not in ("", "paper") else None

        # Use feed mid price for paper tick, not strategy quotes.
        # Priority 1: feed matching the market ID (for multi-feed setups)
        # Priority 2: first available feed with a valid price
        feed_mid = None
        if ctx is not None:
            market_id = market.id if market else None
            fdata = ctx.feeds.get(market_id) if market_id else None
            if fdata is None or fdata.price <= 0:
                fdata = next((f for f in ctx.feeds.values() if f.price > 0), None)
            if fdata and fdata.price > 0:
                if fdata.bid > 0 and fdata.ask > 0:
                    feed_mid = (fdata.bid + fdata.ask) / 2.0
                else:
                    feed_mid = fdata.price

        # Tick the paper exchange with the feed mid price (preferred) or quote mid (fallback)
        if feed_mid is not None:
            tick_price = feed_mid
        else:
            mid_prices = []
            for item in result:
                if isinstance(item, Quote):
                    mid_prices.append(item.mid())
            tick_price = sum(mid_prices) / len(mid_prices) if mid_prices else None

        if tick_price is not None:
            engine.tick(market.id, tick_price, exchange=target_exchange)
            engine.update_mark_price(market.id, Side.Yes, tick_price)
            engine.update_mark_price(market.id, Side.No, 1.0 - tick_price)

        # Check contingent order triggers (stop-loss / take-profit)
        if tick_price is not None:
            triggered = engine.check_contingent_triggers(market.id, tick_price)
            if triggered > 0:
                logger.info("Triggered %d contingent orders for %s", triggered, market.id)

        # Resolve token_id from Market for on-chain exchanges
        token_id = market.token_id(Side.Yes)
        neg_risk = market.neg_risk

        # Submit new quotes, routed to the correct exchange
        for item in result:
            if isinstance(item, Quote):
                try:
                    engine.submit_quotes(
                        market.id, [item], Side.Yes,
                        token_id=token_id, neg_risk=neg_risk,
                        exchange=target_exchange,
                    )
                except RuntimeError as e:
                    err_msg = str(e)
                    if "risk violation" in err_msg.lower():
                        logger.debug("Risk rejection for %s: %s", market.id, err_msg)
                    else:
                        logger.warning(
                            "Order submission failed for %s: %s", market.id, err_msg
                        )
