//! Comprehensive tests for the Book module.
//!
//! Test categories:
//! - Order lifecycle (place, cancel, replace)
//! - Matching (FIFO, price priority, implied matching)
//! - Time-in-force (GTC, FOK with minFillSize)
//! - Market depth queries
//! - Runner removal
//! - Trade voiding
//! - Idempotency and determinism
//! - Snapshot (serde) roundtrips
//!
//! Note: Basic matching, market state transitions, and settlement tests
//! are in scenario_tests.rs. This file focuses on nuanced book internals
//! and edge cases not covered elsewhere.

use betex::{
    book::protocol::{
        command::{Command, CommandKind, MarketState, Side},
        reject::RejectReason,
    },
    book::*,
    types::*,
};

// Test Helpers
// ============================================================================

#[derive(Debug, Clone)]
struct TestCommand {
    cmd: Command,
}

fn cancelled_order_ids(cancelled_orders: &[CancelledOrderEntry]) -> Vec<OrderId> {
    cancelled_orders
        .iter()
        .map(|entry| entry.order_id)
        .collect()
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
enum BookCommandKind {
    OpenMarket {
        reason: &'static str,
    },
    SetLivePhase {
        reason: &'static str,
    },
    SuspendMarket {
        reason: &'static str,
    },
    UnsuspendMarket {
        reason: &'static str,
    },
    CloseMarket,
    VoidMarket {
        reason: &'static str,
    },
    PlaceOrder {
        order_id: OrderId,
        account_id: AccountId,
        runner_id: RunnerId,
        side: Side,
        price: OddsX10000,
        stake: Money,
        persistence: Persistence,
        time_in_force: TimeInForce,
    },
    CancelOrder {
        order_id: OrderId,
        account_id: AccountId,
    },
    ReplaceOrder {
        old_order_id: OrderId,
        account_id: AccountId,
        runner_id: RunnerId,
        side: Side,
        new_price: OddsX10000,
        new_stake: Money,
        persistence: Persistence,
    },
    RemoveRunner {
        runner_id: RunnerId,
        reduction_factor_bps: Option<u32>,
    },
    SettleMarket {
        runner_results: Vec<(RunnerId, RunnerResult)>,
        dead_heat_divisor: Option<u32>,
    },
    VoidTradesFromTime {
        from_matched_at_inclusive: DateTime,
        reason: &'static str,
    },
}

impl BookCommandKind {
    fn into_command_kind(self) -> CommandKind {
        match self {
            Self::OpenMarket { .. } | Self::UnsuspendMarket { .. } => CommandKind::SetMarketState {
                state: MarketState::Open,
            },
            Self::SetLivePhase { .. } => CommandKind::SetMarketPhase {
                phase: MarketPhase::Live,
            },
            Self::SuspendMarket { .. } => CommandKind::SetMarketState {
                state: MarketState::Suspended,
            },
            Self::CloseMarket => CommandKind::SetMarketState {
                state: MarketState::Closed,
            },
            Self::VoidMarket { reason } => CommandKind::VoidMarket {
                reason: reason.to_string(),
            },
            Self::PlaceOrder {
                order_id,
                account_id,
                runner_id,
                side,
                price,
                stake,
                persistence,
                time_in_force,
            } => CommandKind::PlaceOrder {
                runner_id,
                account_id,
                client_order_id: Some(ClientOrderId(order_id.0)),
                side,
                odds: price,
                stake,
                persistence,
                time_in_force,
            },
            Self::CancelOrder {
                order_id,
                account_id,
            } => CommandKind::CancelOrder {
                account_id,
                order_id,
            },
            Self::ReplaceOrder {
                old_order_id,
                account_id,
                new_price,
                new_stake,
                ..
            } => CommandKind::ReplaceOrder {
                account_id,
                order_id: old_order_id,
                new_odds: Some(new_price),
                new_stake: Some(new_stake),
            },
            Self::RemoveRunner {
                runner_id,
                reduction_factor_bps,
            } => CommandKind::RemoveRunner {
                runner_id,
                reduction_factor_bps,
            },
            Self::SettleMarket {
                runner_results,
                dead_heat_divisor,
            } => CommandKind::SettleMarket {
                runner_results,
                dead_heat_divisor,
            },
            Self::VoidTradesFromTime {
                from_matched_at_inclusive,
                reason,
            } => CommandKind::VoidTradesFromTime {
                from_matched_at_inclusive,
                reason: reason.to_string(),
            },
        }
    }
}

fn cmd(market_id: MarketId, cmd_id: u64, kind: BookCommandKind) -> TestCommand {
    TestCommand {
        cmd: Command {
            correlation_id: Some(CorrelationId(cmd_id.to_string())),
            metadata: None,
            market_id,
            kind: kind.into_command_kind(),
        },
    }
}

trait BookTestExt {
    /// Apply a command and expect success. Panics on rejection.
    fn apply_ok(&mut self, tc: TestCommand) -> (Vec<BookEventEnvelope>, CommandResponse);
    /// Apply a command and expect rejection. Panics on success.
    fn apply_err(&mut self, tc: TestCommand) -> BookError;
}

impl BookTestExt for Book {
    fn apply_ok(&mut self, tc: TestCommand) -> (Vec<BookEventEnvelope>, CommandResponse) {
        match self.handle(&tc.cmd) {
            Ok((events, resp)) => {
                self.apply_all_events(&events);
                (events, resp)
            }
            Err(e) => panic!("expected success but got error: {:?}", e),
        }
    }

    fn apply_err(&mut self, tc: TestCommand) -> BookError {
        match self.handle(&tc.cmd) {
            Ok(_) => panic!("expected error but got success"),
            Err(e) => e,
        }
    }
}

fn place_order(
    order_id: u64,
    account_id: u64,
    runner_id: u32,
    side: Side,
    price: u32,
    stake: i64,
) -> BookCommandKind {
    BookCommandKind::PlaceOrder {
        order_id: OrderId(order_id),
        account_id: AccountId::from(account_id),
        runner_id: RunnerId(runner_id),
        side,
        price: OddsX10000(price),
        stake: Money(stake),
        persistence: Persistence::Persist,
        time_in_force: TimeInForce::Gtc,
    }
}

fn place_order_fok(
    order_id: u64,
    account_id: u64,
    runner_id: u32,
    side: Side,
    price: u32,
    stake: i64,
    min_fill_size: Option<i64>,
) -> BookCommandKind {
    BookCommandKind::PlaceOrder {
        order_id: OrderId(order_id),
        account_id: AccountId::from(account_id),
        runner_id: RunnerId(runner_id),
        side,
        price: OddsX10000(price),
        stake: Money(stake),
        persistence: Persistence::Persist,
        time_in_force: TimeInForce::FillOrKill {
            min_fill: min_fill_size.map(|v| Quantity(v as u64)),
        },
    }
}

fn has_event<F: Fn(&BookEvent) -> bool>(events: &[BookEventEnvelope], pred: F) -> bool {
    events.iter().any(|e| pred(&e.event))
}

// ============================================================================
// Market State Tests
// ============================================================================

mod market_state {
    use super::*;

    #[derive(Debug, Clone, Copy)]
    enum SelfState {
        Open,
        Live,
        Suspended,
        Closed,
    }

    #[derive(Debug, Clone, Copy)]
    enum BookVariant {
        TwoRunner,
        MultiRunner,
        BinaryYes,
    }

    fn build_book_in_self_state(state: SelfState) -> Book {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);
        match state {
            SelfState::Open => {}
            SelfState::Live => {
                book.apply_ok(cmd(
                    mid,
                    1,
                    BookCommandKind::SetLivePhase { reason: "GO_LIVE" },
                ));
            }
            SelfState::Suspended => {
                book.apply_ok(cmd(
                    mid,
                    1,
                    BookCommandKind::SuspendMarket { reason: "PAUSE" },
                ));
            }
            SelfState::Closed => {
                book.apply_ok(cmd(mid, 1, BookCommandKind::CloseMarket));
            }
        }
        book
    }

    fn build_variant_book_in_self_state(variant: BookVariant, state: SelfState) -> Book {
        let mid = MarketId(1);
        let mut book = match variant {
            BookVariant::TwoRunner => Book::new(mid, [RunnerId(1), RunnerId(2)]),
            BookVariant::MultiRunner => {
                Book::new_multi_runner(mid, [RunnerId(1), RunnerId(2), RunnerId(3)])
            }
            BookVariant::BinaryYes => Book::new_binary_yes(mid, RunnerId(1), RunnerId(2), 10_000),
        };
        match state {
            SelfState::Open => {}
            SelfState::Live => {
                book.apply_ok(cmd(
                    mid,
                    1,
                    BookCommandKind::SetLivePhase { reason: "GO_LIVE" },
                ));
            }
            SelfState::Suspended => {
                book.apply_ok(cmd(
                    mid,
                    1,
                    BookCommandKind::SuspendMarket { reason: "PAUSE" },
                ));
            }
            SelfState::Closed => {
                book.apply_ok(cmd(mid, 1, BookCommandKind::CloseMarket));
            }
        }
        book
    }

    fn command_for_self_state(state: SelfState) -> CommandKind {
        match state {
            SelfState::Open => CommandKind::SetMarketState {
                state: MarketState::Open,
            },
            SelfState::Live => CommandKind::SetMarketPhase {
                phase: MarketPhase::Live,
            },
            SelfState::Suspended => CommandKind::SetMarketState {
                state: MarketState::Suspended,
            },
            SelfState::Closed => CommandKind::SetMarketState {
                state: MarketState::Closed,
            },
        }
    }

    fn book_state_for_self_state(state: SelfState) -> BookMarketState {
        match state {
            SelfState::Open => BookMarketState::Open,
            SelfState::Live => BookMarketState::Open,
            SelfState::Suspended => BookMarketState::Suspended,
            SelfState::Closed => BookMarketState::Closed,
        }
    }

    fn book_phase_for_self_state(state: SelfState) -> MarketPhase {
        match state {
            SelfState::Live => MarketPhase::Live,
            _ => MarketPhase::Pre,
        }
    }

    fn requested_state_for_self_state(state: SelfState) -> RequestedBookState {
        match state {
            SelfState::Open => RequestedBookState::Market(MarketState::Open),
            SelfState::Live => RequestedBookState::Phase(MarketPhase::Live),
            SelfState::Suspended => RequestedBookState::Market(MarketState::Suspended),
            SelfState::Closed => RequestedBookState::Market(MarketState::Closed),
        }
    }

    // NOTE: cannot_trade_when_suspended moved to scenario_tests.rs::market_state module

    #[test]
    fn terminal_states_reject_state_changes() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, BookCommandKind::CloseMarket));

        let err = book.apply_err(cmd(
            mid,
            2,
            BookCommandKind::OpenMarket { reason: "REOPEN" },
        ));
        assert_eq!(err.reason, RejectReason::MarketTerminal);
    }

    #[test]
    fn set_market_state_returns_no_change_when_already_in_target_state() {
        let states = [
            SelfState::Open,
            SelfState::Live,
            SelfState::Suspended,
            SelfState::Closed,
        ];

        for state in states {
            let mut book = build_book_in_self_state(state);
            let err = book.apply_err(TestCommand {
                cmd: Command {
                    correlation_id: Some(CorrelationId(format!("self-{state:?}"))),
                    metadata: None,
                    market_id: MarketId(1),
                    kind: command_for_self_state(state),
                },
            });

            assert_eq!(
                err.reason,
                RejectReason::NoChange,
                "expected NoChange for self transition {state:?}"
            );
            assert_eq!(
                err.detail,
                Some(BookErrorDetail::MarketCommand {
                    current_state: book_state_for_self_state(state),
                    current_phase: book_phase_for_self_state(state),
                    requested_state: requested_state_for_self_state(state),
                }),
                "expected state detail for self transition {state:?}"
            );
        }
    }

    #[test]
    fn invalid_close_batch_size_rejected() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        let err = book.apply_err(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(1.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::CloseMarket {
                    batch_max_events: MIN_CLOSE_BATCH_EVENTS - 1,
                },
            },
        });

        assert_eq!(err.reason, RejectReason::InvalidBatchSize);
    }

    #[test]
    fn close_market_rejected_when_already_closing() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Back, 20000, 100)));

        book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(2.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::CloseMarket {
                    batch_max_events: 2,
                },
            },
        });

        let err = book.apply_err(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(3.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::CloseMarket {
                    batch_max_events: 2,
                },
            },
        });

        assert_eq!(err.reason, RejectReason::NoChange);
        assert_eq!(
            err.detail,
            Some(BookErrorDetail::MarketCommand {
                current_state: BookMarketState::Closed,
                current_phase: MarketPhase::Pre,
                requested_state: RequestedBookState::Market(MarketState::Closed),
            })
        );
    }

    #[test]
    fn halt_blocks_commands_until_resume() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(1.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::HaltMarket { reason: 1 },
            },
        });

        let err = book.apply_err(cmd(mid, 2, place_order(1, 1, 1, Side::Back, 20000, 100)));
        assert_eq!(err.reason, RejectReason::MarketHalted);

        book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(3.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::ResumeMarket,
            },
        });

        let (events, _) = book.apply_ok(cmd(mid, 4, place_order(2, 1, 1, Side::Back, 20000, 100)));
        assert!(has_event(&events, |e| matches!(
            e,
            BookEvent::OrderAccepted { .. }
        )));
    }

    #[test]
    fn repeated_halt_returns_no_change() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(1.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::HaltMarket { reason: 1 },
            },
        });

        let err = book.apply_err(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(2.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::HaltMarket { reason: 1 },
            },
        });

        assert_eq!(err.reason, RejectReason::MarketAlreadyHalted);
        assert_eq!(
            err.detail,
            Some(BookErrorDetail::MarketCommand {
                current_state: BookMarketState::Halted,
                current_phase: MarketPhase::Pre,
                requested_state: RequestedBookState::Halted,
            })
        );
    }

    #[test]
    fn resume_from_non_halted_states_returns_market_not_halted() {
        let variants = [
            BookVariant::TwoRunner,
            BookVariant::MultiRunner,
            BookVariant::BinaryYes,
        ];
        let states = [SelfState::Open, SelfState::Live, SelfState::Suspended];

        for variant in variants {
            for state in states {
                let mut book = build_variant_book_in_self_state(variant, state);
                let err = book.apply_err(TestCommand {
                    cmd: Command {
                        correlation_id: Some(CorrelationId(format!(
                            "resume-{variant:?}-{state:?}"
                        ))),
                        metadata: None,
                        market_id: MarketId(1),
                        kind: CommandKind::ResumeMarket,
                    },
                });

                assert_eq!(
                    err.reason,
                    RejectReason::MarketNotHalted,
                    "expected MarketNotHalted for {variant:?} in {state:?}"
                );
                assert_eq!(err.detail, None);
            }
        }
    }

    #[test]
    fn repeated_resume_returns_market_not_halted() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(1.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::HaltMarket { reason: 1 },
            },
        });
        book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(2.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::ResumeMarket,
            },
        });

        let err = book.apply_err(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(3.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::ResumeMarket,
            },
        });

        assert_eq!(err.reason, RejectReason::MarketNotHalted);
        assert_eq!(err.detail, None);
    }

    #[test]
    fn repeated_resume_returns_market_not_halted_multi_runner() {
        let mid = MarketId(11);
        let mut book = Book::new_multi_runner(mid, [RunnerId(1), RunnerId(2), RunnerId(3)]);

        book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(1.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::HaltMarket { reason: 1 },
            },
        });
        book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(2.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::ResumeMarket,
            },
        });

        let err = book.apply_err(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(3.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::ResumeMarket,
            },
        });

        assert_eq!(err.reason, RejectReason::MarketNotHalted);
        assert_eq!(err.detail, None);
    }

    #[test]
    fn repeated_resume_returns_market_not_halted_binary_yes() {
        let mid = MarketId(12);
        let mut book = Book::new_binary_yes(mid, RunnerId(1), RunnerId(2), 10_000);

        book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(1.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::HaltMarket { reason: 1 },
            },
        });
        book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(2.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::ResumeMarket,
            },
        });

        let err = book.apply_err(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(3.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::ResumeMarket,
            },
        });

        assert_eq!(err.reason, RejectReason::MarketNotHalted);
        assert_eq!(err.detail, None);
    }

    #[test]
    fn repeated_suspend_returns_no_change() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        book.apply_ok(cmd(
            mid,
            1,
            BookCommandKind::SuspendMarket { reason: "PAUSE" },
        ));

        let err = book.apply_err(cmd(
            mid,
            2,
            BookCommandKind::SuspendMarket { reason: "PAUSE" },
        ));

        assert_eq!(err.reason, RejectReason::NoChange);
        assert_eq!(
            err.detail,
            Some(BookErrorDetail::MarketCommand {
                current_state: BookMarketState::Suspended,
                current_phase: MarketPhase::Pre,
                requested_state: RequestedBookState::Market(MarketState::Suspended),
            })
        );
    }

    #[test]
    fn repeated_void_returns_no_change() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, BookCommandKind::VoidMarket { reason: "V" }));

        let err = book.apply_err(cmd(mid, 2, BookCommandKind::VoidMarket { reason: "V" }));

        assert_eq!(err.reason, RejectReason::MarketAlreadyVoided);
        assert_eq!(
            err.detail,
            Some(BookErrorDetail::MarketCommand {
                current_state: BookMarketState::Voided,
                current_phase: MarketPhase::Pre,
                requested_state: RequestedBookState::Voided,
            })
        );
    }

    #[test]
    fn suspend_from_open_lapses_lapse_orders_and_keeps_persist_orders() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(1.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::PlaceOrder {
                    runner_id: RunnerId(1),
                    account_id: AccountId::from(1),
                    client_order_id: Some(ClientOrderId(1)),
                    side: Side::Back,
                    odds: OddsX10000(20000),
                    stake: Money(100),
                    persistence: Persistence::Lapse,
                    time_in_force: TimeInForce::Gtc,
                },
            },
        });

        book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(2.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::PlaceOrder {
                    runner_id: RunnerId(1),
                    account_id: AccountId::from(2),
                    client_order_id: Some(ClientOrderId(2)),
                    side: Side::Back,
                    odds: OddsX10000(19000),
                    stake: Money(100),
                    persistence: Persistence::Persist,
                    time_in_force: TimeInForce::Gtc,
                },
            },
        });

        let (events, _) = book.apply_ok(cmd(
            mid,
            3,
            BookCommandKind::SuspendMarket { reason: "PAUSE" },
        ));

        assert!(has_event(&events, |e| matches!(
            e,
            BookEvent::OrderCancelledBatched {
                cancelled_orders,
                cancel_cause,
                ..
            } if *cancel_cause == CancelCause::SuspendLapse
                && cancelled_order_ids(cancelled_orders) == vec![OrderId(1)]
        )));
        assert_eq!(book.market_state(), BookMarketState::Suspended);
        assert!(book.get_order(OrderId(1)).is_none());
        assert!(book.get_order(OrderId(2)).is_some());
    }

    #[test]
    fn set_live_phase_from_open_lapses_lapse_orders_and_keeps_persist_orders() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(1.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::PlaceOrder {
                    runner_id: RunnerId(1),
                    account_id: AccountId::from(1),
                    client_order_id: Some(ClientOrderId(1)),
                    side: Side::Back,
                    odds: OddsX10000(20000),
                    stake: Money(100),
                    persistence: Persistence::Lapse,
                    time_in_force: TimeInForce::Gtc,
                },
            },
        });

        book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(2.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::PlaceOrder {
                    runner_id: RunnerId(1),
                    account_id: AccountId::from(2),
                    client_order_id: Some(ClientOrderId(2)),
                    side: Side::Back,
                    odds: OddsX10000(19000),
                    stake: Money(100),
                    persistence: Persistence::Persist,
                    time_in_force: TimeInForce::Gtc,
                },
            },
        });

        let (events, _) = book.apply_ok(cmd(
            mid,
            3,
            BookCommandKind::SetLivePhase { reason: "GO_LIVE" },
        ));

        assert!(matches!(
            events.first().map(|env| &env.event),
            Some(BookEvent::MarketPhaseChanged {
                to: MarketPhase::Live,
                ..
            })
        ));
        assert!(has_event(&events, |e| matches!(
            e,
            BookEvent::OrderCancelledBatched {
                cancelled_orders,
                cancel_cause,
                ..
            } if *cancel_cause == CancelCause::InPlayLapse
                && cancelled_order_ids(cancelled_orders) == vec![OrderId(1)]
        )));
        assert_eq!(book.market_state(), BookMarketState::Open);
        assert_eq!(book.market_phase(), MarketPhase::Live);
        assert!(book.get_order(OrderId(1)).is_none());
        assert!(book.get_order(OrderId(2)).is_some());
    }

    #[test]
    fn suspend_from_open_emits_order_lapsed_in_batches() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);
        let total = DEFAULT_CLOSE_BATCH_EVENTS as u64 + 3;

        for i in 0..total {
            book.apply_ok(TestCommand {
                cmd: Command {
                    correlation_id: Some(CorrelationId((i + 1).to_string())),
                    metadata: None,
                    market_id: mid,
                    kind: CommandKind::PlaceOrder {
                        runner_id: RunnerId(1),
                        account_id: AccountId::from(1),
                        client_order_id: Some(ClientOrderId(i + 1)),
                        side: Side::Back,
                        odds: OddsX10000(20000),
                        stake: Money(10),
                        persistence: Persistence::Lapse,
                        time_in_force: TimeInForce::Gtc,
                    },
                },
            });
        }

        let (events, _) = book.apply_ok(cmd(
            mid,
            10_000,
            BookCommandKind::SuspendMarket { reason: "PAUSE" },
        ));

        let mut lapsed_batches = Vec::new();
        for env in &events {
            if let BookEvent::OrderCancelledBatched {
                cancelled_orders,
                cancel_cause,
                ..
            } = &env.event
            {
                assert_eq!(*cancel_cause, CancelCause::SuspendLapse);
                assert!(cancelled_orders.len() <= DEFAULT_CLOSE_BATCH_EVENTS as usize);
                lapsed_batches.push(cancelled_orders.len());
            }
        }

        assert_eq!(lapsed_batches.len(), 1);
        assert_eq!(lapsed_batches[0], DEFAULT_CLOSE_BATCH_EVENTS as usize);
        assert_eq!(book.market_state(), BookMarketState::Suspended);

        let (cont_events, _) = book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(10_001.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::ContinueLapseOrders,
            },
        });

        let final_batch = cont_events.iter().find_map(|env| match &env.event {
            BookEvent::OrderCancelledBatched {
                cancelled_orders, ..
            } => Some(cancelled_orders.len()),
            _ => None,
        });
        assert_eq!(final_batch, Some(3));
        assert_eq!(book.market_state(), BookMarketState::Suspended);
    }

    #[test]
    fn set_live_phase_from_open_emits_order_lapsed_in_batches() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);
        let total = DEFAULT_CLOSE_BATCH_EVENTS as u64 + 3;

        for i in 0..total {
            book.apply_ok(TestCommand {
                cmd: Command {
                    correlation_id: Some(CorrelationId((i + 1).to_string())),
                    metadata: None,
                    market_id: mid,
                    kind: CommandKind::PlaceOrder {
                        runner_id: RunnerId(1),
                        account_id: AccountId::from(1),
                        client_order_id: Some(ClientOrderId(i + 1)),
                        side: Side::Back,
                        odds: OddsX10000(20000),
                        stake: Money(10),
                        persistence: Persistence::Lapse,
                        time_in_force: TimeInForce::Gtc,
                    },
                },
            });
        }

        let (events, _) = book.apply_ok(cmd(
            mid,
            10_000,
            BookCommandKind::SetLivePhase { reason: "GO_LIVE" },
        ));

        assert!(matches!(
            events.first().map(|env| &env.event),
            Some(BookEvent::MarketPhaseChanged {
                to: MarketPhase::Live,
                ..
            })
        ));

        let mut lapsed_batches = Vec::new();
        for env in &events {
            if let BookEvent::OrderCancelledBatched {
                cancelled_orders,
                cancel_cause,
                ..
            } = &env.event
            {
                assert_eq!(*cancel_cause, CancelCause::InPlayLapse);
                assert!(cancelled_orders.len() <= DEFAULT_CLOSE_BATCH_EVENTS as usize);
                lapsed_batches.push(cancelled_orders.len());
            }
        }

        assert_eq!(lapsed_batches.len(), 1);
        assert_eq!(lapsed_batches[0], DEFAULT_CLOSE_BATCH_EVENTS as usize);
        assert_eq!(book.market_state(), BookMarketState::Open);
        assert_eq!(book.market_phase(), MarketPhase::Live);

        let (cont_events, _) = book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(10_001.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::ContinueLapseOrders,
            },
        });

        let final_batch = cont_events.iter().find_map(|env| match &env.event {
            BookEvent::OrderCancelledBatched {
                cancelled_orders, ..
            } => Some(cancelled_orders.len()),
            _ => None,
        });
        assert_eq!(final_batch, Some(3));
        assert_eq!(book.market_state(), BookMarketState::Open);
        assert_eq!(book.market_phase(), MarketPhase::Live);
    }

    #[test]
    fn void_market_emits_order_voided_in_batches() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);
        let total = DEFAULT_CLOSE_BATCH_EVENTS as u64 + 5;

        for i in 0..total {
            book.apply_ok(TestCommand {
                cmd: Command {
                    correlation_id: Some(CorrelationId((i + 1).to_string())),
                    metadata: None,
                    market_id: mid,
                    kind: CommandKind::PlaceOrder {
                        runner_id: RunnerId(1),
                        account_id: AccountId::from(1),
                        client_order_id: Some(ClientOrderId(i + 1)),
                        side: Side::Back,
                        odds: OddsX10000(20000),
                        stake: Money(10),
                        persistence: Persistence::Persist,
                        time_in_force: TimeInForce::Gtc,
                    },
                },
            });
        }

        let (events, _) = book.apply_ok(cmd(
            mid,
            20_000,
            BookCommandKind::VoidMarket { reason: "V" },
        ));

        let mut voided_batches = Vec::new();
        for env in &events {
            if let BookEvent::OrderCancelledBatched {
                cancelled_orders,
                cancel_cause,
                ..
            } = &env.event
            {
                assert_eq!(*cancel_cause, CancelCause::MarketVoid);
                assert!(cancelled_orders.len() <= DEFAULT_CLOSE_BATCH_EVENTS as usize);
                voided_batches.push(cancelled_orders.len());
            }
        }

        assert_eq!(voided_batches.len(), 1);
        assert_eq!(voided_batches[0], DEFAULT_CLOSE_BATCH_EVENTS as usize);
        assert_eq!(book.market_state(), BookMarketState::Voided);

        let (cont_events, _) = book.apply_ok(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(20_001.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::ContinueVoidOrders,
            },
        });
        let final_batch = cont_events.iter().find_map(|env| match &env.event {
            BookEvent::OrderCancelledBatched {
                cancelled_orders, ..
            } => Some(cancelled_orders.len()),
            _ => None,
        });
        assert_eq!(final_batch, Some(5));
        assert_eq!(book.market_state(), BookMarketState::Voided);
    }
}

