//! Two-runner order book with implied matching.
//!
//! For 2-runner markets, we can automatically cross-match orders without
//! needing an external hedging service. This is because:
//!   BACK A @ odds `a` ≡ LAY B @ odds `a/(a-1)`
//!
//! This book handles the implied matching internally.

use super::common::*;
use super::error::{BookError, RequestedBookState};
use super::protocol::{
    command::{Command, CommandKind, MarketState, Persistence, Side, TimeInForce},
    reject::RejectReason,
};
use crate::book::common::{formulas, types::BookOrderInfo};
use crate::types::*;
use chrono::Utc;
use std::collections::{BTreeSet, HashMap};
use tracing::error;

use super::common::fast::{OrderKey, OrderStore, RunnerBook};
use serde::{Deserialize, Serialize};

type TickIndex = u16;
type RunnerIdx = usize;
type TradePairKey = (OrderId, OrderId);
type RunnerLevelKey = (RunnerIdx, Side, TickIndex);
type RestingLevelOrder = (DateTime, OrderId, OrderKey, Money);
type RestingOrdersByLevel = std::collections::HashMap<RunnerLevelKey, Vec<RestingLevelOrder>>;

/// Scratch buffers for command processing.
///
/// ## Memory Reuse Pattern
///
/// The `Scratch` struct provides pre-allocated buffers that are reused across
/// command invocations to avoid repeated heap allocations on the hot path.
///
/// ### Usage Pattern
/// ```ignore
/// // Take ownership of scratch at start of command
/// let mut scratch = std::mem::take(&mut self.scratch);
///
/// // Use scratch buffers during processing
/// scratch.events.clear();
/// scratch.fills.clear();
/// // ... process command ...
///
/// // Return scratch to self at end (even on error paths)
/// self.scratch = scratch;
/// ```
///
/// ### Why This Pattern?
/// - **Avoids allocation on hot path**: Vectors grow to accommodate peak load,
///   then reuse that capacity for subsequent commands.
/// - **Deterministic memory**: No allocator pressure during steady-state operation.
/// - **Thread-local**: Each book owns its scratch, no synchronization needed.
///
/// ### Buffers
/// - `events`: Collects `BookEventEnvelope`s to emit
/// - `fills`: Planned order fills before committing
/// - `matchable`: Orders eligible for matching at current price
/// - `maker_matched_delta`: Tracks cumulative fills per maker during matching
#[derive(Debug, Default)]
struct Scratch {
    maker_matched_delta: std::collections::HashMap<OrderId, i64>,
    matchable: Vec<MatchableOrder>,
    fills: Vec<PlannedFill>,
    events: EventVec,
}

#[derive(Debug, Clone)]
struct PlannedFill {
    maker_order_id: OrderId,
    maker_account_id: AccountId,
    maker_correlation_id: Option<CorrelationId>,
    maker_runner_id: RunnerId,
    maker_side: Side,
    maker_order_price: OddsX10000,
    trade_price: OddsX10000,
    maker_fill_amount: Money,
    taker_fill_amount: Money,
    maker_remaining_after: Money,
    taker_remaining_after: Money,
}

#[derive(Debug, Clone)]
struct TradeMatchView {
    order_id: OrderId,
    role: TradeRole,
    runner_id: RunnerId,
    counter_party: OrderId,
    remaining_stake: Money,
    matched_delta: Money,
}

#[derive(Debug, Clone, Copy)]
struct PendingTradeView {
    order_id: OrderId,
    role: TradeRole,
    runner_id: RunnerId,
    matched_delta: Money,
}

#[derive(Debug, Clone)]
struct MatchRequest {
    ts: DateTime,
    taker_order_id: OrderId,
    taker_account_id: AccountId,
    taker_correlation_id: Option<CorrelationId>,
    taker_side: Side,
    taker_runner_id: RunnerId,
    taker_price: OddsX10000,
    taker_stake: Money,
    taker_runner_idx: RunnerIdx,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct TwoRunnerBookSnapshot {
    market_id: MarketId,
    market_name: String,
    market_kind: MarketKind,
    state: BookMarketState,
    market_phase: MarketPhase,
    next_order_id: u64,
    /// Exactly 2 runners stored as [runner_a, runner_b]
    runner_ids: [RunnerId; 2],
    runner_labels: [String; 2],
    removed_runners: BTreeSet<RunnerId>,
    batch_process: Option<BatchProcessState>,
    orders: OrderStore,
    runner_matched_volume: [Money; 2],
}

/// Two-runner order book with implied matching.
///
/// For 2-runner markets (e.g., tennis, head-to-head), matching is done
/// both directly (BACK A vs LAY A) and through implied odds (BACK A vs BACK B
/// at derived prices).
#[derive(Debug)]
pub struct TwoRunnerBook {
    market_id: MarketId,
    pub(crate) market_name: String,
    market_kind: MarketKind,
    state: BookMarketState,
    market_phase: MarketPhase,
    next_order_id: u64,
    /// Exactly 2 runners stored as [runner_a, runner_b]
    runner_ids: [RunnerId; 2],
    runner_labels: [String; 2],
    removed_runners: BTreeSet<RunnerId>,
    runner_books: [RunnerBook; 2],
    batch_process: Option<BatchProcessState>,
    orders: OrderStore,
    active_order_count_cache: usize,
    runner_matched_volume: [Money; 2],
    pending_trade_views: HashMap<TradePairKey, PendingTradeView>,

    scratch: Scratch,
}

impl Serialize for TwoRunnerBook {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        TwoRunnerBookSnapshot {
            market_id: self.market_id,
            market_name: self.market_name.clone(),
            market_kind: self.market_kind,
            state: self.state,
            market_phase: self.market_phase,
            next_order_id: self.next_order_id,
            runner_ids: self.runner_ids,
            runner_labels: self.runner_labels.clone(),
            removed_runners: self.removed_runners.clone(),
            batch_process: self.batch_process.clone(),
            orders: self.orders.clone(),
            runner_matched_volume: self.runner_matched_volume,
        }
        .serialize(serializer)
    }
}

impl<'de> Deserialize<'de> for TwoRunnerBook {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let snap = TwoRunnerBookSnapshot::deserialize(deserializer)?;
        let mut book = Self {
            market_id: snap.market_id,
            market_name: snap.market_name,
            market_kind: snap.market_kind,
            state: snap.state,
            market_phase: snap.market_phase,
            next_order_id: snap.next_order_id,
            runner_ids: snap.runner_ids,
            runner_labels: snap.runner_labels,
            removed_runners: snap.removed_runners,
            runner_books: [RunnerBook::new(), RunnerBook::new()],
            batch_process: snap.batch_process,
            orders: snap.orders,
            active_order_count_cache: 0,
            runner_matched_volume: snap.runner_matched_volume,
            pending_trade_views: HashMap::new(),
            scratch: Scratch::default(),
        };
        // Deserialization rebuilds runner ladders from the stored order store.
        // This is O(n log n) due to per-level sorting, but bounded by expected snapshot sizes.
        book.rebuild_ladders();
        if let Some((max_oid, _, _)) = book.orders.iter_keys_sorted().last() {
            book.next_order_id = book.next_order_id.max(max_oid.0.saturating_add(1));
        }
        Ok(book)
    }
}

/// Represents a matchable order with its effective price for priority ordering.
#[derive(Debug, Clone)]
struct MatchableOrder {
    order_id: OrderId,
    /// The effective price for matching priority.
    /// For direct matches: the actual order price.
    /// For implied matches: the derived price.
    effective_price: OddsX10000,
    /// Used for FIFO within the same effective price.
    created_at: DateTime,
}

impl Clone for TwoRunnerBook {
    fn clone(&self) -> Self {
        Self {
            market_id: self.market_id,
            market_name: self.market_name.clone(),
            market_kind: self.market_kind,
            state: self.state,
            market_phase: self.market_phase,
            next_order_id: self.next_order_id,
            runner_ids: self.runner_ids,
            runner_labels: self.runner_labels.clone(),
            removed_runners: self.removed_runners.clone(),
            runner_books: self.runner_books.clone(),
            batch_process: self.batch_process.clone(),
            orders: self.orders.clone(),
            active_order_count_cache: self.active_order_count_cache,
            runner_matched_volume: self.runner_matched_volume,
            pending_trade_views: HashMap::new(),
            scratch: Scratch::default(),
        }
    }
}

impl TwoRunnerBook {
    // Close process batching constants live in `common::close_process`.
    /// Create a new two-runner book.
    ///
    /// # Panics
    /// Panics if the two runner IDs are the same.
    pub fn new(
        market_id: MarketId,
        market_kind: MarketKind,
        market_phase: MarketPhase,
        runner_a: RunnerId,
        runner_b: RunnerId,
    ) -> Self {
        Self::new_with_capacity(
            market_id,
            market_kind,
            market_phase,
            runner_a,
            runner_b,
            20_000,
        )
    }

    pub fn new_with_capacity(
        market_id: MarketId,
        market_kind: MarketKind,
        market_phase: MarketPhase,
        runner_a: RunnerId,
        runner_b: RunnerId,
        order_store_capacity: usize,
    ) -> Self {
        assert!(
            market_kind.supports_initial_phase(market_phase),
            "market kind {market_kind:?} does not support initial phase {market_phase:?}"
        );
        assert_ne!(
            runner_a, runner_b,
            "Two-runner book requires distinct runners"
        );
        Self {
            market_id,
            market_name: String::new(),
            market_kind,
            market_phase,
            state: BookMarketState::Open,
            next_order_id: 1,
            runner_ids: [runner_a, runner_b],
            runner_labels: [runner_a.to_string(), runner_b.to_string()],
            removed_runners: BTreeSet::new(),
            runner_books: [RunnerBook::new(), RunnerBook::new()],
            batch_process: None,
            orders: OrderStore::with_capacity(order_store_capacity),
            active_order_count_cache: 0,
            runner_matched_volume: [Money::zero(), Money::zero()],
            pending_trade_views: HashMap::new(),
            scratch: Scratch::default(),
        }
    }