// ============================================================================
// Order Lifecycle Tests
// ============================================================================

mod order_lifecycle {
    use super::*;

    #[test]
    fn place_order_accepted() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        let (events, _) = book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Back, 20000, 100)));

        assert!(has_event(&events, |e| matches!(
            e,
            BookEvent::OrderAccepted {
                order_id: OrderId(1),
                ..
            }
        )));
        assert!(book.is_resting(OrderId(1)));
    }

    // NOTE: reject_invalid_tick_price, reject_unknown_runner, reject_zero_stake
    // moved to scenario_tests.rs::validation module

    #[test]
    fn duplicate_client_order_id_still_accepts_new_order() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        let (first_events, _) =
            book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Back, 20000, 100)));
        let (second_events, _) =
            book.apply_ok(cmd(mid, 2, place_order(1, 1, 1, Side::Back, 20000, 100)));

        let first_id = first_events.iter().find_map(|env| match env.event {
            BookEvent::OrderAccepted { order_id, .. } => Some(order_id),
            _ => None,
        });
        let second_id = second_events.iter().find_map(|env| match env.event {
            BookEvent::OrderAccepted { order_id, .. } => Some(order_id),
            _ => None,
        });
        assert!(first_id.is_some());
        assert!(second_id.is_some());
        assert_ne!(first_id, second_id);
    }

    #[test]
    fn cancel_order() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Back, 20000, 100)));

        let (events, _) = book.apply_ok(cmd(
            mid,
            2,
            BookCommandKind::CancelOrder {
                order_id: OrderId(1),
                account_id: AccountId::from(1),
            },
        ));

        assert!(has_event(&events, |e| matches!(
            e,
            BookEvent::OrderCancelled {
                cancelled_order,
                cancel_cause,
                ..
            } if *cancel_cause == CancelCause::UserCancel
                && cancelled_order.order_id == OrderId(1)
        )));
        assert!(!book.is_resting(OrderId(1)));
    }

    #[test]
    fn cancel_requires_matching_account() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Back, 20000, 100)));

        let err = book.apply_err(cmd(
            mid,
            2,
            BookCommandKind::CancelOrder {
                order_id: OrderId(1),
                account_id: AccountId::from(99), // Wrong account
            },
        ));
        assert_eq!(err.reason, RejectReason::NotOrderOwner);
    }

    #[test]
    fn replace_order() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Back, 20000, 100)));

        let (events, _) = book.apply_ok(cmd(
            mid,
            2,
            BookCommandKind::ReplaceOrder {
                old_order_id: OrderId(1),
                account_id: AccountId::from(1),
                runner_id: rid,
                side: Side::Back,
                new_price: OddsX10000(20200), // 2.02
                new_stake: Money(150),
                persistence: Persistence::Persist,
            },
        ));

        assert!(has_event(&events, |e| matches!(
            e,
            BookEvent::OrderCancelled {
                cancelled_order,
                cancel_cause,
                ..
            } if *cancel_cause == CancelCause::Replace
                && cancelled_order.order_id == OrderId(1)
        )));
        assert!(has_event(&events, |e| matches!(
            e,
            BookEvent::OrderAccepted {
                order_id: OrderId(2),
                ..
            }
        )));
        assert!(!book.is_resting(OrderId(1)));
        assert!(book.is_resting(OrderId(2)));
    }

    #[test]
    fn replace_order_no_change_is_rejected_as_nochange() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Back, 20000, 100)));

        let err = book.apply_err(cmd(
            mid,
            2,
            BookCommandKind::ReplaceOrder {
                old_order_id: OrderId(1),
                account_id: AccountId::from(1),
                runner_id: rid,
                side: Side::Back,
                new_price: OddsX10000(20000),
                new_stake: Money(100),
                persistence: Persistence::Persist,
            },
        ));
        assert_eq!(err.reason, RejectReason::NoChange);
        assert!(book.is_resting(OrderId(1)));
        assert_eq!(book.get_order(OrderId(1)).unwrap().price, OddsX10000(20000));
        assert_eq!(book.get_order(OrderId(1)).unwrap().remaining(), Money(100));
    }

    #[test]
    fn replace_order_no_change_is_rejected_as_nochange_multi_runner() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2), RunnerId(3)]);

        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Back, 20000, 100)));

        let err = book.apply_err(cmd(
            mid,
            2,
            BookCommandKind::ReplaceOrder {
                old_order_id: OrderId(1),
                account_id: AccountId::from(1),
                runner_id: rid,
                side: Side::Back,
                new_price: OddsX10000(20000),
                new_stake: Money(100),
                persistence: Persistence::Persist,
            },
        ));
        assert_eq!(err.reason, RejectReason::NoChange);
        assert!(book.is_resting(OrderId(1)));
        let order = book
            .get_order(OrderId(1))
            .expect("order should still exist");
        assert_eq!(order.price, OddsX10000(20000));
        assert_eq!(order.remaining(), Money(100));
    }

    #[test]
    fn replace_order_no_change_with_omitted_fields_is_rejected_as_nochange() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Back, 20000, 100)));

        let err = book.apply_err(TestCommand {
            cmd: Command {
                correlation_id: Some(CorrelationId(2.to_string())),
                metadata: None,
                market_id: mid,
                kind: CommandKind::ReplaceOrder {
                    account_id: AccountId::from(1),
                    order_id: OrderId(1),
                    new_odds: None,
                    new_stake: None,
                },
            },
        });

        assert_eq!(err.reason, RejectReason::NoChange);
        assert!(book.is_resting(OrderId(1)));
        assert_eq!(book.get_order(OrderId(1)).unwrap().price, OddsX10000(20000));
        assert_eq!(book.get_order(OrderId(1)).unwrap().remaining(), Money(100));
    }

    #[test]
    fn replace_requires_matching_account() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Back, 20000, 100)));

        let err = book.apply_err(cmd(
            mid,
            2,
            BookCommandKind::ReplaceOrder {
                old_order_id: OrderId(1),
                account_id: AccountId::from(2),
                runner_id: rid,
                side: Side::Back,
                new_price: OddsX10000(20200),
                new_stake: Money(150),
                persistence: Persistence::Persist,
            },
        ));

        assert_eq!(err.reason, RejectReason::NotOrderOwner);
    }

    #[test]
    fn replace_nonexistent_order_rejected() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        let err = book.apply_err(cmd(
            mid,
            1,
            BookCommandKind::ReplaceOrder {
                old_order_id: OrderId(99),
                account_id: AccountId::from(1),
                runner_id: rid,
                side: Side::Back,
                new_price: OddsX10000(20200),
                new_stake: Money(150),
                persistence: Persistence::Persist,
            },
        ));

        assert_eq!(err.reason, RejectReason::OrderNotFound);
    }
}

// ============================================================================
// Time-in-Force Tests
// ============================================================================

mod time_in_force {
    use super::*;

    #[test]
    fn gtc_rests_unmatched() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        let (events, resp) =
            book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Back, 20000, 100)));

        if let Some(CommandResponseKind::PlaceOrder(result)) = resp.kind {
            assert_eq!(result.matched, Money(0));
            assert_eq!(result.remaining, Money(100));
            assert_eq!(
                result.final_order_state,
                Some(BookOrderState::ExecutableUnmatched)
            );
        }

        let order_events: Vec<_> = events
            .iter()
            .filter(|e| {
                matches!(
                    e.event,
                    BookEvent::OrderAccepted {
                        order_id: OrderId(1),
                        ..
                    }
                ) || matches!(
                    e.event,
                    BookEvent::TradeMatched {
                        order_id: OrderId(1),
                        ..
                    }
                )
            })
            .map(|e| &e.event)
            .collect();
        assert_eq!(
            order_events.len(),
            1,
            "Expected only a single OrderAccepted event for unmatched GTC"
        );
        assert!(
            matches!(order_events[0], BookEvent::OrderAccepted { .. }),
            "Unmatched GTC should emit OrderAccepted only"
        );

        assert!(book.is_resting(OrderId(1)));
    }

    #[test]
    fn gtc_full_match_has_no_order_updated_for_taker() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        // Resting maker.
        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 100)));

        // Aggressing taker fully matches.
        let (events, resp) =
            book.apply_ok(cmd(mid, 2, place_order(2, 2, 1, Side::Back, 20000, 100)));
        if let Some(CommandResponseKind::PlaceOrder(result)) = resp.kind {
            assert_eq!(result.matched, Money(100));
            assert_eq!(result.remaining, Money(0));
            assert_eq!(
                result.final_order_state,
                Some(BookOrderState::ExecutionComplete)
            );
        }

        let order_events: Vec<_> = events
            .iter()
            .filter(|e| {
                matches!(
                    e.event,
                    BookEvent::OrderAccepted {
                        order_id: OrderId(2),
                        ..
                    }
                ) || matches!(
                    e.event,
                    BookEvent::TradeMatched {
                        order_id: OrderId(2),
                        ..
                    }
                )
            })
            .map(|e| &e.event)
            .collect();

        assert_eq!(
            order_events.len(),
            2,
            "Expected OrderAccepted + TradeMatched only"
        );
        assert!(
            matches!(order_events[0], BookEvent::OrderAccepted { .. }),
            "First taker event should be OrderAccepted"
        );
        assert!(
            matches!(order_events[1], BookEvent::TradeMatched { .. }),
            "Second taker event should be TradeMatched"
        );
    }

    #[test]
    fn fok_full_fill_succeeds() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        // Lay enough liquidity
        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 100)));

        // FOK for exact amount
        let (_events, resp) = book.apply_ok(cmd(
            mid,
            2,
            place_order_fok(2, 2, 1, Side::Back, 20000, 100, None),
        ));

        if let Some(CommandResponseKind::PlaceOrder(result)) = resp.kind {
            assert!(result.accepted);
            assert_eq!(result.matched, Money(100));
            assert_eq!(result.remaining, Money(0));
        }
    }

    #[test]
    fn fok_insufficient_liquidity_rejected() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        // Only 50 available
        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 50)));

        // FOK for 100 - should reject
        let err = book.apply_err(cmd(
            mid,
            2,
            place_order_fok(2, 2, 1, Side::Back, 20000, 100, None),
        ));
        assert_eq!(err.reason, RejectReason::WouldNotFillFok);
    }

    #[test]
    fn fok_with_min_fill_size_partial_ok() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        // 80 available
        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 80)));

        // FOK for 100 with minFillSize=50 - should succeed with 80 filled, 20 cancelled
        let (events, resp) = book.apply_ok(cmd(
            mid,
            2,
            place_order_fok(2, 2, 1, Side::Back, 20000, 100, Some(50)),
        ));

        if let Some(CommandResponseKind::PlaceOrder(result)) = resp.kind {
            assert!(result.accepted);
            assert_eq!(result.matched, Money(80));
            assert_eq!(result.remaining, Money(0)); // Remainder was cancelled
        }

        // Remainder should be cancelled
        assert!(has_event(&events, |e| matches!(
            e,
            BookEvent::OrderCancelled { cancel_cause, .. } if *cancel_cause == CancelCause::FokRemainder
        )));

        // Order should not be resting
        assert!(!book.is_resting(OrderId(2)));
    }

    #[test]
    fn fok_with_min_fill_size_below_minimum_rejected() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        // Only 40 available
        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 40)));

        // FOK for 100 with minFillSize=50 - should reject (40 < 50)
        let err = book.apply_err(cmd(
            mid,
            2,
            place_order_fok(2, 2, 1, Side::Back, 20000, 100, Some(50)),
        ));
        assert_eq!(err.reason, RejectReason::WouldNotFillFok);
    }

    #[test]
    fn fok_no_liquidity_rejected() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        // No liquidity at all
        let err = book.apply_err(cmd(
            mid,
            1,
            place_order_fok(1, 1, 1, Side::Back, 20000, 100, None),
        ));
        assert_eq!(err.reason, RejectReason::WouldNotFillFok);
    }

    #[test]
    fn fok_partial_fill_event_sequence() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        // 80 available at lay
        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 80)));

        // FOK for 100 with minFillSize=50 - should accept 80, cancel 20 remainder
        let (events, resp) = book.apply_ok(cmd(
            mid,
            2,
            place_order_fok(2, 2, 1, Side::Back, 20000, 100, Some(50)),
        ));

        // Check response
        if let Some(CommandResponseKind::PlaceOrder(result)) = resp.kind {
            assert!(result.accepted);
            assert_eq!(result.matched, Money(80));
            assert_eq!(result.remaining, Money(0));
        }

        // Check event sequence: OrderAccepted → TradeMatched → OrderCancelled
        let event_kinds: Vec<_> = events.iter().map(|e| &e.event).collect();

        // Find the events for our order (order_id=2)
        let mut order_events = Vec::new();
        for event in &event_kinds {
            match event {
                BookEvent::OrderAccepted { order_id, .. } if *order_id == OrderId(2) => {
                    order_events.push(event)
                }
                BookEvent::TradeMatched { order_id, .. } if *order_id == OrderId(2) => {
                    order_events.push(event)
                }
                BookEvent::OrderCancelled {
                    cancelled_order, ..
                } if cancelled_order.order_id == OrderId(2) => order_events.push(event),
                _ => {}
            }
        }

        // Should be exactly 3 events: accept, match, cancel
        assert_eq!(
            order_events.len(),
            3,
            "Expected 3 events for partial FOK fill"
        );

        // Check order: OrderAccepted, then TradeMatched, then OrderCancelled
        assert!(
            matches!(order_events[0], BookEvent::OrderAccepted { .. }),
            "First event should be OrderAccepted"
        );
        assert!(
            matches!(order_events[1], BookEvent::TradeMatched { .. }),
            "Second event should be TradeMatched"
        );
        assert!(
            matches!(order_events[2], BookEvent::OrderCancelled { cancel_cause, .. } if *cancel_cause == CancelCause::FokRemainder),
            "Third event should be OrderCancelled with FOK_REMAINDER"
        );

        // Order should not be resting
        assert!(!book.is_resting(OrderId(2)));
    }
}