    // ========================================================================
    // Odds Derivation
    // ========================================================================

    /// Get the runner index (0 or 1) for a runner ID.
    fn runner_index(&self, runner_id: RunnerId) -> Option<usize> {
        if runner_id == self.runner_ids[0] {
            Some(0)
        } else if runner_id == self.runner_ids[1] {
            Some(1)
        } else {
            None
        }
    }

    /// Get the opposite runner index.
    fn opposite_index(idx: usize) -> usize {
        1 - idx
    }

    pub(crate) fn set_runner_labels(&mut self, runner_ids: &[RunnerId], runner_labels: &[String]) {
        assert_eq!(
            runner_ids.len(),
            2,
            "two-runner market requires 2 runner ids"
        );
        assert_eq!(
            runner_labels.len(),
            2,
            "two-runner market requires 2 runner labels"
        );
        for (runner_id, runner_label) in runner_ids.iter().zip(runner_labels.iter()) {
            if *runner_id == self.runner_ids[0] {
                self.runner_labels[0] = runner_label.clone();
            } else if *runner_id == self.runner_ids[1] {
                self.runner_labels[1] = runner_label.clone();
            } else {
                panic!("unknown runner id {:?} for two-runner market", runner_id);
            }
        }
    }

    pub fn runner_label(&self, runner_id: RunnerId) -> &str {
        if runner_id == self.runner_ids[0] {
            &self.runner_labels[0]
        } else if runner_id == self.runner_ids[1] {
            &self.runner_labels[1]
        } else {
            panic!("unknown runner id {:?} for two-runner market", runner_id);
        }
    }

    fn rebuild_ladders(&mut self) {
        self.runner_books = [RunnerBook::new(), RunnerBook::new()];
        self.active_order_count_cache = 0;

        let mut per_level: RestingOrdersByLevel = std::collections::HashMap::new();

        for (oid, key, order) in self.orders.iter_keys_sorted() {
            let is_live = matches!(
                order.info.state,
                BookOrderState::ExecutableUnmatched | BookOrderState::ExecutablePartiallyMatched
            );
            if !is_live {
                continue;
            }
            self.active_order_count_cache = self.active_order_count_cache.saturating_add(1);
            if self.removed_runners.contains(&order.runner_id) {
                continue;
            }
            let remaining = order.remaining();
            if remaining.0 <= 0 {
                continue;
            }
            let Some(idx) = self.runner_index(order.runner_id) else {
                continue;
            };
            let tick: TickIndex = self.orders.stored_tick(key) as u16;
            per_level
                .entry((idx, order.info.side, tick))
                .or_default()
                .push((order.info.created_at, oid, key, remaining));
        }

        for ((idx, _, _), mut items) in per_level {
            items.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
            for (_, _, key, remaining) in items {
                self.runner_books[idx].insert_tail(&mut self.orders, key, remaining);
            }
        }
    }

    // ========================================================================
    // Accessors
    // ========================================================================

    pub fn market_id(&self) -> MarketId {
        self.market_id
    }

    pub fn market_kind(&self) -> MarketKind {
        self.market_kind
    }

    pub fn market_state(&self) -> BookMarketState {
        self.state
    }

    pub fn market_phase(&self) -> MarketPhase {
        self.market_phase
    }

    pub fn is_halted(&self) -> bool {
        self.state.is_halted()
    }

    pub fn get_order(&self, order_id: OrderId) -> Option<&BookOrder> {
        self.orders.get(&order_id)
    }

    pub fn is_resting(&self, order_id: OrderId) -> bool {
        self.orders.is_in_level(&order_id)
    }

    pub fn active_order_count(&self) -> usize {
        self.active_order_count_cache
    }

    fn select_cancelled_chunk(
        &self,
        cursor_after: Option<OrderId>,
        max_orders: usize,
        should_cancel: impl FnMut(&BookOrder) -> bool,
    ) -> CancelledOrdersChunk {
        collect_cancelled_orders_chunk(
            self.orders.iter_sorted_from(cursor_after),
            max_orders,
            should_cancel,
            |order| &order.info,
        )
    }

    pub fn close_process_state(&self) -> Option<CloseProcessState> {
        match self.batch_process.as_ref() {
            Some(BatchProcessState::Close(s)) => Some(*s),
            _ => None,
        }
    }

    pub fn cancel_process_state(&self) -> Option<&CancelProcessState> {
        match self.batch_process.as_ref() {
            Some(BatchProcessState::Cancel(s)) => Some(s),
            _ => None,
        }
    }

    pub fn batch_process_state(&self) -> Option<&BatchProcessState> {
        self.batch_process.as_ref()
    }