// ============================================================================
// Trade Role Tests
// ============================================================================

mod trade_roles {
    use super::*;

    #[test]
    fn multi_runner_order_can_be_taker_then_maker_with_explicit_roles() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2), RunnerId(3)]);

        // Order 1 rests as maker.
        book.apply_ok(cmd(mid, 1, place_order(1, 10, 1, Side::Lay, 20000, 50)));

        // Order 2 aggresses and is taker against order 1, with remainder resting.
        let (events_taker_fill, _) =
            book.apply_ok(cmd(mid, 2, place_order(2, 20, 1, Side::Back, 20000, 150)));

        // Order 3 now aggresses against resting remainder of order 2, so order 2 is maker.
        let (events_maker_fill, _) =
            book.apply_ok(cmd(mid, 3, place_order(3, 30, 1, Side::Lay, 20000, 70)));

        let first_trade_events: Vec<_> = events_taker_fill
            .iter()
            .filter_map(|e| match &e.event {
                BookEvent::TradeMatched {
                    order_id,
                    role,
                    runner_id,
                    side,
                    price,
                    stake,
                    counter_party,
                    remaining_stake,
                    matched_delta,
                    ..
                } => Some((
                    *order_id,
                    *role,
                    *runner_id,
                    *side,
                    *price,
                    *stake,
                    *counter_party,
                    *remaining_stake,
                    *matched_delta,
                )),
                _ => None,
            })
            .collect();
        assert_eq!(
            first_trade_events,
            vec![
                (
                    OrderId(1),
                    TradeRole::Maker,
                    rid,
                    Side::No,
                    OddsX10000(20000),
                    Money(50),
                    OrderId(2),
                    Money(0),
                    Money(50),
                ),
                (
                    OrderId(2),
                    TradeRole::Taker,
                    rid,
                    Side::Yes,
                    OddsX10000(20000),
                    Money(50),
                    OrderId(1),
                    Money(100),
                    Money(50),
                ),
            ]
        );

        let second_trade_events: Vec<_> = events_maker_fill
            .iter()
            .filter_map(|e| match &e.event {
                BookEvent::TradeMatched {
                    order_id,
                    role,
                    runner_id,
                    side,
                    price,
                    stake,
                    counter_party,
                    remaining_stake,
                    matched_delta,
                    ..
                } => Some((
                    *order_id,
                    *role,
                    *runner_id,
                    *side,
                    *price,
                    *stake,
                    *counter_party,
                    *remaining_stake,
                    *matched_delta,
                )),
                _ => None,
            })
            .collect();
        assert_eq!(
            second_trade_events,
            vec![
                (
                    OrderId(2),
                    TradeRole::Maker,
                    rid,
                    Side::Yes,
                    OddsX10000(20000),
                    Money(70),
                    OrderId(3),
                    Money(30),
                    Money(70),
                ),
                (
                    OrderId(3),
                    TradeRole::Taker,
                    rid,
                    Side::No,
                    OddsX10000(20000),
                    Money(70),
                    OrderId(2),
                    Money(0),
                    Money(70),
                ),
            ]
        );
    }

    #[test]
    fn two_runner_implied_match_emits_explicit_maker_and_taker_roles() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        // Resting maker on runner 2 creates implied liquidity for runner 1.
        book.apply_ok(cmd(mid, 1, place_order(1, 10, 2, Side::Back, 30000, 100)));

        let (events, _) = book.apply_ok(cmd(mid, 2, place_order(2, 20, 1, Side::Back, 15000, 200)));
        let trade_events: Vec<_> = events
            .iter()
            .filter_map(|e| match &e.event {
                BookEvent::TradeMatched {
                    order_id,
                    role,
                    runner_id,
                    side,
                    price,
                    stake,
                    counter_party,
                    remaining_stake,
                    matched_delta,
                    ..
                } => Some((
                    *order_id,
                    *role,
                    *runner_id,
                    *side,
                    *price,
                    *stake,
                    *counter_party,
                    *remaining_stake,
                    *matched_delta,
                )),
                _ => None,
            })
            .collect();

        assert_eq!(
            trade_events,
            vec![
                (
                    OrderId(1),
                    TradeRole::Maker,
                    RunnerId(2),
                    Side::Yes,
                    OddsX10000(30000),
                    Money(100),
                    OrderId(2),
                    Money(0),
                    Money(100),
                ),
                (
                    OrderId(2),
                    TradeRole::Taker,
                    RunnerId(1),
                    Side::Yes,
                    OddsX10000(15000),
                    Money(200),
                    OrderId(1),
                    Money(0),
                    Money(200),
                ),
            ]
        );
    }
}

// ============================================================================
// Market Depth Tests
// ============================================================================

mod market_depth {
    use super::*;

    #[test]
    fn two_runner_runner_prices_include_implied_liquidity() {
        let mid = MarketId(1);
        let runner_a = RunnerId(1);
        let runner_b = RunnerId(2);
        let mut book = Book::new(mid, [runner_a, runner_b]);

        // BACK on runner B implies an equivalent LAY on runner A at the derived price,
        // which should be visible as available-to-back liquidity on runner A.
        book.apply_ok(cmd(mid, 1, place_order(1, 1, 2, Side::Back, 20000, 1000)));

        let prices_a = book.runner_prices(runner_a, 10);
        assert_eq!(prices_a.available_to_back.len(), 1);
        assert_eq!(prices_a.available_to_back[0].price, OddsX10000(20000));
        assert_eq!(prices_a.available_to_back[0].size, Money(1000));

        let prices_b = book.runner_prices(runner_b, 10);
        assert_eq!(prices_b.available_to_lay.len(), 1);
        assert_eq!(prices_b.available_to_lay[0].price, OddsX10000(20000));
        assert_eq!(prices_b.available_to_lay[0].size, Money(1000));
    }

    #[test]
    fn two_runner_runner_prices_aggregate_direct_and_implied_at_same_price() {
        let mid = MarketId(1);
        let runner_a = RunnerId(1);
        let runner_b = RunnerId(2);
        let mut book = Book::new(mid, [runner_a, runner_b]);

        // Direct LAY on runner A shows up as available-to-back liquidity on A.
        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 1000)));

        // BACK on runner B implies more LAY liquidity on runner A at the same price.
        book.apply_ok(cmd(mid, 2, place_order(2, 2, 2, Side::Back, 20000, 500)));

        let prices_a = book.runner_prices(runner_a, 10);
        assert_eq!(prices_a.available_to_back.len(), 1);
        assert_eq!(prices_a.available_to_back[0].price, OddsX10000(20000));
        assert_eq!(prices_a.available_to_back[0].size, Money(1500));
    }

    #[test]
    fn two_runner_runner_prices_implied_size_is_taker_capacity_not_raw_maker_stake() {
        let mid = MarketId(1);
        let runner_a = RunnerId(1);
        let runner_b = RunnerId(2);
        let mut book = Book::new(mid, [runner_a, runner_b]);

        // BACK on runner B @ 3.0 with maker stake 100 implies LAY on runner A @ 1.5
        // with taker capacity 200.
        book.apply_ok(cmd(mid, 1, place_order(1, 1, 2, Side::Back, 30000, 100)));

        let prices_a = book.runner_prices(runner_a, 10);
        assert_eq!(prices_a.available_to_back.len(), 1);
        assert_eq!(prices_a.available_to_back[0].price, OddsX10000(15000));
        assert_eq!(prices_a.available_to_back[0].size, Money(200));

        // Symmetric side in an isolated book:
        // BACK on runner A @ 1.5 stake 200 implies LAY on runner B @ 3.0 stake 100.
        let mut book2 = Book::new(mid, [runner_a, runner_b]);
        book2.apply_ok(cmd(mid, 2, place_order(2, 2, 1, Side::Back, 15000, 200)));
        let prices_b = book2.runner_prices(runner_b, 10);
        assert_eq!(prices_b.available_to_back[0].price, OddsX10000(30000));
        assert_eq!(prices_b.available_to_back[0].size, Money(100));
    }

    #[test]
    fn best_prices_empty_book() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let book = Book::new(mid, [rid, RunnerId(2)]);

        assert!(book.best_back_price(rid).is_none());
        assert!(book.best_lay_price(rid).is_none());
    }

    #[test]
    fn best_back_price_is_highest_lay() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        // Lay orders at different prices
        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 50))); // 2.00
        book.apply_ok(cmd(mid, 2, place_order(2, 2, 1, Side::Lay, 19000, 50))); // 1.90
        book.apply_ok(cmd(mid, 3, place_order(3, 3, 1, Side::Lay, 21000, 50))); // 2.10

        let best = book
            .best_back_price(rid)
            .expect("best_back_price exists: lay orders were placed");
        assert_eq!(best.price, OddsX10000(21000)); // Highest lay = best for backer
        assert_eq!(best.size, Money(50));
    }

    #[test]
    fn best_lay_price_is_lowest_back() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        // Back orders at different prices
        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Back, 20000, 50))); // 2.00
        book.apply_ok(cmd(mid, 2, place_order(2, 2, 1, Side::Back, 21000, 50))); // 2.10
        book.apply_ok(cmd(mid, 3, place_order(3, 3, 1, Side::Back, 19000, 50))); // 1.90

        let best = book
            .best_lay_price(rid)
            .expect("best_lay_price exists: back orders were placed");
        assert_eq!(best.price, OddsX10000(19000)); // Lowest back = best for layer
        assert_eq!(best.size, Money(50));
    }

    #[test]
    fn runner_prices_depth() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        // 3 lay levels
        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 19000, 10)));
        book.apply_ok(cmd(mid, 2, place_order(2, 2, 1, Side::Lay, 19500, 20)));
        book.apply_ok(cmd(mid, 3, place_order(3, 3, 1, Side::Lay, 20000, 30)));

        let prices = book.runner_prices(rid, 2);

        assert_eq!(prices.available_to_back.len(), 2);
        assert_eq!(prices.available_to_back[0].price, OddsX10000(20000));
        assert_eq!(prices.available_to_back[1].price, OddsX10000(19500));
    }

    #[test]
    fn multi_runner_best_prices_follow_bettor_ordering() {
        let mid = MarketId(2);
        let rid = RunnerId(1);
        let mut book = Book::new_multi_runner(mid, [rid, RunnerId(2), RunnerId(3)]);

        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 50)));
        book.apply_ok(cmd(mid, 2, place_order(2, 2, 1, Side::Lay, 21000, 50)));
        book.apply_ok(cmd(mid, 3, place_order(3, 3, 1, Side::Lay, 19000, 50)));

        let best_back = book
            .best_back_price(rid)
            .expect("best_back_price exists: lay orders were placed");
        assert_eq!(best_back.price, OddsX10000(21000));

        let mut book_back = Book::new_multi_runner(mid, [rid, RunnerId(2), RunnerId(3)]);
        book_back.apply_ok(cmd(mid, 4, place_order(4, 4, 1, Side::Back, 20000, 50)));
        book_back.apply_ok(cmd(mid, 5, place_order(5, 5, 1, Side::Back, 19000, 50)));
        book_back.apply_ok(cmd(mid, 6, place_order(6, 6, 1, Side::Back, 21000, 50)));

        let best_lay = book_back
            .best_lay_price(rid)
            .expect("best_lay_price exists: back orders were placed");
        assert_eq!(best_lay.price, OddsX10000(19000));
    }

    #[test]
    fn aggregates_at_same_price() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        // Multiple orders at same price
        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 30)));
        book.apply_ok(cmd(mid, 2, place_order(2, 2, 1, Side::Lay, 20000, 20)));
        book.apply_ok(cmd(mid, 3, place_order(3, 3, 1, Side::Lay, 20000, 50)));

        let best = book
            .best_back_price(rid)
            .expect("best_back_price exists: 3 lay orders were placed");
        assert_eq!(best.size, Money(100)); // 30 + 20 + 50
    }

    #[test]
    fn total_matched_volume() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 100)));
        book.apply_ok(cmd(mid, 2, place_order(2, 2, 1, Side::Back, 20000, 60)));

        assert_eq!(book.runner_matched_volume(rid), Money(60));
        assert_eq!(book.total_matched(), Money(60));
    }
}