    pub fn runners(&self) -> impl Iterator<Item = RunnerId> + '_ {
        self.runner_ids.iter().copied()
    }

    fn has_active_batch_process(&self) -> bool {
        self.batch_process.is_some()
    }

    // ========================================================================
    // Market Depth Queries
    // ========================================================================

    pub fn runner_prices(&self, runner_id: RunnerId, depth: usize) -> RunnerPrices {
        let mut result = RunnerPrices {
            runner_id,
            available_to_back: Vec::with_capacity(depth),
            available_to_lay: Vec::with_capacity(depth),
        };

        if depth == 0 {
            return result;
        }

        let Some(idx) = self.runner_index(runner_id) else {
            return result;
        };
        let opposite_idx = Self::opposite_index(idx);
        let runner_book = &self.runner_books[idx];
        let opposite_book = &self.runner_books[opposite_idx];

        // Available to back = direct LAY on this runner + implied BACK on the opposite runner.
        // Higher odds are better for a backer, so both streams are iterated in
        // descending effective price order.
        let mut direct_back_tick = runner_book.best_tick_desc(Side::No);
        let mut implied_back_tick = opposite_book.best_tick_asc(Side::Yes);
        let mut direct_back: Option<(OddsX10000, Money)> = None;
        let mut implied_back: Option<(OddsX10000, Money)> = None;

        while result.available_to_back.len() < depth {
            if direct_back.is_none() {
                while let Some(t) = direct_back_tick {
                    let px = OddsX10000(crate::types::odds::TICK_LADDER[t]);
                    let size = runner_book.level_total_remaining(Side::No, t);
                    direct_back_tick = if t == 0 {
                        None
                    } else {
                        runner_book.next_tick_desc_from(Side::No, t - 1)
                    };
                    if size.0 > 0 {
                        direct_back = Some((px, size));
                        break;
                    }
                }
            }

            if implied_back.is_none() {
                while let Some(t) = implied_back_tick {
                    let maker_px = OddsX10000(crate::types::odds::TICK_LADDER[t]);
                    let maker_size = opposite_book.level_total_remaining(Side::Yes, t);
                    implied_back_tick = opposite_book.next_tick_asc_from(Side::Yes, t + 1);
                    if maker_size.0 > 0 {
                        let implied_px = formulas::back_to_lay_odds(maker_px);
                        let taker_cap =
                            formulas::implied_taker_capacity_from_maker(maker_size, implied_px);
                        if taker_cap.0 <= 0 {
                            continue;
                        }
                        implied_back = Some((implied_px, taker_cap));
                        break;
                    }
                }
            }

            let next = match (direct_back, implied_back) {
                (Some(d), Some(i)) => {
                    if d.0.0 >= i.0.0 {
                        direct_back = None;
                        d
                    } else {
                        implied_back = None;
                        i
                    }
                }
                (Some(d), None) => {
                    direct_back = None;
                    d
                }
                (None, Some(i)) => {
                    implied_back = None;
                    i
                }
                (None, None) => break,
            };

            if let Some(last) = result.available_to_back.last_mut()
                && last.price == next.0
            {
                last.size = Money(last.size.0 + next.1.0);
                continue;
            }
            result.available_to_back.push(PriceSize {
                price: next.0,
                size: next.1,
            });
        }

        // Available to lay = direct BACK on this runner + implied LAY on the opposite runner.
        // Lower odds are better for a layer, so both streams are iterated in
        // ascending effective price order.
        let mut direct_lay_tick = runner_book.best_tick_asc(Side::Yes);
        let mut implied_lay_tick = opposite_book.best_tick_desc(Side::No);
        let mut direct_lay: Option<(OddsX10000, Money)> = None;
        let mut implied_lay: Option<(OddsX10000, Money)> = None;

        while result.available_to_lay.len() < depth {
            if direct_lay.is_none() {
                while let Some(t) = direct_lay_tick {
                    let px = OddsX10000(crate::types::odds::TICK_LADDER[t]);
                    let size = runner_book.level_total_remaining(Side::Yes, t);
                    direct_lay_tick = runner_book.next_tick_asc_from(Side::Yes, t + 1);
                    if size.0 > 0 {
                        direct_lay = Some((px, size));
                        break;
                    }
                }
            }

            if implied_lay.is_none() {
                while let Some(t) = implied_lay_tick {
                    let maker_px = OddsX10000(crate::types::odds::TICK_LADDER[t]);
                    let maker_size = opposite_book.level_total_remaining(Side::No, t);
                    implied_lay_tick = if t == 0 {
                        None
                    } else {
                        opposite_book.next_tick_desc_from(Side::No, t - 1)
                    };
                    if maker_size.0 > 0 {
                        let implied_px = formulas::lay_to_back_odds(maker_px);
                        let taker_cap =
                            formulas::implied_taker_capacity_from_maker(maker_size, implied_px);
                        if taker_cap.0 <= 0 {
                            continue;
                        }
                        implied_lay = Some((implied_px, taker_cap));
                        break;
                    }
                }
            }

            let next = match (direct_lay, implied_lay) {
                (Some(d), Some(i)) => {
                    if d.0.0 <= i.0.0 {
                        direct_lay = None;
                        d
                    } else {
                        implied_lay = None;
                        i
                    }
                }
                (Some(d), None) => {
                    direct_lay = None;
                    d
                }
                (None, Some(i)) => {
                    implied_lay = None;
                    i
                }
                (None, None) => break,
            };

            if let Some(last) = result.available_to_lay.last_mut()
                && last.price == next.0
            {
                last.size = Money(last.size.0 + next.1.0);
                continue;
            }
            result.available_to_lay.push(PriceSize {
                price: next.0,
                size: next.1,
            });
        }

        result
    }

    pub fn best_back_price(&self, runner_id: RunnerId) -> Option<PriceSize> {
        let prices = self.runner_prices(runner_id, 1);
        prices.available_to_back.into_iter().next()
    }

    pub fn best_lay_price(&self, runner_id: RunnerId) -> Option<PriceSize> {
        let prices = self.runner_prices(runner_id, 1);
        prices.available_to_lay.into_iter().next()
    }

    pub fn runner_matched_volume(&self, runner_id: RunnerId) -> Money {
        self.runner_index(runner_id)
            .map(|idx| self.runner_matched_volume[idx])
            .unwrap_or(Money::zero())
    }

    pub fn total_matched(&self) -> Money {
        Money(self.runner_matched_volume[0].0 + self.runner_matched_volume[1].0)
    }

    // ========================================================================
    // Command Processing
    // ========================================================================

    pub(crate) fn handle_command(
        &mut self,
        cmd: &Command,
    ) -> Result<(EventVec, CommandResponse), BookError> {
        let correlation_id = cmd.correlation_id.clone();
        let err_correlation_id = correlation_id.clone();
        let err = |reason| BookError::new(err_correlation_id.clone(), reason);
        let current_state = self.state;
        let current_phase = self.market_phase;
        let state_err = |reason, requested_state| {
            BookError::state_error(
                err_correlation_id.clone(),
                reason,
                current_state,
                current_phase,
                requested_state,
            )
        };
        let ts = Utc::now();
        let market_id = cmd.market_id;
        if market_id != self.market_id {
            return Err(err(RejectReason::MarketIdMismatch));
        }

        match &cmd.kind {
            CommandKind::HaltMarket { reason, .. } => {
                let events = self
                    .cmd_halt_market(ts, *reason)
                    .map_err(|reason| state_err(reason, RequestedBookState::Halted))?;
                return Ok((
                    events,
                    CommandResponse {
                        correlation_id: correlation_id.clone(),
                        kind: None,
                    },
                ));
            }
            CommandKind::ResumeMarket => {
                let events = self.cmd_resume_market(ts).map_err(&err)?;
                return Ok((
                    events,
                    CommandResponse {
                        correlation_id: correlation_id.clone(),
                        kind: None,
                    },
                ));
            }
            _ => {}
        }

        if self.has_active_batch_process()
            && !matches!(
                &cmd.kind,
                CommandKind::ContinueCloseMarket
                    | CommandKind::ContinueBatchCancelOrders
                    | CommandKind::ContinueLapseOrders
                    | CommandKind::ContinueVoidOrders
            )
        {
            return Err(err(RejectReason::MarketBatchCancelling));
        }

        if self.state.is_halted()
            && !matches!(
                &cmd.kind,
                CommandKind::BatchCancelOrders { .. } | CommandKind::ContinueBatchCancelOrders
            )
        {
            return Err(err(RejectReason::MarketHalted));
        }

        let mut scratch = std::mem::take(&mut self.scratch);
        let result = (|| -> Result<(EventVec, CommandResponse), BookError> {
            let (events, kind) = match &cmd.kind {
                CommandKind::CreateMarket { .. } => {
                    return Err(err(RejectReason::InternalError));
                }
                CommandKind::SetMarketState { state, .. } => {
                    let reason = "SET_MARKET_STATE";
                    match state {
                        MarketState::Open => {
                            if self.state == BookMarketState::Suspended {
                                self.cmd_unsuspend(&mut scratch, ts, reason).map_err(&err)?
                            } else {
                                self.cmd_open_market(&mut scratch, ts, reason).map_err(
                                    |reason| state_err(reason, RequestedBookState::Market(*state)),
                                )?
                            }
                        }
                        MarketState::Suspended => self
                            .cmd_suspend(&mut scratch, ts, reason)
                            .map_err(|reason| {
                                state_err(reason, RequestedBookState::Market(*state))
                            })?,
                        MarketState::Closed => self
                            .cmd_close_market(ts, crate::config::DEFAULT_SET_MARKET_STATE_BATCH)
                            .map_err(|reason| {
                                state_err(reason, RequestedBookState::Market(*state))
                            })?,
                    }
                }
                CommandKind::SetMarketPhase { phase } => self
                    .cmd_set_market_phase(&mut scratch, ts, *phase, "SET_MARKET_PHASE")
                    .map_err(|reason| state_err(reason, RequestedBookState::Phase(*phase)))?,

                CommandKind::CloseMarket {
                    batch_max_events, ..
                } => self
                    .cmd_close_market(ts, *batch_max_events)
                    .map_err(|reason| {
                        state_err(reason, RequestedBookState::Market(MarketState::Closed))
                    })?,

                CommandKind::ContinueCloseMarket => {
                    self.cmd_continue_close_market(ts).map_err(&err)?
                }

                CommandKind::BatchCancelOrders {
                    from_created_at_inclusive,
                    to_created_at_inclusive,
                    account_id,
                    runner_id,
                    batch_max_events,
                    reason,
                } => self
                    .cmd_batch_cancel_orders(
                        &mut scratch,
                        ts,
                        *from_created_at_inclusive,
                        *to_created_at_inclusive,
                        account_id.clone(),
                        *runner_id,
                        *batch_max_events,
                        reason.as_str(),
                        cmd.metadata.clone(),
                    )
                    .map_err(&err)?,

                CommandKind::ContinueBatchCancelOrders => self
                    .cmd_continue_batch_cancel_orders(&mut scratch, ts)
                    .map_err(&err)?,

                CommandKind::ContinueLapseOrders => self
                    .cmd_continue_lapse_orders(&mut scratch, ts)
                    .map_err(&err)?,

                CommandKind::ContinueVoidOrders => self
                    .cmd_continue_void_orders(&mut scratch, ts)
                    .map_err(&err)?,

                CommandKind::VoidMarket { reason, .. } => self
                    .cmd_void_market(&mut scratch, ts, reason.as_str())
                    .map_err(|reason| state_err(reason, RequestedBookState::Voided))?,

                CommandKind::SettleMarket {
                    runner_results,
                    dead_heat_divisor,
                    ..
                } => self
                    .cmd_settle_market(
                        &mut scratch,
                        ts,
                        runner_results.as_slice(),
                        *dead_heat_divisor,
                    )
                    .map_err(|reason| state_err(reason, RequestedBookState::Settled))?,

                CommandKind::PlaceOrder {
                    account_id,
                    runner_id,
                    side,
                    odds,
                    stake,
                    persistence,
                    time_in_force,
                    ..
                } => {
                    ensure_can_accept_new_orders(self.state).map_err(&err)?;
                    let order_id = OrderId(self.next_order_id);
                    self.cmd_place_order(
                        &mut scratch,
                        ts,
                        correlation_id.clone(),
                        order_id,
                        account_id.clone(),
                        *runner_id,
                        *side,
                        *odds,
                        *stake,
                        *persistence,
                        *time_in_force,
                    )
                    .map_err(&err)?
                }

                CommandKind::PlaceBinaryOrder { .. } | CommandKind::ReplaceBinaryOrder { .. } => {
                    return Err(err(RejectReason::MarketModelMismatch));
                }

                CommandKind::CancelOrder {
                    order_id,
                    account_id,
                    ..
                } => self
                    .cmd_cancel_order(
                        &mut scratch,
                        ts,
                        *order_id,
                        account_id.clone(),
                        "USER_CANCEL",
                    )
                    .map_err(&err)?,

                CommandKind::ReplaceOrder {
                    order_id,
                    account_id,
                    new_odds,
                    new_stake,
                    ..
                } => {
                    ensure_can_accept_new_orders(self.state).map_err(&err)?;
                    let Some(old) = self.get_order(*order_id) else {
                        return Err(err(RejectReason::OrderNotFound));
                    };
                    if &old.info.account_id != account_id {
                        return Err(err(RejectReason::NotOrderOwner));
                    }
                    let new_price = new_odds.unwrap_or(old.price);
                    let new_stake = new_stake.unwrap_or_else(|| old.remaining());
                    if new_price == old.price && new_stake == old.remaining() {
                        return Err(err(RejectReason::NoChange));
                    }
                    let new_order_id = OrderId(self.next_order_id);
                    self.cmd_replace_order(
                        &mut scratch,
                        ts,
                        correlation_id.clone(),
                        *order_id,
                        new_order_id,
                        account_id.clone(),
                        old.runner_id,
                        old.info.side,
                        new_price,
                        new_stake,
                        old.persistence,
                    )
                    .map_err(&err)?
                }
                CommandKind::RemoveRunner {
                    runner_id,
                    reduction_factor_bps,
                    ..
                } => self
                    .cmd_remove_runner(&mut scratch, ts, *runner_id, *reduction_factor_bps)
                    .map_err(&err)?,
                CommandKind::VoidTradesFromTime {
                    from_matched_at_inclusive,
                    reason,
                    ..
                } => self
                    .cmd_void_trades_from_time(
                        &mut scratch,
                        ts,
                        *from_matched_at_inclusive,
                        reason.as_str(),
                    )
                    .map_err(&err)?,

                CommandKind::HaltMarket { .. } | CommandKind::ResumeMarket => {
                    unreachable!("handled above")
                }
                CommandKind::RemoveMarket => {
                    return Err(err(RejectReason::InternalError));
                }
            };

            Ok((
                events,
                CommandResponse {
                    correlation_id: correlation_id.clone(),
                    kind,
                },
            ))
        })();

        self.scratch = scratch;
        result
    }

    // ========================================================================
    // Internal Helpers
    // ========================================================================

    pub(crate) fn apply_event(&mut self, env: &BookEventEnvelope) {
        let ts = env.timestamp;
        let event = &env.event;
        match event {
            BookEvent::MarketCreated { .. } => {}
            BookEvent::MarketStateChanged { to, reason } => {
                self.market_state_changed(*to, reason.as_str())
            }
            BookEvent::MarketPhaseChanged { to, reason } => {
                self.market_phase_changed(*to, reason.as_str())
            }
            BookEvent::OrderAccepted {
                correlation_id,
                order_id,
                account_id,
                runner_id,
                side,
                price,
                stake,
                persistence,
                ..
            } => {
                self.next_order_id = self.next_order_id.max(order_id.0.saturating_add(1));
                self.order_accepted(
                    ts,
                    *order_id,
                    account_id.clone(),
                    correlation_id.clone(),
                    *runner_id,
                    *side,
                    *price,
                    *stake,
                    *persistence,
                )
            }
            BookEvent::BinaryOrderAccepted { .. } => {}
            BookEvent::OrderCancelled {
                cancelled_order, ..
            } => {
                self.order_cancelled(cancelled_order.order_id);
            }
            BookEvent::OrderCancelledBatched {
                cancelled_orders,
                cursor_after,
                cancel_cause,
                cause_detail,
            } => {
                let cursor_after_order_id = cursor_after.as_ref().map(|cursor| cursor.order_id);
                let cancelled_count = cancelled_orders.len() as u64;
                for cancelled_order in cancelled_orders {
                    self.order_cancelled(cancelled_order.order_id);
                }
                match cancel_cause {
                    CancelCause::CloseCancel => {
                        if let Some(BatchProcessState::Close(s)) = self.batch_process.as_mut() {
                            s.cursor_after = cursor_after_order_id;
                            s.cancelled_total = s.cancelled_total.saturating_add(cancelled_count);
                            s.chunks_done = s.chunks_done.saturating_add(1);
                        }
                    }
                    CancelCause::SuspendLapse | CancelCause::InPlayLapse => {
                        if let Some(BatchProcessState::Lapse(s)) = self.batch_process.as_mut() {
                            s.cursor_after = cursor_after_order_id;
                            s.processed_total = s.processed_total.saturating_add(cancelled_count);
                            s.chunks_done = s.chunks_done.saturating_add(1);
                        } else {
                            self.batch_process =
                                Some(BatchProcessState::Lapse(OrderBatchProcessState {
                                    batch_max_events:
                                        crate::book::close_process::DEFAULT_CLOSE_BATCH_EVENTS,
                                    cursor_after: cursor_after_order_id,
                                    cancel_cause: *cancel_cause,
                                    cause_detail: None,
                                    processed_total: cancelled_count,
                                    chunks_done: 1,
                                }));
                        }
                    }
                    CancelCause::MarketVoid => {
                        if let Some(BatchProcessState::Void(s)) = self.batch_process.as_mut() {
                            s.cursor_after = cursor_after_order_id;
                            s.processed_total = s.processed_total.saturating_add(cancelled_count);
                            s.chunks_done = s.chunks_done.saturating_add(1);
                        } else {
                            self.batch_process =
                                Some(BatchProcessState::Void(OrderBatchProcessState {
                                    batch_max_events:
                                        crate::book::close_process::DEFAULT_CLOSE_BATCH_EVENTS,
                                    cursor_after: cursor_after_order_id,
                                    cancel_cause: *cancel_cause,
                                    cause_detail: cause_detail.clone(),
                                    processed_total: cancelled_count,
                                    chunks_done: 1,
                                }));
                        }
                    }
                    _ => {
                        if let Some(BatchProcessState::Cancel(s)) = self.batch_process.as_mut() {
                            s.cursor_after = cursor_after_order_id;
                            s.cancelled_total = s.cancelled_total.saturating_add(cancelled_count);
                            s.chunks_done = s.chunks_done.saturating_add(1);
                        }
                    }
                }
            }
            BookEvent::TradeMatched {
                order_id,
                role,
                runner_id,
                counter_party,
                remaining_stake,
                matched_delta,
                ..
            } => self.trade_matched(
                ts,
                TradeMatchView {
                    order_id: *order_id,
                    role: *role,
                    runner_id: *runner_id,
                    counter_party: *counter_party,
                    remaining_stake: *remaining_stake,
                    matched_delta: *matched_delta,
                },
            ),
            BookEvent::BinaryTradeMatched { .. } => {}
            BookEvent::VoidTradesFromTime { .. } => {}
            BookEvent::RunnerRemoved { runner_id, .. } => {
                if self.runner_index(*runner_id).is_some() {
                    self.removed_runners.insert(*runner_id);
                }
            }
            BookEvent::MarketSettled { .. } => {
                // No book state beyond the terminal MarketStateChanged; downstream uses results.
            }
            BookEvent::BatchCancelStarted {
                batch_max_events,
                started_at_ms,
                from_created_at_inclusive_ms,
                to_created_at_inclusive_ms,
                account_filter,
                runner_filter,
                reason,
                final_event_metadata_json,
            } => {
                self.batch_process = Some(BatchProcessState::Cancel(CancelProcessState {
                    batch_max_events: *batch_max_events,
                    cursor_after: None,
                    started_at_ms: *started_at_ms,
                    from_created_at_inclusive_ms: *from_created_at_inclusive_ms,
                    to_created_at_inclusive_ms: *to_created_at_inclusive_ms,
                    account_filter: account_filter.clone(),
                    runner_filter: *runner_filter,
                    reason: reason.clone(),
                    final_event_metadata_json: final_event_metadata_json.clone(),
                    cancelled_total: 0,
                    chunks_done: 0,
                }));
            }
            BookEvent::BatchProcessCompleted { .. } => {
                self.batch_process = None;
            }

            BookEvent::MarketRemoved { .. } => {}
        }
    }

    pub(crate) fn market_state_changed(&mut self, to: BookMarketState, reason: &str) {
        if to == BookMarketState::Closed {
            let batch_max_events =
                crate::book::close_process::parse_close_start_batch_max_events(reason)
                    .unwrap_or(crate::book::close_process::DEFAULT_CLOSE_BATCH_EVENTS);
            let total_live_orders =
                crate::book::close_process::count_live_orders(self.orders.iter_sorted());
            if total_live_orders > 0 {
                self.batch_process = Some(BatchProcessState::Close(CloseProcessState {
                    batch_max_events,
                    cursor_after: None,
                    total_live_orders,
                    cancelled_total: 0,
                    chunks_done: 0,
                }));
            }
        }
        let from = self.state;
        let lapse_cause = if to == BookMarketState::Suspended && from == BookMarketState::Open {
            Some(CancelCause::SuspendLapse)
        } else {
            None
        };
        if let Some(cancel_cause) = lapse_cause {
            let has_lapse = self.orders.iter_sorted().any(|(_, o)| {
                crate::book::close_process::is_live_order(o) && o.persistence == Persistence::Lapse
            });
            if has_lapse {
                self.batch_process = Some(BatchProcessState::Lapse(OrderBatchProcessState {
                    batch_max_events: crate::book::close_process::DEFAULT_CLOSE_BATCH_EVENTS,
                    cursor_after: None,
                    cancel_cause,
                    cause_detail: None,
                    processed_total: 0,
                    chunks_done: 0,
                }));
            }
        }
        if to == BookMarketState::Voided {
            let live_orders =
                crate::book::close_process::count_live_orders(self.orders.iter_sorted());
            if live_orders > 0 {
                self.batch_process = Some(BatchProcessState::Void(OrderBatchProcessState {
                    batch_max_events: crate::book::close_process::DEFAULT_CLOSE_BATCH_EVENTS,
                    cursor_after: None,
                    cancel_cause: CancelCause::MarketVoid,
                    cause_detail: Some("MARKET_VOID".to_string()),
                    processed_total: 0,
                    chunks_done: 0,
                }));
            }
        }
        self.state = to;
    }

    pub(crate) fn market_phase_changed(&mut self, to: MarketPhase, _reason: &str) {
        let from = self.market_phase;
        assert!(
            self.market_kind.can_transition_to_phase(from, to),
            "pre-event-only market cannot be applied into live phase"
        );
        if from == MarketPhase::Pre && to == MarketPhase::Live {
            let has_lapse = self.orders.iter_sorted().any(|(_, o)| {
                crate::book::close_process::is_live_order(o) && o.persistence == Persistence::Lapse
            });
            if has_lapse {
                self.batch_process = Some(BatchProcessState::Lapse(OrderBatchProcessState {
                    batch_max_events: crate::book::close_process::DEFAULT_CLOSE_BATCH_EVENTS,
                    cursor_after: None,
                    cancel_cause: CancelCause::InPlayLapse,
                    cause_detail: None,
                    processed_total: 0,
                    chunks_done: 0,
                }));
            }
        }
        self.market_phase = to;
    }

    #[allow(clippy::too_many_arguments)]
    pub(crate) fn order_accepted(
        &mut self,
        ts: DateTime,
        order_id: OrderId,
        account_id: AccountId,
        correlation_id: Option<CorrelationId>,
        runner_id: RunnerId,
        side: Side,
        price: OddsX10000,
        stake: Money,
        persistence: Persistence,
    ) {
        if self.orders.contains(&order_id) {
            return;
        }
        let Some(runner_idx) = self.runner_index(runner_id) else {
            return;
        };

        if let Err(err) = self.orders.insert(
            order_id,
            BookOrder {
                info: BookOrderInfo {
                    order_id,
                    account_id,
                    correlation_id,
                    side,
                    state: BookOrderState::ExecutableUnmatched,
                    created_at: ts,
                    last_updated_at: ts,
                },
                runner_id,
                price,
                stake,
                matched: Money::zero(),
                persistence,
            },
        ) {
            error!(order_id = ?order_id, reason = ?err, "failed to insert order");
            return;
        }
        self.active_order_count_cache = self.active_order_count_cache.saturating_add(1);

        let Some(key) = self.orders.get_key(&order_id) else {
            error!(order_id = ?order_id, "missing order key after insert");
            return;
        };
        self.runner_books[runner_idx].insert_tail(&mut self.orders, key, stake);
    }

    fn trade_pair_key(order_id: OrderId, counter_party: OrderId) -> TradePairKey {
        if order_id <= counter_party {
            (order_id, counter_party)
        } else {
            (counter_party, order_id)
        }
    }

    fn trade_matched(&mut self, ts: DateTime, m: TradeMatchView) {
        let was_live = self.orders.get(&m.order_id).is_some_and(|o| {
            matches!(
                o.info.state,
                BookOrderState::ExecutableUnmatched | BookOrderState::ExecutablePartiallyMatched
            )
        });
        if let Some(order) = self.orders.get_mut(&m.order_id) {
            order.matched = Money(order.matched.0 + m.matched_delta.0);
            order.info.last_updated_at = ts;
            order.info.state = if m.remaining_stake.0 == 0 {
                BookOrderState::ExecutionComplete
            } else {
                BookOrderState::ExecutablePartiallyMatched
            };
        }
        if let Some(order) = self.orders.get(&m.order_id)
            && let Some(runner_idx) = self.runner_index(order.runner_id)
        {
            self.update_level_after_fill(m.order_id, runner_idx, m.matched_delta);
        }
        if m.remaining_stake.0 == 0 {
            if was_live {
                self.active_order_count_cache = self.active_order_count_cache.saturating_sub(1);
            }
            self.orders.remove(&m.order_id);
        }

        let current = PendingTradeView {
            order_id: m.order_id,
            role: m.role,
            runner_id: m.runner_id,
            matched_delta: m.matched_delta,
        };
        let key = Self::trade_pair_key(m.order_id, m.counter_party);
        if let Some(first) = self.pending_trade_views.remove(&key) {
            assert_eq!(first.order_id, m.counter_party);
            let (maker, taker) = match (first.role, current.role) {
                (TradeRole::Maker, TradeRole::Taker) => (first, current),
                (TradeRole::Taker, TradeRole::Maker) => (current, first),
                _ => {
                    debug_assert!(
                        false,
                        "trade pair must contain exactly one maker and one taker"
                    );
                    return;
                }
            };

            if let Some(taker_idx) = self.runner_index(taker.runner_id) {
                self.runner_matched_volume[taker_idx] =
                    Money(self.runner_matched_volume[taker_idx].0 + taker.matched_delta.0);
            }
            if maker.runner_id != taker.runner_id
                && let Some(maker_idx) = self.runner_index(maker.runner_id)
            {
                self.runner_matched_volume[maker_idx] =
                    Money(self.runner_matched_volume[maker_idx].0 + maker.matched_delta.0);
            }
        } else {
            self.pending_trade_views.insert(key, current);
        }
    }

    pub(crate) fn order_cancelled(&mut self, order_id: OrderId) {
        if let Some(order) = self.orders.get(&order_id)
            && matches!(
                order.info.state,
                BookOrderState::ExecutableUnmatched | BookOrderState::ExecutablePartiallyMatched
            )
        {
            self.active_order_count_cache = self.active_order_count_cache.saturating_sub(1);
        }
        self.remove_from_levels(order_id);
        self.orders.remove(&order_id);
    }

    fn emit(&self, event_time: DateTime, event: BookEvent) -> BookEventEnvelope {
        BookEventEnvelope {
            market_id: self.market_id,
            market_name: self.market_name.clone(),
            market_seq: 0,
            timestamp: event_time,
            metadata: EventMetadata::default(),
            event,
        }
    }

    fn state_change(&self, ts: DateTime, to: BookMarketState, reason: &str, out: &mut EventVec) {
        if self.state == to {
            return;
        }
        out.push(self.emit(
            ts,
            BookEvent::MarketStateChanged {
                to,
                reason: reason.to_string(),
            },
        ));
    }

    fn phase_change(&self, ts: DateTime, to: MarketPhase, reason: &str, out: &mut EventVec) {
        if self.market_phase == to {
            return;
        }
        out.push(self.emit(
            ts,
            BookEvent::MarketPhaseChanged {
                to,
                reason: reason.to_string(),
            },
        ));
    }

    fn ok_noop(events: EventVec) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        Ok((events, None))
    }

    // ========================================================================
    // Market State Commands
    // ========================================================================

    fn cmd_open_market(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
        reason: &'static str,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();
        ensure_state_change(self.state, BookMarketState::Open)?;
        if self.state.is_terminal() {
            return Err(RejectReason::MarketTerminal);
        }
        self.state_change(ts, BookMarketState::Open, reason, &mut scratch.events);
        Self::ok_noop(std::mem::take(&mut scratch.events))
    }

    fn cmd_set_market_phase(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
        phase: MarketPhase,
        reason: &'static str,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();
        ensure_phase_change(self.market_phase, phase)?;
        if self.state != BookMarketState::Open {
            return Err(RejectReason::MarketNotOpen);
        }
        if !self
            .market_kind
            .can_transition_to_phase(self.market_phase, phase)
        {
            return Err(RejectReason::MarketInPlayNotSupported);
        }
        if self.state.is_terminal() {
            return Err(RejectReason::MarketTerminal);
        }

        self.phase_change(ts, phase, reason, &mut scratch.events);

        if self.market_phase == MarketPhase::Pre && phase == MarketPhase::Live {
            let batch_max_events = crate::book::close_process::DEFAULT_CLOSE_BATCH_EVENTS;
            let chunk = self.select_cancelled_chunk(None, batch_max_events as usize, |order| {
                crate::book::close_process::is_live_order(order)
                    && order.persistence == Persistence::Lapse
            });
            push_cancel_chunk(
                &mut scratch.events,
                |event| self.emit(ts, event),
                chunk,
                CancelCause::InPlayLapse,
                None,
                false,
                true,
            );
        }
        Self::ok_noop(std::mem::take(&mut scratch.events))
    }

    fn cmd_suspend(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
        reason: &'static str,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();
        ensure_state_change(self.state, BookMarketState::Suspended)?;
        if !self.state.is_matchable() {
            return Err(RejectReason::MarketNotOpen);
        }

        if self.state == BookMarketState::Open {
            let batch_max_events = crate::book::close_process::DEFAULT_CLOSE_BATCH_EVENTS;
            let chunk = self.select_cancelled_chunk(None, batch_max_events as usize, |order| {
                crate::book::close_process::is_live_order(order)
                    && order.persistence == Persistence::Lapse
            });
            self.state_change(ts, BookMarketState::Suspended, reason, &mut scratch.events);
            push_cancel_chunk(
                &mut scratch.events,
                |event| self.emit(ts, event),
                chunk,
                CancelCause::SuspendLapse,
                None,
                false,
                true,
            );
            return Self::ok_noop(std::mem::take(&mut scratch.events));
        }

        self.state_change(ts, BookMarketState::Suspended, reason, &mut scratch.events);
        Self::ok_noop(std::mem::take(&mut scratch.events))
    }

    fn cmd_unsuspend(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
        reason: &'static str,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();
        if self.state != BookMarketState::Suspended {
            return Err(RejectReason::MarketNotSuspended);
        }
        self.state_change(ts, BookMarketState::Open, reason, &mut scratch.events);
        Self::ok_noop(std::mem::take(&mut scratch.events))
    }

    fn cmd_halt_market(&self, ts: DateTime, reason: u32) -> Result<EventVec, RejectReason> {
        if self.state.is_halted() {
            return Err(RejectReason::MarketAlreadyHalted);
        }
        if self.state.is_terminal() {
            return Err(RejectReason::MarketTerminal);
        }
        let mut events = EventVec::new();
        self.state_change(
            ts,
            BookMarketState::Halted,
            &format!("HALT:{reason}"),
            &mut events,
        );
        Ok(events)
    }

    fn cmd_resume_market(&self, ts: DateTime) -> Result<EventVec, RejectReason> {
        if self.state.is_terminal() {
            return Err(RejectReason::MarketTerminal);
        }
        if !self.state.is_halted() {
            return Err(RejectReason::MarketNotHalted);
        }
        let mut events = EventVec::new();
        self.state_change(ts, BookMarketState::Open, "RESUME", &mut events);
        Ok(events)
    }

    fn cmd_close_market(
        &mut self,
        ts: DateTime,
        batch_max_events: u16,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        // close stats may be retained after completion for UI/debugging; state is authoritative.
        ensure_state_change(self.state, BookMarketState::Closed)?;
        if self.has_active_batch_process() {
            return Err(RejectReason::MarketBatchCancelling);
        }
        if self.state.is_terminal() {
            return Err(RejectReason::MarketTerminal);
        }
        if batch_max_events < MIN_CLOSE_BATCH_EVENTS {
            return Err(RejectReason::InvalidBatchSize);
        }

        let reason = crate::book::close_process::close_start_reason(batch_max_events);
        let mut events = EventVec::new();
        self.state_change(ts, BookMarketState::Closed, reason.as_str(), &mut events);
        let chunk = self.select_cancelled_chunk(None, batch_max_events as usize, |order| {
            crate::book::close_process::is_live_order(order)
        });
        push_cancel_chunk(
            &mut events,
            |event| self.emit(ts, event),
            chunk,
            CancelCause::CloseCancel,
            None,
            false,
            true,
        );
        Self::ok_noop(events)
    }

    fn cmd_continue_close_market(
        &self,
        ts: DateTime,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        let Some(BatchProcessState::Close(proc_state)) = self.batch_process else {
            return Err(RejectReason::MarketNotBatchCancelling);
        };
        let chunk = self.select_cancelled_chunk(
            proc_state.cursor_after,
            proc_state.batch_max_events as usize,
            crate::book::close_process::is_live_order,
        );
        let mut events = EventVec::new();
        push_cancel_chunk(
            &mut events,
            |event| self.emit(ts, event),
            chunk,
            CancelCause::CloseCancel,
            None,
            false,
            true,
        );
        Self::ok_noop(events)
    }

    #[allow(clippy::too_many_arguments)]
    fn cmd_batch_cancel_orders(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
        from_created_at_inclusive: Option<DateTime>,
        to_created_at_inclusive: Option<DateTime>,
        account_filter: Option<AccountId>,
        runner_filter: Option<RunnerId>,
        batch_max_events: u16,
        reason: &str,
        final_event_metadata: Option<serde_json::Value>,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();
        if self.has_active_batch_process() {
            return Err(RejectReason::MarketBatchCancelling);
        }
        if batch_max_events < 1 {
            return Err(RejectReason::InvalidBatchSize);
        }

        let from_ms_opt = from_created_at_inclusive.map(|d| d.timestamp_millis());
        let to_ms_opt = to_created_at_inclusive.map(|d| d.timestamp_millis());
        let metadata_json = final_event_metadata
            .as_ref()
            .and_then(|m| serde_json::to_string(m).ok());
        let started_at_ms = ts.timestamp_millis();

        scratch.events.push(self.emit(
            ts,
            BookEvent::BatchCancelStarted {
                batch_max_events,
                started_at_ms,
                from_created_at_inclusive_ms: from_ms_opt,
                to_created_at_inclusive_ms: to_ms_opt,
                account_filter: account_filter.clone(),
                runner_filter,
                reason: reason.to_string(),
                final_event_metadata_json: metadata_json,
            },
        ));
        let chunk = self.select_cancelled_chunk(None, batch_max_events as usize, |order| {
            if !crate::book::close_process::is_live_order(order) {
                return false;
            }
            let created_at_ms = order.info.created_at.timestamp_millis();
            if created_at_ms > started_at_ms {
                return false;
            }
            if let Some(from_ms) = from_ms_opt
                && created_at_ms < from_ms
            {
                return false;
            }
            if let Some(to_ms) = to_ms_opt
                && created_at_ms > to_ms
            {
                return false;
            }
            if let Some(account_id) = account_filter.as_ref()
                && &order.info.account_id != account_id
            {
                return false;
            }
            if let Some(runner_id) = runner_filter
                && order.runner_id != runner_id
            {
                return false;
            }
            true
        });
        let done = chunk.done;
        let mut cancel_event = self.emit(
            ts,
            chunk.into_event(CancelCause::BatchCancel, Some(reason.to_string())),
        );
        if done {
            cancel_event.metadata = final_event_metadata.clone();
        }
        scratch.events.push(cancel_event);
        if done {
            scratch.events.push(self.emit(
                ts,
                BookEvent::BatchProcessCompleted {
                    cancel_cause: CancelCause::BatchCancel,
                },
            ));
        }
        Self::ok_noop(std::mem::take(&mut scratch.events))
    }

    fn cmd_continue_batch_cancel_orders(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();
        let Some(BatchProcessState::Cancel(proc_state)) = self.batch_process.as_ref() else {
            return Err(RejectReason::MarketNotBatchCancelling);
        };

        let chunk = self.select_cancelled_chunk(
            proc_state.cursor_after,
            proc_state.batch_max_events as usize,
            |order| {
                if !crate::book::close_process::is_live_order(order) {
                    return false;
                }
                let created_at_ms = order.info.created_at.timestamp_millis();
                if created_at_ms > proc_state.started_at_ms {
                    return false;
                }
                if let Some(from_ms) = proc_state.from_created_at_inclusive_ms
                    && created_at_ms < from_ms
                {
                    return false;
                }
                if let Some(to_ms) = proc_state.to_created_at_inclusive_ms
                    && created_at_ms > to_ms
                {
                    return false;
                }
                if let Some(account_id) = proc_state.account_filter.as_ref()
                    && &order.info.account_id != account_id
                {
                    return false;
                }
                if let Some(runner_id) = proc_state.runner_filter
                    && order.runner_id != runner_id
                {
                    return false;
                }
                true
            },
        );
        let done = chunk.done;
        let mut cancel_event = self.emit(
            ts,
            chunk.into_event(CancelCause::BatchCancel, Some(proc_state.reason.clone())),
        );
        if done {
            cancel_event.metadata = proc_state
                .final_event_metadata_json
                .as_ref()
                .and_then(|m| serde_json::from_str(m).ok());
        }
        scratch.events.push(cancel_event);
        if done {
            scratch.events.push(self.emit(
                ts,
                BookEvent::BatchProcessCompleted {
                    cancel_cause: CancelCause::BatchCancel,
                },
            ));
        }
        Self::ok_noop(std::mem::take(&mut scratch.events))
    }

    fn cmd_continue_lapse_orders(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();
        let Some(BatchProcessState::Lapse(proc_state)) = self.batch_process.as_ref() else {
            return Err(RejectReason::MarketNotBatchCancelling);
        };
        let chunk = self.select_cancelled_chunk(
            proc_state.cursor_after,
            proc_state.batch_max_events as usize,
            |order| {
                crate::book::close_process::is_live_order(order)
                    && order.persistence == Persistence::Lapse
            },
        );
        push_cancel_chunk(
            &mut scratch.events,
            |event| self.emit(ts, event),
            chunk,
            proc_state.cancel_cause,
            proc_state.cause_detail.clone(),
            true,
            true,
        );
        Self::ok_noop(std::mem::take(&mut scratch.events))
    }

    fn cmd_void_market(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
        reason: &str,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();
        if self.state == BookMarketState::Voided {
            return Err(RejectReason::MarketAlreadyVoided);
        }
        let batch_max_events = crate::book::close_process::DEFAULT_CLOSE_BATCH_EVENTS;
        let chunk = self.select_cancelled_chunk(None, batch_max_events as usize, |order| {
            crate::book::close_process::is_live_order(order)
        });
        self.state_change(ts, BookMarketState::Voided, reason, &mut scratch.events);
        push_cancel_chunk(
            &mut scratch.events,
            |event| self.emit(ts, event),
            chunk,
            CancelCause::MarketVoid,
            Some(reason.to_string()),
            false,
            true,
        );
        Self::ok_noop(std::mem::take(&mut scratch.events))
    }

    fn cmd_continue_void_orders(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();
        let Some(BatchProcessState::Void(proc_state)) = self.batch_process.as_ref() else {
            return Err(RejectReason::MarketNotBatchCancelling);
        };
        let chunk = self.select_cancelled_chunk(
            proc_state.cursor_after,
            proc_state.batch_max_events as usize,
            crate::book::close_process::is_live_order,
        );
        push_cancel_chunk(
            &mut scratch.events,
            |event| self.emit(ts, event),
            chunk,
            proc_state.cancel_cause,
            proc_state.cause_detail.clone(),
            true,
            true,
        );
        Self::ok_noop(std::mem::take(&mut scratch.events))
    }

    // ========================================================================
    // Order Commands
    // ========================================================================

    #[allow(clippy::too_many_arguments)]
    fn cmd_place_order(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
        correlation_id: Option<CorrelationId>,
        order_id: OrderId,
        account_id: AccountId,
        runner_id: RunnerId,
        side: Side,
        price: OddsX10000,
        stake: Money,
        persistence: Persistence,
        time_in_force: TimeInForce,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();
        debug_assert!(self.state.is_matchable());

        let Some(runner_idx) = self.runner_index(runner_id) else {
            return Err(RejectReason::RunnerNotFound);
        };

        if self.removed_runners.contains(&runner_id) {
            return Err(RejectReason::RunnerNotFound);
        }

        if !price.is_valid_tick() {
            return Err(RejectReason::InvalidOdds);
        }

        if !stake.is_positive() {
            return Err(RejectReason::InvalidStake);
        }

        if self.orders.contains(&order_id) {
            return Err(RejectReason::Duplicate);
        }

        if let TimeInForce::FillOrKill { min_fill } = time_in_force {
            let required = match min_fill {
                Some(qty) => {
                    let value = i64::try_from(qty.0).map_err(|_| RejectReason::InvalidStake)?;
                    Money(value)
                }
                None => stake,
            };
            let available = self.available_to_match(runner_idx, side, price);
            if available.0 < required.0 {
                return Err(RejectReason::WouldNotFillFok);
            }
        }

        scratch.events.push(self.emit(
            ts,
            BookEvent::OrderAccepted {
                correlation_id: correlation_id.clone(),
                order_id,
                account_id: account_id.clone(),
                runner_id,
                runner_label: self.runner_label(runner_id).to_string(),
                side,
                price,
                stake,
                persistence,
                time_in_force,
            },
        ));

        let (matched, avg_price) = self.plan_matching(
            scratch,
            MatchRequest {
                ts,
                taker_order_id: order_id,
                taker_account_id: account_id.clone(),
                taker_correlation_id: correlation_id.clone(),
                taker_side: side,
                taker_runner_id: runner_id,
                taker_price: price,
                taker_stake: stake,
                taker_runner_idx: runner_idx,
            },
        );

        let mut final_remaining = Money(stake.0.saturating_sub(matched.0));
        let mut final_state = if final_remaining.0 == 0 {
            BookOrderState::ExecutionComplete
        } else if matched.0 > 0 {
            BookOrderState::ExecutablePartiallyMatched
        } else {
            BookOrderState::ExecutableUnmatched
        };

        let should_cancel_fok_remainder =
            matches!(time_in_force, TimeInForce::FillOrKill { .. }) && final_remaining.0 > 0;
        if should_cancel_fok_remainder {
            final_remaining = Money::zero();
            final_state = BookOrderState::Cancelled;
            scratch.events.push(self.emit(
                ts,
                BookEvent::OrderCancelled {
                    cancelled_order: CancelledOrderEntry {
                        order_id,
                        account_id,
                        correlation_id,
                    },
                    cancel_cause: CancelCause::FokRemainder,
                    cause_detail: None,
                },
            ));
        }

        Ok((
            std::mem::take(&mut scratch.events),
            Some(CommandResponseKind::PlaceOrder(PlaceOrderResult {
                accepted: true,
                order_id,
                matched: FillQuantity::Stake(matched),
                avg_matched_price: avg_price.map(FillPrice::Odds),
                remaining: FillQuantity::Stake(final_remaining),
                final_order_state: Some(final_state),
            })),
        ))
    }

    /// Calculate total available liquidity to match against for a given taker order.
    fn available_to_match(&self, runner_idx: usize, side: Side, price: OddsX10000) -> Money {
        let opposite_idx = Self::opposite_index(runner_idx);
        let derived_price = match side {
            Side::Yes => formulas::max_opposite_back_odds_for_taker_back(price),
            Side::No => formulas::min_opposite_lay_odds_for_taker_lay(price),
        };
        let mut total = 0i64;

        match side {
            Side::Yes => {
                // Direct: BACK odds are a minimum acceptable price, so consume
                // same-runner LAYs from the highest odds down to the taker limit.
                let rb = &self.runner_books[runner_idx];
                let mut tick = rb.best_tick_desc(Side::No);
                while let Some(t) = tick {
                    let px = OddsX10000(crate::types::odds::TICK_LADDER[t]);
                    if px.0 < price.0 {
                        break;
                    }
                    total += rb.level_total_remaining(Side::No, t).0;
                    if t == 0 {
                        break;
                    }
                    tick = rb.next_tick_desc_from(Side::No, t - 1);
                }

                // Implied: opposite-runner BACKs where the derived odds are at
                // least the taker's minimum acceptable odds.
                let rb = &self.runner_books[opposite_idx];
                let mut tick = rb.best_tick_asc(Side::Yes);
                while let Some(t) = tick {
                    let px = OddsX10000(crate::types::odds::TICK_LADDER[t]);
                    if px.0 > derived_price.0 {
                        break;
                    }
                    let effective = formulas::back_to_lay_odds(px);
                    let cap = formulas::implied_taker_capacity_from_maker(
                        rb.level_total_remaining(Side::Yes, t),
                        effective,
                    );
                    total += cap.0;
                    tick = rb.next_tick_asc_from(Side::Yes, t + 1);
                }
            }
            Side::No => {
                // Direct: LAY odds are a maximum acceptable price, so consume
                // same-runner BACKs from the lowest odds up to the taker limit.
                let rb = &self.runner_books[runner_idx];
                let mut tick = rb.best_tick_asc(Side::Yes);
                while let Some(t) = tick {
                    let px = OddsX10000(crate::types::odds::TICK_LADDER[t]);
                    if px.0 > price.0 {
                        break;
                    }
                    total += rb.level_total_remaining(Side::Yes, t).0;
                    tick = rb.next_tick_asc_from(Side::Yes, t + 1);
                }

                // Implied: opposite-runner LAYs where the derived odds are at
                // most the taker's maximum acceptable odds.
                let rb = &self.runner_books[opposite_idx];
                let mut tick = rb.best_tick_desc(Side::No);
                while let Some(t) = tick {
                    let px = OddsX10000(crate::types::odds::TICK_LADDER[t]);
                    if px.0 < derived_price.0 {
                        break;
                    }
                    let effective = formulas::lay_to_back_odds(px);
                    let cap = formulas::implied_taker_capacity_from_maker(
                        rb.level_total_remaining(Side::No, t),
                        effective,
                    );
                    total += cap.0;
                    if t == 0 {
                        break;
                    }
                    tick = rb.next_tick_desc_from(Side::No, t - 1);
                }
            }
        }

        Money(total)
    }

    /// Plan matching with implied liquidity without mutating book state.
    fn plan_matching(&self, scratch: &mut Scratch, r: MatchRequest) -> (Money, Option<OddsX10000>) {
        let opposite_idx = Self::opposite_index(r.taker_runner_idx);
        let derived_taker_price = match r.taker_side {
            Side::Yes => formulas::max_opposite_back_odds_for_taker_back(r.taker_price),
            Side::No => formulas::min_opposite_lay_odds_for_taker_lay(r.taker_price),
        };

        scratch.maker_matched_delta.clear();
        scratch.matchable.clear();
        scratch.fills.clear();

        match r.taker_side {
            Side::Yes => {
                // Direct: same-runner LAYs from highest odds down to the taker limit.
                let rb = &self.runner_books[r.taker_runner_idx];
                let mut tick = rb.best_tick_desc(Side::No);
                while let Some(t) = tick {
                    let px = OddsX10000(crate::types::odds::TICK_LADDER[t]);
                    if px.0 < r.taker_price.0 {
                        break;
                    }
                    for maker_key in rb.iter_level_keys(Side::No, t, &self.orders) {
                        let maker = self.orders.order_by_key(maker_key);
                        scratch.matchable.push(MatchableOrder {
                            order_id: maker.info.order_id,
                            effective_price: px,
                            created_at: maker.info.created_at,
                        });
                    }
                    if t == 0 {
                        break;
                    }
                    tick = rb.next_tick_desc_from(Side::No, t - 1);
                }

                // Implied: opposite-runner BACKs whose derived odds meet the
                // taker's minimum acceptable odds.
                let rb = &self.runner_books[opposite_idx];
                let mut tick = rb.best_tick_asc(Side::Yes);
                while let Some(t) = tick {
                    let maker_px = OddsX10000(crate::types::odds::TICK_LADDER[t]);
                    if maker_px.0 > derived_taker_price.0 {
                        break;
                    }
                    let effective = formulas::back_to_lay_odds(maker_px);
                    for maker_key in rb.iter_level_keys(Side::Yes, t, &self.orders) {
                        let maker = self.orders.order_by_key(maker_key);
                        scratch.matchable.push(MatchableOrder {
                            order_id: maker.info.order_id,
                            effective_price: effective,
                            created_at: maker.info.created_at,
                        });
                    }
                    tick = rb.next_tick_asc_from(Side::Yes, t + 1);
                }

                // Higher odds are better for a backer.
                scratch.matchable.sort_by(|a, b| {
                    b.effective_price
                        .0
                        .cmp(&a.effective_price.0)
                        // Deterministic tie-breakers:
                        // - `created_at` for FIFO within the same effective price
                        // - `order_id` to break ties when timestamps collide
                        .then_with(|| a.created_at.cmp(&b.created_at))
                        .then_with(|| a.order_id.cmp(&b.order_id))
                });
            }
            Side::No => {
                // Direct: same-runner BACKs from lowest odds up to the taker limit.
                let rb = &self.runner_books[r.taker_runner_idx];
                let mut tick = rb.best_tick_asc(Side::Yes);
                while let Some(t) = tick {
                    let px = OddsX10000(crate::types::odds::TICK_LADDER[t]);
                    if px.0 > r.taker_price.0 {
                        break;
                    }
                    for maker_key in rb.iter_level_keys(Side::Yes, t, &self.orders) {
                        let maker = self.orders.order_by_key(maker_key);
                        scratch.matchable.push(MatchableOrder {
                            order_id: maker.info.order_id,
                            effective_price: px,
                            created_at: maker.info.created_at,
                        });
                    }
                    tick = rb.next_tick_asc_from(Side::Yes, t + 1);
                }

                // Implied: opposite-runner LAYs whose derived odds meet the
                // taker's maximum acceptable odds.
                let rb = &self.runner_books[opposite_idx];
                let mut tick = rb.best_tick_desc(Side::No);
                while let Some(t) = tick {
                    let maker_px = OddsX10000(crate::types::odds::TICK_LADDER[t]);
                    if maker_px.0 < derived_taker_price.0 {
                        break;
                    }
                    let effective = formulas::lay_to_back_odds(maker_px);
                    for maker_key in rb.iter_level_keys(Side::No, t, &self.orders) {
                        let maker = self.orders.order_by_key(maker_key);
                        scratch.matchable.push(MatchableOrder {
                            order_id: maker.info.order_id,
                            effective_price: effective,
                            created_at: maker.info.created_at,
                        });
                    }
                    if t == 0 {
                        break;
                    }
                    tick = rb.next_tick_desc_from(Side::No, t - 1);
                }

                // Lower odds are better for a layer.
                scratch.matchable.sort_by(|a, b| {
                    a.effective_price
                        .0
                        .cmp(&b.effective_price.0)
                        // Deterministic tie-breakers:
                        // - `created_at` for FIFO within the same effective price
                        // - `order_id` to break ties when timestamps collide
                        .then_with(|| a.created_at.cmp(&b.created_at))
                        .then_with(|| a.order_id.cmp(&b.order_id))
                });
            }
        }

        let mut remaining = r.taker_stake;
        let mut matched_total = Money::zero();
        let mut sum_price_qty: u128 = 0;

        for m in scratch.matchable.iter() {
            if remaining.0 <= 0 {
                break;
            }

            let Some(maker) = self.orders.get(&m.order_id) else {
                continue;
            };

            // Skip self-match
            if maker.info.account_id == r.taker_account_id {
                continue;
            }

            let maker_is_live = matches!(
                maker.info.state,
                BookOrderState::ExecutableUnmatched | BookOrderState::ExecutablePartiallyMatched
            );
            if !maker_is_live {
                continue;
            }

            let base_remaining = maker.stake.0.saturating_sub(maker.matched.0);
            let delta = *scratch.maker_matched_delta.get(&m.order_id).unwrap_or(&0);
            let maker_remaining = base_remaining.saturating_sub(delta);
            if maker_remaining <= 0 {
                continue;
            }

            let is_implied = maker.runner_id != r.taker_runner_id;
            let (fill_i64, maker_fill_i64) = if is_implied {
                let max_taker = formulas::implied_taker_capacity_from_maker(
                    Money(maker_remaining),
                    m.effective_price,
                )
                .0;
                let fill_i64 = remaining.0.min(max_taker);
                if fill_i64 <= 0 {
                    continue;
                }
                let maker_fill =
                    formulas::implied_maker_stake_from_taker(Money(fill_i64), m.effective_price)
                        .0
                        .min(maker_remaining);
                if maker_fill <= 0 {
                    continue;
                }
                (fill_i64, maker_fill)
            } else {
                let fill_i64 = remaining.0.min(maker_remaining);
                (fill_i64, fill_i64)
            };
            let taker_fill_amount = Money(fill_i64);
            remaining = Money(remaining.0 - fill_i64);
            let maker_remaining_after = Money(maker_remaining.saturating_sub(maker_fill_i64));
            let taker_remaining_after = remaining;

            // Trade price: for direct matches, use effective price.
            // For implied matches, use the derived price of the maker's actual price.
            let trade_price = m.effective_price;

            *scratch.maker_matched_delta.entry(m.order_id).or_insert(0) += maker_fill_i64;
            scratch.fills.push(PlannedFill {
                maker_order_id: m.order_id,
                maker_account_id: maker.info.account_id.clone(),
                maker_correlation_id: maker.info.correlation_id.clone(),
                maker_runner_id: maker.runner_id,
                maker_side: maker.info.side,
                maker_order_price: maker.price,
                trade_price,
                maker_fill_amount: Money(maker_fill_i64),
                taker_fill_amount,
                maker_remaining_after,
                taker_remaining_after,
            });

            matched_total = Money(matched_total.0 + taker_fill_amount.0);
            sum_price_qty =
                sum_price_qty.saturating_add(trade_price.0 as u128 * fill_i64.max(0) as u128);
        }

        for fill in scratch.fills.iter() {
            scratch.events.push(self.emit(
                r.ts,
                BookEvent::TradeMatched {
                    correlation_id: fill.maker_correlation_id.clone(),
                    order_id: fill.maker_order_id,
                    account_id: fill.maker_account_id.clone(),
                    role: TradeRole::Maker,
                    runner_id: fill.maker_runner_id,
                    runner_label: self.runner_label(fill.maker_runner_id).to_string(),
                    side: fill.maker_side,
                    market_phase: self.market_phase,
                    price: fill.maker_order_price,
                    stake: fill.maker_fill_amount,
                    counter_party: r.taker_order_id,
                    counter_party_account_id: r.taker_account_id.clone(),
                    remaining_stake: fill.maker_remaining_after,
                    matched_delta: fill.maker_fill_amount,
                },
            ));
            scratch.events.push(self.emit(
                r.ts,
                BookEvent::TradeMatched {
                    correlation_id: r.taker_correlation_id.clone(),
                    order_id: r.taker_order_id,
                    account_id: r.taker_account_id.clone(),
                    role: TradeRole::Taker,
                    runner_id: r.taker_runner_id,
                    runner_label: self.runner_label(r.taker_runner_id).to_string(),
                    side: r.taker_side,
                    market_phase: self.market_phase,
                    price: fill.trade_price,
                    stake: fill.taker_fill_amount,
                    counter_party: fill.maker_order_id,
                    counter_party_account_id: fill.maker_account_id.clone(),
                    remaining_stake: fill.taker_remaining_after,
                    matched_delta: fill.taker_fill_amount,
                },
            ));
        }

        let avg_price = if matched_total.0 > 0 {
            let denom = matched_total.0 as u128;
            let px = (sum_price_qty / denom).min(u32::MAX as u128) as u32;
            Some(OddsX10000(px))
        } else {
            None
        };

        (matched_total, avg_price)
    }

    fn update_level_after_fill(
        &mut self,
        maker_order_id: OrderId,
        runner_idx: usize,
        fill_amount: Money,
    ) {
        let (maker_side, maker_remaining) = match self.orders.get(&maker_order_id) {
            Some(o) => (o.info.side, o.remaining()),
            None => return,
        };
        let Some(maker_key) = self.orders.get_key(&maker_order_id) else {
            return;
        };
        let maker_tick = self.orders.stored_tick(maker_key);

        self.runner_books[runner_idx].decrement_level_total(maker_side, maker_tick, fill_amount);
        if maker_remaining.0 == 0 && self.orders.in_level_by_key(maker_key) {
            self.runner_books[runner_idx].unlink(&mut self.orders, maker_key, Money::zero());
        }
    }

    fn cmd_cancel_order(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
        order_id: OrderId,
        account_id: AccountId,
        reason: &'static str,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();

        let Some(order) = self.orders.get(&order_id) else {
            return Err(RejectReason::OrderNotFound);
        };

        if order.info.account_id != account_id {
            return Err(RejectReason::NotOrderOwner);
        }

        let is_live = matches!(
            order.info.state,
            BookOrderState::ExecutableUnmatched | BookOrderState::ExecutablePartiallyMatched
        );
        if !is_live {
            return Err(RejectReason::OrderNotLive);
        }

        scratch.events.push(self.emit(
            ts,
            BookEvent::OrderCancelled {
                cancelled_order: CancelledOrderEntry::from_order_info(order_id, &order.info),
                cancel_cause: match reason {
                    "USER_CANCEL" => CancelCause::UserCancel,
                    "REPLACE" => CancelCause::Replace,
                    "RUNNER_REMOVED" => CancelCause::RunnerRemoved,
                    _ => CancelCause::Admin,
                },
                cause_detail: Some(reason.to_string()),
            },
        ));
        Self::ok_noop(std::mem::take(&mut scratch.events))
    }

    fn remove_from_levels(&mut self, order_id: OrderId) {
        if !self.orders.is_in_level(&order_id) {
            return;
        };

        let Some(order) = self.orders.get(&order_id) else {
            return;
        };

        let remaining = order.remaining();
        if remaining.0 <= 0 {
            return;
        }
        let Some(idx) = self.runner_index(order.runner_id) else {
            return;
        };
        let Some(key) = self.orders.get_key(&order_id) else {
            return;
        };
        self.runner_books[idx].unlink(&mut self.orders, key, remaining);
    }

    #[allow(clippy::too_many_arguments)]
    fn cmd_replace_order(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
        correlation_id: Option<CorrelationId>,
        old_order_id: OrderId,
        new_order_id: OrderId,
        account_id: AccountId,
        runner_id: RunnerId,
        side: Side,
        new_price: OddsX10000,
        new_stake: Money,
        persistence: Persistence,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();

        let Some(old) = self.orders.get(&old_order_id) else {
            return Err(RejectReason::OrderNotFound);
        };

        if old.info.account_id != account_id {
            return Err(RejectReason::NotOrderOwner);
        }

        let is_live = matches!(
            old.info.state,
            BookOrderState::ExecutableUnmatched | BookOrderState::ExecutablePartiallyMatched
        );
        if !is_live {
            return Err(RejectReason::OrderNotLive);
        }

        // Atomic: only cancel old if the replacement can be placed.
        let (place_events, place_ok) = self.cmd_place_order(
            scratch,
            ts,
            correlation_id,
            new_order_id,
            account_id.clone(),
            runner_id,
            side,
            new_price,
            new_stake,
            persistence,
            TimeInForce::Gtc,
        )?;

        let (cancel_events, _) =
            self.cmd_cancel_order(scratch, ts, old_order_id, account_id, "REPLACE")?;

        scratch.events.extend(cancel_events);
        scratch.events.extend(place_events);

        Ok((std::mem::take(&mut scratch.events), place_ok))
    }

    // ========================================================================
    // Runner Commands
    // ========================================================================

    fn cmd_remove_runner(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
        runner_id: RunnerId,
        reduction_factor_bps: Option<u32>,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();

        let Some(_) = self.runner_index(runner_id) else {
            return Err(RejectReason::RunnerNotFound);
        };

        if self.removed_runners.contains(&runner_id) {
            return Err(RejectReason::RunnerAlreadyRemoved);
        }

        let orders_to_cancel: Vec<(OrderId, AccountId)> = self
            .orders
            .iter_sorted()
            .filter_map(|(oid, o)| {
                if o.runner_id == runner_id
                    && matches!(
                        o.info.state,
                        BookOrderState::ExecutableUnmatched
                            | BookOrderState::ExecutablePartiallyMatched
                    )
                {
                    Some((oid, o.info.account_id.clone()))
                } else {
                    None
                }
            })
            .collect();

        for (oid, account_id) in orders_to_cancel {
            let (cancel_events, _) =
                self.cmd_cancel_order(scratch, ts, oid, account_id, "RUNNER_REMOVED")?;
            scratch.events.extend(cancel_events);
        }

        scratch.events.push(self.emit(
            ts,
            BookEvent::RunnerRemoved {
                runner_id,
                runner_label: self.runner_label(runner_id).to_string(),
                reduction_factor_bps,
            },
        ));

        Self::ok_noop(std::mem::take(&mut scratch.events))
    }

    // ========================================================================
    // Settlement Commands
    // ========================================================================

    fn cmd_settle_market(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
        runner_results: &[(RunnerId, RunnerResult)],
        dead_heat_divisor: Option<u32>,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();

        ensure_state_change(self.state, BookMarketState::Settled)?;
        if self.state != BookMarketState::Closed {
            return Err(RejectReason::MarketNotClosed);
        }

        let result_runners: BTreeSet<_> = runner_results.iter().map(|(r, _)| *r).collect();
        if result_runners.len() != runner_results.len() {
            return Err(RejectReason::DuplicateRunner);
        }

        let active_runners: BTreeSet<_> = self
            .runner_ids
            .iter()
            .filter(|r| !self.removed_runners.contains(r))
            .copied()
            .collect();

        if result_runners != active_runners {
            return Err(RejectReason::IncompleteResults);
        }

        scratch.events.push(
            self.emit(
                ts,
                BookEvent::MarketSettled {
                    runner_results: runner_results
                        .iter()
                        .map(|(runner_id, result)| RunnerResultEntry {
                            runner_id: *runner_id,
                            runner_label: self.runner_label(*runner_id).to_string(),
                            result: *result,
                        })
                        .collect(),
                    dead_heat_divisor,
                },
            ),
        );

        self.state_change(ts, BookMarketState::Settled, "SETTLED", &mut scratch.events);
        Self::ok_noop(std::mem::take(&mut scratch.events))
    }

    // ========================================================================
    // Trade Void Commands
    // ========================================================================

    fn cmd_void_trades_from_time(
        &self,
        scratch: &mut Scratch,
        ts: DateTime,
        from_inclusive: DateTime,
        reason: &str,
    ) -> Result<(EventVec, Option<CommandResponseKind>), RejectReason> {
        scratch.events.clear();

        if self.state == BookMarketState::Voided || self.state == BookMarketState::Settled {
            return Err(RejectReason::MarketTerminal);
        }

        scratch.events.push(self.emit(
            ts,
            BookEvent::VoidTradesFromTime {
                from_matched_at_inclusive: from_inclusive,
                reason: reason.to_string(),
            },
        ));

        Self::ok_noop(std::mem::take(&mut scratch.events))
    }
}