// ============================================================================
// Settlement Tests
// ============================================================================

// ============================================================================
// Runner Removal Tests
// ============================================================================

mod runner_removal {
    use super::*;

    #[test]
    fn remove_runner_cancels_orders() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Back, 20000, 100)));

        let (events, _) = book.apply_ok(cmd(
            mid,
            2,
            BookCommandKind::RemoveRunner {
                runner_id: rid,
                reduction_factor_bps: Some(500), // 5%
            },
        ));

        assert!(has_event(&events, |e| matches!(
            e,
            BookEvent::OrderCancelled { cancel_cause, .. } if *cancel_cause == CancelCause::RunnerRemoved
        )));
        assert!(has_event(&events, |e| matches!(
            e,
            BookEvent::RunnerRemoved {
                runner_id: RunnerId(1),
                reduction_factor_bps: Some(500),
                ..
            }
        )));
    }

    #[test]
    fn remove_runner_twice_is_rejected() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        book.apply_ok(cmd(
            mid,
            1,
            BookCommandKind::RemoveRunner {
                runner_id: rid,
                reduction_factor_bps: None,
            },
        ));

        let err = book.apply_err(cmd(
            mid,
            2,
            BookCommandKind::RemoveRunner {
                runner_id: rid,
                reduction_factor_bps: None,
            },
        ));

        assert_eq!(err.reason, RejectReason::RunnerAlreadyRemoved);
    }

    // NOTE: cannot_place_order_on_removed_runner moved to scenario_tests.rs::validation module
}

// ============================================================================
// Settlement Validation Tests
// ============================================================================

mod settlement_validation {
    use super::*;

    #[test]
    fn settle_requires_market_closed() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        let err = book.apply_err(cmd(
            mid,
            1,
            BookCommandKind::SettleMarket {
                runner_results: vec![
                    (RunnerId(1), RunnerResult::Winner),
                    (RunnerId(2), RunnerResult::Loser),
                ],
                dead_heat_divisor: None,
            },
        ));

        assert_eq!(err.reason, RejectReason::MarketNotClosed);
    }

    #[test]
    fn settle_rejects_duplicate_runners() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, BookCommandKind::CloseMarket));

        let err = book.apply_err(cmd(
            mid,
            2,
            BookCommandKind::SettleMarket {
                runner_results: vec![
                    (RunnerId(1), RunnerResult::Winner),
                    (RunnerId(1), RunnerResult::Loser),
                ],
                dead_heat_divisor: None,
            },
        ));

        assert_eq!(err.reason, RejectReason::DuplicateRunner);
    }

    #[test]
    fn settle_rejects_incomplete_results() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, BookCommandKind::CloseMarket));

        let err = book.apply_err(cmd(
            mid,
            2,
            BookCommandKind::SettleMarket {
                runner_results: vec![(RunnerId(1), RunnerResult::Winner)],
                dead_heat_divisor: None,
            },
        ));

        assert_eq!(err.reason, RejectReason::IncompleteResults);
    }

    #[test]
    fn repeated_settle_returns_no_change() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, BookCommandKind::CloseMarket));
        book.apply_ok(cmd(
            mid,
            2,
            BookCommandKind::SettleMarket {
                runner_results: vec![
                    (RunnerId(1), RunnerResult::Winner),
                    (RunnerId(2), RunnerResult::Loser),
                ],
                dead_heat_divisor: None,
            },
        ));

        let err = book.apply_err(cmd(
            mid,
            3,
            BookCommandKind::SettleMarket {
                runner_results: vec![
                    (RunnerId(1), RunnerResult::Winner),
                    (RunnerId(2), RunnerResult::Loser),
                ],
                dead_heat_divisor: None,
            },
        ));

        assert_eq!(err.reason, RejectReason::NoChange);
        assert_eq!(
            err.detail,
            Some(BookErrorDetail::MarketCommand {
                current_state: BookMarketState::Settled,
                current_phase: MarketPhase::Pre,
                requested_state: RequestedBookState::Settled,
            })
        );
    }
}

// ============================================================================
// Idempotency and Determinism Tests
// ============================================================================

mod idempotency {
    use super::*;

    // Note: Command deduplication is handled at the gateway layer, not in the book.

    #[test]
    fn replay_produces_same_logical_state() {
        let mid = MarketId(1);
        let rid = RunnerId(1);

        let commands = vec![
            cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 100)),
            cmd(mid, 2, place_order(2, 2, 1, Side::Back, 20000, 60)),
            cmd(mid, 3, BookCommandKind::SuspendMarket { reason: "PAUSE" }),
            cmd(
                mid,
                4,
                BookCommandKind::UnsuspendMarket { reason: "RESUME" },
            ),
            cmd(mid, 5, place_order(3, 3, 1, Side::Lay, 19000, 40)),
        ];

        // Run A
        let mut book_a = Book::new(mid, [rid, RunnerId(2)]);
        let mut events_a = Vec::new();
        for c in commands.clone() {
            events_a.extend(book_a.apply_ok(c).0);
        }

        // Run B
        let mut book_b = Book::new(mid, [rid, RunnerId(2)]);
        let mut events_b = Vec::new();
        for c in commands {
            events_b.extend(book_b.apply_ok(c).0);
        }

        // Same number of events
        assert_eq!(events_a.len(), events_b.len());

        // Same logical event types in order
        for (a, b) in events_a.iter().zip(events_b.iter()) {
            assert_eq!(
                std::mem::discriminant(&a.event),
                std::mem::discriminant(&b.event),
                "Event types should match"
            );
        }

        // Same final book state
        assert_eq!(book_a.market_state(), book_b.market_state());
        assert_eq!(book_a.active_order_count(), book_b.active_order_count());
        assert_eq!(book_a.total_matched(), book_b.total_matched());

        // Same order states
        assert_eq!(
            book_a.get_order(OrderId(1)).unwrap().matched,
            book_b.get_order(OrderId(1)).unwrap().matched
        );
        assert!(book_a.get_order(OrderId(2)).is_none());
        assert!(book_b.get_order(OrderId(2)).is_none());
    }
}

// ============================================================================
// Void Marker Tests
// ============================================================================

mod void_markers {
    use super::*;

    #[test]
    fn void_trades_from_time_emits_marker_event() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);
        let from = chrono::Utc::now();

        let (events, _) = book.apply_ok(cmd(
            mid,
            1,
            BookCommandKind::VoidTradesFromTime {
                from_matched_at_inclusive: from,
                reason: "TEST",
            },
        ));

        assert!(has_event(&events, |e| matches!(
            e,
            BookEvent::VoidTradesFromTime {
                from_matched_at_inclusive,
                reason
            } if *from_matched_at_inclusive == from && reason == "TEST"
        )));
    }
}

// ============================================================================
// Snapshot (serde) Roundtrip Tests
// ============================================================================

mod snapshot_roundtrip {
    use super::*;

    #[test]
    fn multi_runner_snapshot_roundtrip_preserves_depth_and_fifo() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2), RunnerId(3)]);

        // Build resting LAY liquidity with FIFO at the same price.
        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 50)));
        book.apply_ok(cmd(mid, 2, place_order(2, 2, 1, Side::Lay, 20000, 50)));
        book.apply_ok(cmd(mid, 3, place_order(3, 3, 1, Side::Lay, 21000, 40)));

        let before = book.runner_prices(rid, 10);

        let json = serde_json::to_string(&book).expect("serialize snapshot");
        let mut book: Book = serde_json::from_str(&json).expect("deserialize snapshot");

        let after = book.runner_prices(rid, 10);
        assert_eq!(after.available_to_back, before.available_to_back);
        assert_eq!(after.available_to_lay, before.available_to_lay);

        // After restore, bettor-centric price priority should still take the
        // 2.10 level before the 2.00 FIFO queue, and FIFO should still apply
        // within the 2.00 level once execution reaches it.
        let (events, _) = book.apply_ok(cmd(mid, 4, place_order(4, 4, 1, Side::Back, 20000, 60)));
        let trades: Vec<_> = events
            .iter()
            .filter_map(|e| match &e.event {
                BookEvent::TradeMatched {
                    order_id,
                    matched_delta,
                    ..
                } if *order_id != OrderId(4) => Some((*order_id, *matched_delta)),
                _ => None,
            })
            .collect();
        assert_eq!(trades.len(), 2);
        assert_eq!(trades[0], (OrderId(3), Money(40)));
        assert_eq!(trades[1], (OrderId(1), Money(20)));
    }

    #[test]
    fn two_runner_snapshot_roundtrip_preserves_implied_matching() {
        let mid = MarketId(1);
        let runner_a = RunnerId(1);
        let runner_b = RunnerId(2);
        let mut book = Book::new(mid, [runner_a, runner_b]);

        // Resting BACK on runner B provides implied liquidity for runner A.
        book.apply_ok(cmd(mid, 1, place_order(1, 10, 2, Side::Back, 20000, 1000)));

        let json = serde_json::to_string(&book).expect("serialize snapshot");
        let mut book: Book = serde_json::from_str(&json).expect("deserialize snapshot");

        // Incoming BACK on runner A should match via implied.
        let (events, _) =
            book.apply_ok(cmd(mid, 2, place_order(2, 20, 1, Side::Back, 20000, 1000)));
        assert!(has_event(&events, |e| matches!(
            e,
            BookEvent::TradeMatched { .. }
        )));

        assert_eq!(book.runner_matched_volume(runner_a), Money(1000));
        assert_eq!(book.runner_matched_volume(runner_b), Money(1000));
        assert_eq!(book.total_matched(), Money(2000));
    }
}

// ============================================================================
// Edge Case Tests
// ============================================================================

mod edge_cases {
    use super::*;

    #[test]
    fn market_id_mismatch_rejected() {
        let mut book = Book::new(MarketId(1), [RunnerId(1), RunnerId(2)]);

        let err = book.apply_err(cmd(
            MarketId(99),
            1,
            BookCommandKind::SuspendMarket { reason: "X" },
        ));
        assert_eq!(err.reason, RejectReason::MarketIdMismatch);
    }

    #[test]
    fn order_fully_matched_not_resting() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 100)));
        book.apply_ok(cmd(mid, 2, place_order(2, 2, 1, Side::Back, 20000, 100)));

        // Both fully matched - neither should be resting
        assert!(!book.is_resting(OrderId(1)));
        assert!(!book.is_resting(OrderId(2)));
    }

    #[test]
    fn cancel_nonexistent_order() {
        let mid = MarketId(1);
        let mut book = Book::new(mid, [RunnerId(1), RunnerId(2)]);

        let err = book.apply_err(cmd(
            mid,
            1,
            BookCommandKind::CancelOrder {
                order_id: OrderId(999),
                account_id: AccountId::from(1),
            },
        ));
        assert_eq!(err.reason, RejectReason::OrderNotFound);
    }

    #[test]
    fn cancel_already_filled_order_is_order_not_found() {
        let mid = MarketId(1);
        let rid = RunnerId(1);
        let mut book = Book::new(mid, [rid, RunnerId(2)]);

        book.apply_ok(cmd(mid, 1, place_order(1, 1, 1, Side::Lay, 20000, 100)));
        book.apply_ok(cmd(mid, 2, place_order(2, 2, 1, Side::Back, 20000, 100)));

        // Try to cancel the fully matched order (terminal orders are not retained)
        let err = book.apply_err(cmd(
            mid,
            3,
            BookCommandKind::CancelOrder {
                order_id: OrderId(1),
                account_id: AccountId::from(1),
            },
        ));
        assert_eq!(err.reason, RejectReason::OrderNotFound);
    }
}
