//! Terminal UI (TUI) client for the `live` example with embedded trading bots.
//!
//! Run the server first:
//! - `cargo run --example live`
//!
//! Then run the TUI:
//! - `cargo run --example live_tui`
//!
//! Optional:
//! - `cargo run --example live_tui -- --url ws://127.0.0.1:3000/ws_ctl?depth=10&market_id=1`
//!
//! Keys:
//! - `Esc` to go back (metrics -> dashboard, bots -> market)
//! - `q` to quit (from dashboard)
//! - `m` to open metrics (dashboard only)
//! - `l` to open logs (dashboard only)
//! - `b` to open bots (market view only)
//! - Bots view: `m/n/i` add bots, `Enter` toggle, `s` start/stop all
//! - `f` to toggle Betfair ladder mode
//! - Market controls: o=open, u=suspend, g=live, c=close
//! - Engine controls: h=halt, r=resume

#![allow(clippy::collapsible_if)]

use betex::{
    book::BookMarketState,
    book::protocol::command::{BinarySide, BinaryTimeInForce, MarketState, Side, TimeInForce},
    metrics::MetricsSnapshot,
    prelude::*,
    snapshot::MarketSnapshot,
};

use clap::Parser;
use crossterm::{
    event::{self, Event, KeyCode},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use futures::{SinkExt, StreamExt};
use rand::{RngExt, SeedableRng, rngs::SmallRng};
use ratatui::{
    Terminal,
    backend::CrosstermBackend,
    layout::{Constraint, Direction, Layout},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table},
};
use std::{
    collections::{HashMap, VecDeque},
    io,
    sync::{
        Arc, RwLock,
        atomic::{AtomicBool, AtomicU64, Ordering},
    },
    time::{Duration, Instant},
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{mpsc, watch};
use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage};

use strum::IntoEnumIterator;

#[path = "shared/ws_protocol.rs"]
mod ws_protocol;

use ws_protocol::{ClientMsg, MarketInfo, ServerMsg};

#[derive(Clone, Debug)]
enum ConnState {
    Connecting,
    Connected,
    Disconnected,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UiView {
    ControlPlane,
    Market,
    Bots,
    Metrics,
    Logs,
}

const LOG_CAPACITY: usize = 200;

#[derive(Clone, Debug)]
struct SnapshotState {
    snapshot: MarketSnapshot,
    received_at: Instant,
}

#[derive(Clone, Debug)]
struct WsStatus {
    global_seq: u64,
    market_count: usize,
    received_at: Instant,
}

#[derive(Clone, Debug)]
struct MetricsState {
    snapshot: MetricsSnapshot,
    received_at: Instant,
    window: Duration,
    duration_samples_by_stage: Vec<u64>,
    duration_p50_ns_by_stage: Vec<Option<u64>>,
    duration_p99_ns_by_stage: Vec<Option<u64>>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BotKind {
    MarketMaker,
    Noise,
    Informed,
}

#[derive(Clone, Debug)]
struct BotEntry {
    name: String,
    orders_sent: u64,
    trades: u64,
    paused: bool,
}

#[derive(Clone, Debug, Default)]
struct BotMarketState {
    bots: Vec<BotEntry>,
    selected_index: usize,
}

#[derive(Clone, Debug)]
enum CloseChoice {
    Settle { runner_id: u64, label: String },
    Void { label: String },
}

#[derive(Clone, Debug)]
struct CloseModalState {
    choices: Vec<CloseChoice>,
    selected: usize,
}

// ============================================================================
// Market Info Types (client-side)
// ============================================================================

#[derive(
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    serde::Serialize,
    serde::Deserialize,
    strum::Display,
    strum::EnumIter,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum MarketPreset {
    #[strum(to_string = "Tennis (2-runner)")]
    Tennis2Runner,
    #[strum(to_string = "Soccer (3-runner)")]
    Soccer3Runner,
    #[strum(to_string = "Binary YES/NO (prediction)")]
    BinaryYesNo,
}

// ============================================================================
// Create Market Modal
// ============================================================================

#[derive(Clone, Debug)]
struct CreateMarketModal {
    name_input: String,
    preset_options: Vec<MarketPreset>,
    selected_preset: usize,
    focused_field: CreateMarketField,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CreateMarketField {
    Name,
    Preset,
}

impl Default for CreateMarketModal {
    fn default() -> Self {
        Self {
            name_input: String::new(),
            preset_options: MarketPreset::iter().collect(),
            selected_preset: 0,
            focused_field: CreateMarketField::Name,
        }
    }
}

#[derive(Clone, Debug)]
enum ModalState {
    None,
    CreateMarket(CreateMarketModal),
    ConfirmDelete {
        market_id: MarketId,
        name: String,
        selected_yes: bool,
    },
}

#[derive(Clone, Debug)]
struct UiState {
    url: String,
    conn: ConnState,
    view: UiView,
    // Control plane state
    markets: Vec<MarketInfo>,
    selected_market_index: usize,
    selected_market_id: Option<MarketId>,
    modal: ModalState,
    // Market view state
    betfair_mode: bool,
    betfair_sel_runner: usize,
    betfair_sel_side_lay: bool,
    betfair_sel_col: usize,
    snapshot: Option<SnapshotState>,
    status: Option<WsStatus>,
    metrics: Option<MetricsState>,
    engine_ev_per_s: Option<f64>,
    engine_cmd_per_s: Option<f64>,
    wal_ev_per_s: Option<f64>,
    wal_commits_per_s: Option<f64>,
    engine_queue_len: Option<i64>,
    last_metrics_error: Option<String>,
    close_modal: Option<CloseModalState>,
    last_ack: Option<String>,
    last_error: Option<String>,
    logs: VecDeque<String>,
    bot_markets: HashMap<MarketId, BotMarketState>,
}

impl Default for UiState {
    fn default() -> Self {
        Self {
            url: String::new(),
            conn: ConnState::Connecting,
            view: UiView::ControlPlane,
            // Control plane state
            markets: vec![],
            selected_market_index: 0,
            selected_market_id: None,
            modal: ModalState::None,
            // Market view state
            betfair_mode: false,
            betfair_sel_runner: 0,
            betfair_sel_side_lay: false,
            betfair_sel_col: 0,
            snapshot: None,
            status: None,
            metrics: None,
            engine_ev_per_s: None,
            engine_cmd_per_s: None,
            wal_ev_per_s: None,
            wal_commits_per_s: None,
            engine_queue_len: None,
            last_metrics_error: None,
            close_modal: None,
            last_ack: None,
            last_error: None,
            logs: VecDeque::with_capacity(LOG_CAPACITY),
            bot_markets: HashMap::new(),
        }
    }
}

impl UiState {
    fn push_log(&mut self, message: impl Into<String>) {
        let message = message.into();
        if self.logs.len() == LOG_CAPACITY {
            self.logs.pop_front();
        }
        self.logs.push_back(message);
    }

    fn set_error(&mut self, message: impl Into<String>) {
        let message = message.into();
        self.last_error = Some(message.clone());
        self.push_log(message);
    }

    fn set_metrics_error(&mut self, message: impl Into<String>) {
        let message = message.into();
        self.last_metrics_error = Some(message.clone());
        self.push_log(format!("metrics: {message}"));
    }
}

struct Tui {
    terminal: Terminal<CrosstermBackend<io::Stdout>>,
}

#[derive(Clone, Copy)]
struct Palette {
    bg: Color,
    header_bg: Color,
    header_fg: Color,
    border: Color,
    title: Color,
    text: Color,
    muted: Color,
    back: Color,
    lay: Color,
    warn: Color,
}

impl Default for Palette {
    fn default() -> Self {
        // Bloomberg-ish palette on dark background.
        Self {
            bg: Color::Black,
            header_bg: Color::Blue,
            header_fg: Color::White,
            border: Color::Cyan,
            title: Color::Yellow,
            text: Color::White,
            muted: Color::Gray,
            back: Color::Green,
            lay: Color::Red,
            warn: Color::Rgb(255, 165, 0), // Orange for warnings
        }
    }
}

impl Tui {
    fn new() -> anyhow::Result<Self> {
        enable_raw_mode()?;
        let mut stdout = io::stdout();
        execute!(stdout, EnterAlternateScreen)?;
        let backend = CrosstermBackend::new(stdout);
        let mut terminal = Terminal::new(backend)?;
        terminal.clear()?;
        Ok(Self { terminal })
    }

    fn draw(&mut self, ui: &UiState) -> anyhow::Result<()> {
        let pal = Palette::default();
        self.terminal.draw(|f| {
            let area = f.area();
            f.render_widget(Block::default().style(Style::default().bg(pal.bg)), area);

            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([
                    Constraint::Length(3), // header
                    Constraint::Min(0),    // main
                    Constraint::Length(3), // footer
                ])
                .split(area);

            let header = header_text(ui, area.width);
            let header_widget = Paragraph::new(header).block(
                Block::default()
                    .borders(Borders::ALL)
                    .style(Style::default().fg(pal.header_fg).bg(pal.header_bg))
                    .title("btx live_tui"),
            );
            f.render_widget(header_widget, chunks[0]);

            match ui.view {
                UiView::ControlPlane => {
                    render_control_plane_view(f, chunks[1], ui, pal);
                }
                UiView::Market => {
                    let main_rows = Layout::default()
                        .direction(Direction::Vertical)
                        .constraints([Constraint::Length(7), Constraint::Min(0)])
                        .split(chunks[1]);

                    render_engine_panel(f, main_rows[0], ui, pal);

                    let market_model = ui.snapshot.as_ref().map(|s| s.snapshot.market_model());
                    let is_odds = market_model == Some(betex::types::MarketModel::ExchangeOdds);
                    let is_binary =
                        market_model.is_some_and(|m| m != betex::types::MarketModel::ExchangeOdds);

                    if ui.betfair_mode && is_odds {
                        render_betfair_ladder(f, main_rows[1], ui, pal);
                    } else if is_binary {
                        render_binary_yes_panel(f, main_rows[1], ui, pal);
                    } else {
                        let cols = Layout::default()
                            .direction(Direction::Horizontal)
                            .constraints([
                                Constraint::Percentage(33),
                                Constraint::Percentage(34),
                                Constraint::Percentage(33),
                            ])
                            .split(main_rows[1]);

                        render_runner_panel(f, cols[0], ui, 1u32, "Home", pal);
                        render_runner_panel(f, cols[1], ui, 2u32, "Draw", pal);
                        render_runner_panel(f, cols[2], ui, 3u32, "Away", pal);
                    }
                }
                UiView::Bots => {
                    render_bots_view(f, chunks[1], ui, pal);
                }
                UiView::Metrics => {
                    render_metrics_view(f, chunks[1], ui, pal);
                }
                UiView::Logs => {
                    render_logs_view(f, chunks[1], ui, pal);
                }
            }

            let footer = footer_text(ui);
            let footer_widget = Paragraph::new(footer)
                .block(
                    Block::default()
                        .borders(Borders::ALL)
                        .border_style(Style::default().fg(pal.border))
                        .title(Span::styled("Controls", Style::default().fg(pal.title))),
                )
                .style(Style::default().fg(pal.text).bg(pal.bg));
            f.render_widget(footer_widget, chunks[2]);

            if let Some(modal) = ui.close_modal.as_ref() {
                render_close_modal(f, area, modal, pal);
            }
        })?;

        Ok(())
    }
}

impl Drop for Tui {
    fn drop(&mut self) {
        let _ = disable_raw_mode();
        let mut stdout = io::stdout();
        let _ = execute!(stdout, LeaveAlternateScreen);
        let _ = self.terminal.show_cursor();
    }
}

fn header_text(ui: &UiState, width: u16) -> String {
    let conn = match ui.conn {
        ConnState::Connecting => "CONNECTING",
        ConnState::Connected => "CONNECTED",
        ConnState::Disconnected => "DISCONNECTED",
    };

    let (market_id, market_seq, market_state, market_phase) = match ui.snapshot.as_ref() {
        Some(s) => (
            Some(s.snapshot.market_id.0),
            Some(s.snapshot.market_seq),
            Some(format!("{:?}", s.snapshot.state)),
            Some(format!("{:?}", s.snapshot.phase)),
        ),
        None => (None, None, None, None),
    };

    let global_seq = ui.status.as_ref().map(|st| st.global_seq);

    let mut parts = Vec::with_capacity(5);
    parts.push(format!("conn={conn}"));

    if let (Some(mid), Some(market_seq), Some(market_state), Some(market_phase)) =
        (market_id, market_seq, market_state, market_phase)
    {
        parts.push(format!(
            "market={mid} market_seq={market_seq} state={market_state} phase={market_phase}"
        ));
    } else {
        parts.push("no snapshot".to_string());
    }

    if let Some(gs) = global_seq {
        parts.push(format!("proj_seq={gs}"));
    }
    if let Some(evps) = ui.engine_ev_per_s {
        parts.push(format!("ev/s={evps:.0}"));
    }

    let text = parts.join(" | ");
    if width < 80 {
        text.replace(" | ", " ")
    } else {
        text
    }
}

fn active_market_id(ui: &UiState) -> Option<MarketId> {
    ui.selected_market_id
        .or_else(|| ui.snapshot.as_ref().map(|s| s.snapshot.market_id))
}

fn esc_target(view: UiView) -> Option<UiView> {
    match view {
        UiView::ControlPlane => None,
        UiView::Market => Some(UiView::ControlPlane),
        UiView::Bots => Some(UiView::Market),
        UiView::Metrics => Some(UiView::ControlPlane),
        UiView::Logs => Some(UiView::ControlPlane),
    }
}

fn current_bot_state(ui: &UiState) -> Option<&BotMarketState> {
    active_market_id(ui).and_then(|mid| ui.bot_markets.get(&mid))
}

fn footer_text(ui: &UiState) -> String {
    let betfair = if ui.betfair_mode { "ON" } else { "OFF" };
    let no_ack = if ui.url.contains("no_ack=1") {
        " no_ack=1"
    } else {
        ""
    };

    let bots_state = current_bot_state(ui);
    let bots_total = bots_state.map(|s| s.bots.len()).unwrap_or(0);
    let bots_running = bots_state
        .map(|s| s.bots.iter().filter(|b| !b.paused).count())
        .unwrap_or(0);
    let bots_all_running = bots_state
        .map(|s| !s.bots.is_empty() && s.bots.iter().all(|b| !b.paused))
        .unwrap_or(false);
    let bots_toggle = if bots_all_running { "stop" } else { "start" };

    let hotkeys = match ui.view {
        UiView::ControlPlane => {
            "n: new market | Enter: select | d: delete (terminal) | m: metrics | l: logs | q: quit"
                .to_string()
        }
        UiView::Market => format!(
            "b: bots | Esc: back | f: betfair({betfair}) | o: open | u: suspend | g: live | c: close/settle | C: close | h: halt | r: resume"
        ),
        UiView::Bots => format!(
            "m/n/i: add bot | Enter: toggle | s: {bots_toggle} all ({bots_running}/{bots_total}) | Up/Down: select | Esc: back to market{no_ack}"
        ),
        UiView::Metrics => "Esc: back to dashboard".to_string(),
        UiView::Logs => "Esc: back to dashboard".to_string(),
    };

    let mut status_line = if let Some(err) = ui.last_error.as_ref() {
        format!("error: {err}")
    } else if let Some(err) = ui.last_metrics_error.as_ref() {
        format!("metrics: {err}")
    } else if let Some(ack) = ui.last_ack.as_ref() {
        ack.to_string()
    } else {
        String::new()
    };

    if ui.betfair_mode {
        if let Some(s) = ui.snapshot.as_ref() {
            if let Some(r) = s.snapshot.runners().get(ui.betfair_sel_runner) {
                let side = if ui.betfair_sel_side_lay {
                    "LAY"
                } else {
                    "BACK"
                };
                let col_ix = ui.betfair_sel_col.min(2);
                let cell = if ui.betfair_sel_side_lay {
                    r.available_to_lay.get(col_ix)
                } else {
                    r.available_to_back.get(col_ix)
                };
                if let Some(p) = cell {
                    let odds = p.price.to_decimal();
                    let prob = if odds > 0.0 { 100.0 / odds } else { 0.0 };
                    let runner_label = r.runner_label.clone();
                    let sel = format!(
                        "sel={runner_label} {side}{} odds={} p={:.2}% vol=${}",
                        col_ix + 1,
                        fmt_odds_betfair(odds),
                        prob,
                        fmt_int_commas(p.size.0)
                    );
                    if status_line.is_empty() {
                        status_line = sel;
                    } else {
                        status_line.push_str(" | ");
                        status_line.push_str(&sel);
                    }
                }
            }
        }
    }

    format!("{hotkeys}\n{status_line}")
}

fn metrics_engine_inflight(snapshot: &MetricsSnapshot) -> Option<i64> {
    let stage_ix = snapshot.schema.stages.iter().position(|s| s == "engine")?;
    let gauge_ix = snapshot
        .schema
        .gauges
        .iter()
        .position(|g| g.name == "btx.stage.inflight")?;
    snapshot
        .gauge_values
        .get(stage_ix)
        .and_then(|row| row.get(gauge_ix))
        .copied()
}

fn fmt_age_ms(ms: Option<u128>) -> String {
    // Fixed-width age string so the status line doesn't jitter.
    match ms {
        Some(v) => {
            let capped = v.min(999_999);
            format!("{capped:>6}")
        }
        None => "     -".to_string(),
    }
}

fn fmt_opt_i64(v: Option<i64>, width: usize) -> String {
    match v {
        Some(x) => format!("{x:>width$}"),
        None => format!("{:>width$}", "-"),
    }
}

fn book_percentages(snapshot: &MarketSnapshot) -> (f64, f64) {
    if snapshot.market_model() != betex::types::MarketModel::ExchangeOdds {
        return (0.0, 0.0);
    }
    // Betfair-style "Back all"/"Lay all" book %:
    // sum_runners (1 / best_odds) * 100
    let mut back_sum = 0.0;
    let mut lay_sum = 0.0;

    for r in snapshot.runners() {
        if let Some(b) = r.best_back() {
            let odds = b.price.to_decimal();
            if odds > 0.0 {
                back_sum += 1.0 / odds;
            }
        }
        if let Some(l) = r.best_lay() {
            let odds = l.price.to_decimal();
            if odds > 0.0 {
                lay_sum += 1.0 / odds;
            }
        }
    }

    (back_sum * 100.0, lay_sum * 100.0)
}

fn fmt_int_commas(n: i64) -> String {
    let sign = if n < 0 { "-" } else { "" };
    let mut s = n.unsigned_abs().to_string();
    let mut out = String::with_capacity(s.len() + s.len() / 3 + 1);
    while s.len() > 3 {
        let tail = s.split_off(s.len() - 3);
        if out.is_empty() {
            out = tail;
        } else {
            out = format!("{tail},{out}");
        }
    }
    if out.is_empty() {
        format!("{sign}{s}")
    } else {
        format!("{sign}{s},{out}")
    }
}

fn fmt_odds_betfair(o: f64) -> String {
    // Prefer a compact Betfair-ish display (trim trailing zeros).
    let s = format!("{:.2}", o);
    let s = s.trim_end_matches('0').trim_end_matches('.');
    s.to_string()
}

fn render_betfair_ladder(
    f: &mut ratatui::Frame,
    area: ratatui::layout::Rect,
    ui: &UiState,
    pal: Palette,
) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(pal.border))
        .title(Span::styled(
            "Market (Betfair)",
            Style::default().fg(pal.title).add_modifier(Modifier::BOLD),
        ))
        .style(Style::default().bg(pal.bg));
    let inner = block.inner(area);
    f.render_widget(block, area);

    let Some(s) = ui.snapshot.as_ref() else {
        f.render_widget(
            Paragraph::new("no snapshot yet").style(Style::default().fg(pal.muted).bg(pal.bg)),
            inner,
        );
        return;
    };

    let runners = s.snapshot.runners();
    if runners.is_empty() {
        f.render_widget(
            Paragraph::new("no runners").style(Style::default().fg(pal.muted).bg(pal.bg)),
            inner,
        );
        return;
    }

    let matched_total: i64 = runners.iter().map(|r| r.matched_volume.0).sum();
    let (back_book_percent, lay_book_percent) = book_percentages(&s.snapshot);

    let mut constraints = Vec::with_capacity(2 + runners.len());
    constraints.push(Constraint::Length(1)); // MarketHeader
    constraints.push(Constraint::Length(1)); // LadderHeaderRow
    for _ in runners {
        constraints.push(Constraint::Length(2)); // runner row (odds + vol)
    }
    constraints.push(Constraint::Min(0));

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints(constraints)
        .split(inner);

    // Selected cell info (Betfair-like hover).
    let sel_info = {
        let r_ix = ui.betfair_sel_runner.min(runners.len().saturating_sub(1));
        let r = runners.get(r_ix);
        let side = if ui.betfair_sel_side_lay {
            "LAY"
        } else {
            "BACK"
        };
        let col = ui.betfair_sel_col.min(2);
        let (odds, vol, runner_label) = if let Some(r) = r {
            let runner_label = r.runner_label.clone();
            let cell = if ui.betfair_sel_side_lay {
                r.available_to_lay.get(col)
            } else {
                r.available_to_back.get(col)
            };
            if let Some(p) = cell {
                (Some(p.price.to_decimal()), Some(p.size.0), runner_label)
            } else {
                (None, None, runner_label)
            }
        } else {
            (None, None, "-".to_string())
        };

        let prob = odds.map(|o| if o > 0.0 { 100.0 / o } else { 0.0 });
        format!(
            "sel: {runner_label} {side}{}  odds={}  p={}  vol={}",
            col + 1,
            odds.map(fmt_odds_betfair).unwrap_or_else(|| "-".into()),
            prob.map(|p| format!("{p:.2}%"))
                .unwrap_or_else(|| "-".into()),
            vol.map(|v| format!("${}", fmt_int_commas(v)))
                .unwrap_or_else(|| "-".into()),
        )
    };

    // MarketHeader
    let header = Line::from(vec![
        Span::styled("Matched: ", Style::default().fg(pal.muted).bg(pal.bg)),
        Span::styled(
            fmt_int_commas(matched_total),
            Style::default().fg(pal.text).bg(pal.bg),
        ),
        Span::styled("  |  ", Style::default().fg(pal.muted).bg(pal.bg)),
        Span::styled(sel_info, Style::default().fg(pal.muted).bg(pal.bg)),
    ]);
    f.render_widget(
        Paragraph::new(header).style(Style::default().bg(pal.bg)),
        rows[0],
    );

    // Column layout (fixed widths; 3 back + 3 lay)
    let cols = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Length(12), // runner name
            Constraint::Length(10),
            Constraint::Length(10),
            Constraint::Length(10),
            Constraint::Length(10),
            Constraint::Length(10),
            Constraint::Length(10),
        ])
        .split(rows[1]);

    // LadderHeaderRow
    let back_pct = Span::styled(
        format!("{back_book_percent:>6.2}%"),
        Style::default()
            .fg(pal.text)
            .bg(pal.bg)
            .add_modifier(Modifier::BOLD),
    );
    let lay_pct = Span::styled(
        format!("{lay_book_percent:>6.2}%"),
        Style::default()
            .fg(pal.text)
            .bg(pal.bg)
            .add_modifier(Modifier::BOLD),
    );

    f.render_widget(
        Paragraph::new(Line::from(back_pct)).alignment(ratatui::layout::Alignment::Center),
        cols[1],
    );
    f.render_widget(
        Paragraph::new(Line::from(Span::styled(
            "Back all",
            Style::default().fg(pal.title).bg(pal.bg),
        )))
        .alignment(ratatui::layout::Alignment::Center),
        cols[2],
    );
    f.render_widget(
        Paragraph::new(Line::from(Span::styled(
            "Lay all",
            Style::default().fg(pal.title).bg(pal.bg),
        )))
        .alignment(ratatui::layout::Alignment::Center),
        cols[5],
    );
    f.render_widget(
        Paragraph::new(Line::from(lay_pct)).alignment(ratatui::layout::Alignment::Center),
        cols[6],
    );

    // RunnerRows
    for (i, r) in runners.iter().enumerate() {
        let row_area = rows[2 + i];
        let cols = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([
                Constraint::Length(12), // runner name
                Constraint::Length(10),
                Constraint::Length(10),
                Constraint::Length(10),
                Constraint::Length(10),
                Constraint::Length(10),
                Constraint::Length(10),
            ])
            .split(row_area);

        let runner_label = r.runner_label.clone();
        f.render_widget(
            Paragraph::new(runner_label).style(Style::default().fg(pal.text).bg(pal.bg)),
            cols[0],
        );

        let back_style = Style::default().fg(Color::White).bg(Color::Blue);
        let lay_style = Style::default().fg(Color::Black).bg(Color::Magenta);
        let empty_style = Style::default().fg(pal.muted).bg(pal.bg);

        for j in 0..3usize {
            let cell = r.available_to_back.get(j);
            let (odds, vol) = match cell {
                Some(p) => (Some(p.price.to_decimal()), Some(p.size.0)),
                None => (None, None),
            };
            let selected =
                ui.betfair_sel_runner == i && !ui.betfair_sel_side_lay && ui.betfair_sel_col == j;
            let odds_line = odds
                .map(|o| {
                    Line::from(Span::styled(
                        fmt_odds_betfair(o),
                        Style::default().add_modifier(Modifier::BOLD),
                    ))
                })
                .unwrap_or_else(|| Line::from(Span::raw("")));
            let vol_line = vol
                .map(|v| {
                    Line::from(Span::styled(
                        format!("${}", fmt_int_commas(v)),
                        if selected {
                            Style::default()
                        } else {
                            Style::default().fg(Color::Cyan)
                        },
                    ))
                })
                .unwrap_or_else(|| Line::from(Span::raw("")));

            let mut style = if odds.is_some() {
                back_style
            } else {
                empty_style
            };
            if selected {
                // Keep full-cell highlight across both odds and volume lines.
                style = style.add_modifier(Modifier::REVERSED | Modifier::BOLD);
            }
            f.render_widget(
                Paragraph::new(vec![odds_line, vol_line]).style(style),
                cols[1 + j],
            );
        }

        for j in 0..3usize {
            let cell = r.available_to_lay.get(j);
            let (odds, vol) = match cell {
                Some(p) => (Some(p.price.to_decimal()), Some(p.size.0)),
                None => (None, None),
            };
            let selected =
                ui.betfair_sel_runner == i && ui.betfair_sel_side_lay && ui.betfair_sel_col == j;
            let odds_line = odds
                .map(|o| {
                    Line::from(Span::styled(
                        fmt_odds_betfair(o),
                        Style::default().add_modifier(Modifier::BOLD),
                    ))
                })
                .unwrap_or_else(|| Line::from(Span::raw("")));
            let vol_line = vol
                .map(|v| {
                    Line::from(Span::styled(
                        format!("${}", fmt_int_commas(v)),
                        if selected {
                            Style::default()
                        } else {
                            Style::default().fg(Color::White)
                        },
                    ))
                })
                .unwrap_or_else(|| Line::from(Span::raw("")));

            let mut style = if odds.is_some() {
                lay_style
            } else {
                empty_style
            };
            if selected {
                style = style.add_modifier(Modifier::REVERSED | Modifier::BOLD);
            }
            f.render_widget(
                Paragraph::new(vec![odds_line, vol_line]).style(style),
                cols[4 + j],
            );
        }
    }
}

fn render_engine_panel(
    f: &mut ratatui::Frame,
    area: ratatui::layout::Rect,
    ui: &UiState,
    pal: Palette,
) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(pal.border))
        .title(Span::styled(
            "Status",
            Style::default().fg(pal.title).add_modifier(Modifier::BOLD),
        ))
        .style(Style::default().bg(pal.bg));
    let inner = block.inner(area);
    f.render_widget(block, area);

    let snap_age_ms = ui
        .snapshot
        .as_ref()
        .map(|s| s.received_at.elapsed().as_millis());
    let proj_age_ms = ui
        .status
        .as_ref()
        .map(|s| s.received_at.elapsed().as_millis());
    let market_count = ui.status.as_ref().map(|s| s.market_count);
    let metrics_age_ms = ui
        .metrics
        .as_ref()
        .map(|m| m.received_at.elapsed().as_millis());
    let engine_inflight = ui
        .metrics
        .as_ref()
        .and_then(|m| metrics_engine_inflight(&m.snapshot));

    let state = ui
        .snapshot
        .as_ref()
        .map(|s| format!("{:?}", s.snapshot.state))
        .unwrap_or_else(|| "-".into());

    let bot_state = current_bot_state(ui);
    let bot_totals = bot_state
        .map(|s| {
            s.bots.iter().fold((0u64, 0u64), |(o, t), b| {
                (o.saturating_add(b.orders_sent), t.saturating_add(b.trades))
            })
        })
        .unwrap_or((0, 0));
    let bot_count = bot_state.map(|s| s.bots.len()).unwrap_or(0);
    let bot_running = bot_state
        .map(|s| s.bots.iter().filter(|b| !b.paused).count())
        .unwrap_or(0);
    let ack_mode = if ui.url.contains("no_ack=1") {
        "no_ack (no trade acks; bot trade stats disabled)"
    } else {
        "acks on"
    };

    let lines = vec![
        Line::from(format!("state={state}")),
        Line::from(format!(
            "age_ms snap/proj/met={}/{}/{}  inflight={}  markets={}",
            fmt_age_ms(snap_age_ms),
            fmt_age_ms(proj_age_ms),
            fmt_age_ms(metrics_age_ms),
            fmt_opt_i64(engine_inflight, 6),
            market_count
                .map(|v| format!("{v:>3}"))
                .unwrap_or_else(|| "  -".into()),
        )),
        Line::from(format!(
            "bots running={bot_running}/{bot_count}  orders={}  trades={}  ({ack_mode})",
            bot_totals.0, bot_totals.1
        )),
    ];

    let widget = Paragraph::new(lines).style(Style::default().fg(pal.text).bg(pal.bg));
    f.render_widget(widget, inner);
}

fn render_control_plane_view(
    f: &mut ratatui::Frame,
    area: ratatui::layout::Rect,
    ui: &UiState,
    pal: Palette,
) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(pal.border))
        .title(Span::styled(
            format!("Control Plane ({} markets)", ui.markets.len()),
            Style::default().fg(pal.title).add_modifier(Modifier::BOLD),
        ))
        .style(Style::default().bg(pal.bg));
    let inner = block.inner(area);
    f.render_widget(block, area);

    if ui.markets.is_empty() {
        let help = vec![
            Line::from(""),
            Line::from(Span::styled(
                "No markets created yet",
                Style::default().fg(pal.muted),
            )),
            Line::from(""),
            Line::from(Span::styled(
                "Press 'n' to create a new market",
                Style::default().fg(pal.text),
            )),
        ];
        f.render_widget(
            Paragraph::new(help)
                .style(Style::default().bg(pal.bg))
                .alignment(ratatui::layout::Alignment::Center),
            inner,
        );
    } else {
        // Header
        let header_cells = [
            "#", "ID", "Name", "Model", "Kind", "Phase", "Runners", "Status", "Created",
        ]
        .iter()
        .map(|h| Cell::from(*h).style(Style::default().fg(pal.title).add_modifier(Modifier::BOLD)));
        let header = Row::new(header_cells).height(1);

        // Rows
        let rows: Vec<Row> = ui
            .markets
            .iter()
            .enumerate()
            .map(|(i, market)| {
                let is_selected = i == ui.selected_market_index;
                let row_style = if is_selected {
                    Style::default().fg(pal.bg).bg(pal.title)
                } else {
                    Style::default().fg(pal.text).bg(pal.bg)
                };
                let prefix = if is_selected { "> " } else { "  " };
                let created = chrono::DateTime::from_timestamp_millis(market.created_at)
                    .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
                    .unwrap_or_else(|| "?".to_string());
                let model = match market.market_model {
                    betex::types::MarketModel::ExchangeOdds => "ODDS".to_string(),
                    betex::types::MarketModel::BinaryYes { max_price_ticks } => {
                        format!("BINARY({max_price_ticks})")
                    }
                };
                let kind = match market.market_kind {
                    MarketKind::PreEventOnly => "PRE_EVENT_ONLY",
                    MarketKind::InPlayCapable => "IN_PLAY_CAPABLE",
                    MarketKind::LiveOnly => "LIVE_ONLY",
                };
                let phase = match market.market_phase {
                    MarketPhase::Pre => "PRE",
                    MarketPhase::Live => "LIVE",
                };

                Row::new(vec![
                    Cell::from(format!("{}{}", prefix, i + 1)),
                    Cell::from(format!("{}", market.market_id.0)),
                    Cell::from(market.name.clone()),
                    Cell::from(model),
                    Cell::from(kind),
                    Cell::from(phase),
                    Cell::from(format!("{}", market.runner_ids.len())),
                    Cell::from(market.status.to_string()),
                    Cell::from(created),
                ])
                .style(row_style)
            })
            .collect();

        let widths = [
            Constraint::Length(4),
            Constraint::Length(12),
            Constraint::Min(20),
            Constraint::Length(14),
            Constraint::Length(14),
            Constraint::Length(8),
            Constraint::Length(8),
            Constraint::Length(10),
            Constraint::Length(18),
        ];

        let table = Table::new(rows, widths)
            .header(header)
            .row_highlight_style(Style::default().fg(pal.bg).bg(pal.title));

        f.render_widget(table, inner);
    }

    // Render modal if needed
    match &ui.modal {
        ModalState::CreateMarket(modal) => {
            render_create_market_modal(f, area, modal, pal);
        }
        ModalState::ConfirmDelete {
            market_id,
            name,
            selected_yes,
        } => {
            render_confirm_delete_modal(f, area, *market_id, name, *selected_yes, pal);
        }
        _ => {}
    }
}

fn render_create_market_modal(
    f: &mut ratatui::Frame,
    area: ratatui::layout::Rect,
    modal: &CreateMarketModal,
    pal: Palette,
) {
    let popup_w = (area.width as f32 * 0.5) as u16;
    let popup_h = 12;
    let popup = ratatui::layout::Rect::new(
        area.x + (area.width.saturating_sub(popup_w)) / 2,
        area.y + (area.height.saturating_sub(popup_h)) / 2,
        popup_w,
        popup_h,
    );

    f.render_widget(Clear, popup);

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(pal.title))
        .title(Span::styled(
            "Create Market",
            Style::default().fg(pal.title).add_modifier(Modifier::BOLD),
        ))
        .style(Style::default().bg(pal.bg));
    let inner = block.inner(popup);
    f.render_widget(block, popup);

    let name_style = if modal.focused_field == CreateMarketField::Name {
        Style::default().fg(pal.title).add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(pal.text)
    };

    let mut lines = vec![
        Line::from(""),
        Line::from(vec![
            Span::styled("Name: ", name_style),
            Span::styled(
                format!("[{}]", modal.name_input),
                Style::default().fg(pal.text),
            ),
            Span::styled(
                "_",
                if modal.focused_field == CreateMarketField::Name {
                    Style::default().fg(pal.title)
                } else {
                    Style::default().fg(pal.bg)
                },
            ),
        ]),
        Line::from(""),
        Line::from(Span::styled(
            "Preset:",
            if modal.focused_field == CreateMarketField::Preset {
                Style::default().fg(pal.title).add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(pal.text)
            },
        )),
    ];

    for (i, preset) in modal.preset_options.iter().enumerate() {
        let is_selected = i == modal.selected_preset;
        let prefix = if is_selected { "> " } else { "  " };
        let style = if is_selected && modal.focused_field == CreateMarketField::Preset {
            Style::default().fg(pal.title).add_modifier(Modifier::BOLD)
        } else if is_selected {
            Style::default().fg(pal.text).add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(pal.muted)
        };
        lines.push(Line::from(Span::styled(
            format!("  {}{}", prefix, preset),
            style,
        )));
    }

    lines.push(Line::from(""));
    lines.push(Line::from(Span::styled(
        "[Tab: switch field] [Enter: create] [Esc: cancel]",
        Style::default().fg(pal.muted),
    )));

    f.render_widget(
        Paragraph::new(lines).style(Style::default().bg(pal.bg)),
        inner,
    );
}

fn render_confirm_delete_modal(
    f: &mut ratatui::Frame,
    area: ratatui::layout::Rect,
    market_id: MarketId,
    name: &str,
    selected_yes: bool,
    pal: Palette,
) {
    let popup_w = (area.width as f32 * 0.4) as u16;
    let popup_h = 8;
    let popup = ratatui::layout::Rect::new(
        area.x + (area.width.saturating_sub(popup_w)) / 2,
        area.y + (area.height.saturating_sub(popup_h)) / 2,
        popup_w,
        popup_h,
    );

    f.render_widget(Clear, popup);

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(pal.warn))
        .title(Span::styled(
            "Confirm Delete",
            Style::default().fg(pal.warn).add_modifier(Modifier::BOLD),
        ))
        .style(Style::default().bg(pal.bg));
    let inner = block.inner(popup);
    f.render_widget(block, popup);

    let yes_style = if selected_yes {
        Style::default().fg(pal.bg).bg(pal.warn)
    } else {
        Style::default().fg(pal.text)
    };
    let no_style = if !selected_yes {
        Style::default().fg(pal.bg).bg(pal.text)
    } else {
        Style::default().fg(pal.text)
    };

    let lines = vec![
        Line::from(""),
        Line::from(Span::styled(
            format!("Delete market '{}' (id={})?", name, market_id.0),
            Style::default().fg(pal.text),
        )),
        Line::from(""),
        Line::from(vec![
            Span::styled("  ", Style::default()),
            Span::styled(" Yes ", yes_style),
            Span::styled("   ", Style::default()),
            Span::styled(" No ", no_style),
        ]),
        Line::from(""),
        Line::from(Span::styled(
            "[Left/Right: select] [Enter: confirm]",
            Style::default().fg(pal.muted),
        )),
    ];

    f.render_widget(
        Paragraph::new(lines).style(Style::default().bg(pal.bg)),
        inner,
    );
}

fn render_bots_view(
    f: &mut ratatui::Frame,
    area: ratatui::layout::Rect,
    ui: &UiState,
    pal: Palette,
) {
    let market_id = active_market_id(ui);
    let title = match market_id {
        Some(mid) => format!("Bots (market {})", mid.0),
        None => "Bots (no market)".to_string(),
    };
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(pal.border))
        .title(Span::styled(
            title,
            Style::default().fg(pal.title).add_modifier(Modifier::BOLD),
        ))
        .style(Style::default().bg(pal.bg));
    let inner = block.inner(area);
    f.render_widget(block, area);

    let Some(market_id) = market_id else {
        f.render_widget(
            Paragraph::new("select a market to manage bots")
                .style(Style::default().fg(pal.muted).bg(pal.bg)),
            inner,
        );
        return;
    };

    let market_state = ui.bot_markets.get(&market_id);
    if market_state.is_none_or(|s| s.bots.is_empty()) {
        f.render_widget(
            Paragraph::new("no bots (press m/n/i to add)")
                .style(Style::default().fg(pal.muted).bg(pal.bg)),
            inner,
        );
        return;
    }

    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(1), Constraint::Min(0)])
        .split(inner);

    let note = if ui.url.contains("no_ack=1") {
        "note: no_ack=1 => server suppresses OrderResponse; trade counts will stay 0"
    } else {
        "note: trade count increments from OrderResponse.trades (fills)"
    };
    f.render_widget(
        Paragraph::new(note).style(Style::default().fg(pal.muted).bg(pal.bg)),
        layout[0],
    );

    let header_style = Style::default().fg(pal.title).bg(pal.bg);
    let market_state = market_state.expect("checked above");
    let bots_len = market_state.bots.len();
    let selected = market_state.selected_index.min(bots_len.saturating_sub(1));
    let visible_rows = layout[1].height.saturating_sub(1) as usize;
    let start = if selected >= visible_rows {
        selected + 1 - visible_rows
    } else {
        0
    };
    let end = (start + visible_rows).min(bots_len);

    let rows: Vec<Row> = market_state.bots[start..end]
        .iter()
        .enumerate()
        .map(|(i, b)| {
            let idx = start + i;
            let paused = if b.paused { "stopped" } else { "running" };
            let row_style = if idx == selected {
                Style::default().fg(pal.bg).bg(pal.title)
            } else {
                Style::default().fg(pal.text).bg(pal.bg)
            };
            let prefix = if idx == selected { "> " } else { "  " };
            Row::new(vec![
                Cell::from(format!("{}{}", prefix, b.name)),
                Cell::from(paused),
                Cell::from(b.orders_sent.to_string()),
                Cell::from(b.trades.to_string()),
            ])
            .style(row_style)
        })
        .collect();

    let table = Table::new(
        rows,
        [
            Constraint::Percentage(40),
            Constraint::Length(10),
            Constraint::Length(12),
            Constraint::Length(10),
        ],
    )
    .header(Row::new(vec![
        Cell::from("BOT").style(header_style),
        Cell::from("STATE").style(header_style),
        Cell::from("ORDERS").style(header_style),
        Cell::from("TRADES").style(header_style),
    ]))
    .column_spacing(1)
    .style(Style::default().bg(pal.bg));

    f.render_widget(table, layout[1]);
}

fn render_metrics_view(
    f: &mut ratatui::Frame,
    area: ratatui::layout::Rect,
    ui: &UiState,
    pal: Palette,
) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(pal.border))
        .title(Span::styled(
            "Metrics",
            Style::default().fg(pal.title).add_modifier(Modifier::BOLD),
        ))
        .style(Style::default().bg(pal.bg));
    let inner = block.inner(area);
    f.render_widget(block, area);

    let Some(m) = ui.metrics.as_ref() else {
        f.render_widget(
            Paragraph::new("no metrics yet (polling /metrics)")
                .style(Style::default().fg(pal.muted).bg(pal.bg)),
            inner,
        );
        return;
    };

    let snap = &m.snapshot;

    let counter_ix = |name: &str| snap.schema.counters.iter().position(|c| c.name == name);
    let gauge_ix = |name: &str| snap.schema.gauges.iter().position(|g| g.name == name);

    let in_ix = counter_ix("btx.stage.in_total");
    let out_ix = counter_ix("btx.stage.out_total");
    let err_ix = counter_ix("btx.stage.err_total");
    let bp_ix = counter_ix("btx.stage.backpressure_total");
    let inflight_ix = gauge_ix("btx.stage.inflight");

    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(4), Constraint::Min(0)])
        .split(inner);

    let age_ms = m.received_at.elapsed().as_millis();
    let fmt_ns_bound = |ns: Option<u64>| -> String {
        let Some(ns) = ns else {
            return "-".into();
        };
        if ns == u64::MAX {
            return "inf".into();
        }
        if ns >= 1_000_000_000 {
            format!("{:.1}s", (ns as f64) / 1_000_000_000.0)
        } else if ns >= 1_000_000 {
            format!("{:.1}ms", (ns as f64) / 1_000_000.0)
        } else if ns >= 1_000 {
            format!("{:.0}us", (ns as f64) / 1_000.0)
        } else {
            format!("{ns}ns")
        }
    };
    let header = vec![
        Line::from(format!(
            "captured_ns={} age_ms={age_ms} stages={}",
            snap.captured_unix_ns,
            snap.schema.stages.len()
        )),
        Line::from("semantics: engine IN=commands OUT=events | wal_poller IN=events OUT=commits"),
        Line::from(format!(
            "durations: window={}s p50/p99 from btx.stage.duration_ns",
            m.window.as_secs_f64()
        )),
        Line::from(format!(
            "topline: engine cmd/s≈{} ev/s≈{} q={} | wal ev/s≈{} commits/s≈{}",
            ui.engine_cmd_per_s
                .map(|v| format!("{v:.0}"))
                .unwrap_or_else(|| "-".into()),
            ui.engine_ev_per_s
                .map(|v| format!("{v:.0}"))
                .unwrap_or_else(|| "-".into()),
            ui.engine_queue_len
                .map(|v| v.to_string())
                .unwrap_or_else(|| "-".into()),
            ui.wal_ev_per_s
                .map(|v| format!("{v:.0}"))
                .unwrap_or_else(|| "-".into()),
            ui.wal_commits_per_s
                .map(|v| format!("{v:.2}"))
                .unwrap_or_else(|| "-".into()),
        )),
    ];
    f.render_widget(
        Paragraph::new(header).style(Style::default().fg(pal.muted).bg(pal.bg)),
        layout[0],
    );

    let header_style = Style::default().fg(pal.title).bg(pal.bg);
    let rows: Vec<Row> = snap
        .schema
        .stages
        .iter()
        .enumerate()
        .map(|(sx, name)| {
            let get_c = |ix: Option<usize>| {
                ix.and_then(|cx| {
                    snap.counter_values
                        .get(sx)
                        .and_then(|row| row.get(cx))
                        .copied()
                })
            };
            let get_g = |ix: Option<usize>| {
                ix.and_then(|gx| {
                    snap.gauge_values
                        .get(sx)
                        .and_then(|row| row.get(gx))
                        .copied()
                })
            };

            let in_v = get_c(in_ix);
            let out_v = get_c(out_ix);
            let ratio = match (name.as_str(), in_v, out_v) {
                ("engine", Some(cmds), Some(evs)) if cmds > 0 => {
                    format!("{:.2} ev/cmd", (evs as f64) / (cmds as f64))
                }
                ("wal_poller", Some(evs), Some(commits)) if commits > 0 => {
                    format!("{:.0} ev/commit", (evs as f64) / (commits as f64))
                }
                (_, Some(i), Some(o)) if i > 0 => format!("{:.2} out/in", (o as f64) / (i as f64)),
                _ => "-".into(),
            };

            let p50 = m.duration_p50_ns_by_stage.get(sx).copied().flatten();
            let p99 = m.duration_p99_ns_by_stage.get(sx).copied().flatten();
            let n = m.duration_samples_by_stage.get(sx).copied().unwrap_or(0);

            Row::new(vec![
                Cell::from(name.clone()),
                Cell::from(in_v.map(|v| v.to_string()).unwrap_or_else(|| "-".into())),
                Cell::from(out_v.map(|v| v.to_string()).unwrap_or_else(|| "-".into())),
                Cell::from(
                    get_c(err_ix)
                        .map(|v| v.to_string())
                        .unwrap_or_else(|| "-".into()),
                ),
                Cell::from(
                    get_c(bp_ix)
                        .map(|v| v.to_string())
                        .unwrap_or_else(|| "-".into()),
                ),
                Cell::from(
                    get_g(inflight_ix)
                        .map(|v| v.to_string())
                        .unwrap_or_else(|| "-".into()),
                ),
                Cell::from(n.to_string()),
                Cell::from(fmt_ns_bound(p50)),
                Cell::from(fmt_ns_bound(p99)),
                Cell::from(ratio),
            ])
            .style(Style::default().fg(pal.text).bg(pal.bg))
        })
        .collect();

    let table = Table::new(
        rows,
        [
            Constraint::Percentage(26),
            Constraint::Length(10),
            Constraint::Length(10),
            Constraint::Length(8),
            Constraint::Length(14),
            Constraint::Length(8),
            Constraint::Length(8),
            Constraint::Length(10),
            Constraint::Length(10),
            Constraint::Percentage(20),
        ],
    )
    .header(Row::new(vec![
        Cell::from("STAGE").style(header_style),
        Cell::from("IN").style(header_style),
        Cell::from("OUT").style(header_style),
        Cell::from("ERR").style(header_style),
        Cell::from("BACKPRESSURE").style(header_style),
        Cell::from("INFLIGHT").style(header_style),
        Cell::from("N").style(header_style),
        Cell::from("P50").style(header_style),
        Cell::from("P99").style(header_style),
        Cell::from("RATIO").style(header_style),
    ]))
    .column_spacing(1);

    f.render_widget(table, layout[1]);
}

fn render_logs_view(
    f: &mut ratatui::Frame,
    area: ratatui::layout::Rect,
    ui: &UiState,
    pal: Palette,
) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(pal.border))
        .title(Span::styled(
            "Logs",
            Style::default().fg(pal.title).add_modifier(Modifier::BOLD),
        ))
        .style(Style::default().bg(pal.bg));
    let inner = block.inner(area);
    f.render_widget(block, area);

    if ui.logs.is_empty() {
        f.render_widget(
            Paragraph::new("no logs yet").style(Style::default().fg(pal.muted).bg(pal.bg)),
            inner,
        );
        return;
    }

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(1), Constraint::Min(0)])
        .split(inner);

    let header = Line::from(format!("entries: {} (max {})", ui.logs.len(), LOG_CAPACITY));
    f.render_widget(
        Paragraph::new(header).style(Style::default().fg(pal.muted).bg(pal.bg)),
        rows[0],
    );

    let visible_rows = rows[1].height as usize;
    let start = ui.logs.len().saturating_sub(visible_rows);
    let lines: Vec<Line> = ui
        .logs
        .iter()
        .skip(start)
        .map(|entry| Line::from(entry.as_str()))
        .collect();

    f.render_widget(
        Paragraph::new(lines).style(Style::default().fg(pal.text).bg(pal.bg)),
        rows[1],
    );
}

fn render_runner_panel(
    f: &mut ratatui::Frame,
    area: ratatui::layout::Rect,
    ui: &UiState,
    runner_id: u32,
    name: &str,
    pal: Palette,
) {
    let Some(s) = ui.snapshot.as_ref() else {
        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(pal.border))
            .title(Span::styled(
                name,
                Style::default().fg(pal.title).add_modifier(Modifier::BOLD),
            ))
            .style(Style::default().bg(pal.bg));
        let inner = block.inner(area);
        f.render_widget(block, area);
        f.render_widget(
            Paragraph::new("no snapshot yet").style(Style::default().fg(pal.muted).bg(pal.bg)),
            inner,
        );
        return;
    };

    let Some(r) = s.snapshot.runner(RunnerId(runner_id)) else {
        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(pal.border))
            .title(Span::styled(
                name,
                Style::default().fg(pal.title).add_modifier(Modifier::BOLD),
            ))
            .style(Style::default().bg(pal.bg));
        let inner = block.inner(area);
        f.render_widget(block, area);
        f.render_widget(
            Paragraph::new("runner not found").style(Style::default().fg(pal.muted).bg(pal.bg)),
            inner,
        );
        return;
    };

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(pal.border))
        .title(Span::styled(
            name,
            Style::default().fg(pal.title).add_modifier(Modifier::BOLD),
        ))
        .style(Style::default().bg(pal.bg));

    let inner = block.inner(area);
    f.render_widget(block, area);

    let depth = r
        .available_to_back
        .len()
        .max(r.available_to_lay.len())
        .max(1);

    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(0), Constraint::Length(1)])
        .split(inner);

    let header_style = Style::default().fg(pal.title).bg(pal.bg);
    let rows: Vec<Row> = (0..depth)
        .map(|i| {
            let back = r.available_to_back.get(i);
            let lay = r.available_to_lay.get(i);

            let back_sz = back.map(|p| p.size.0.to_string()).unwrap_or_default();
            let back_odds = back
                .map(|p| format!("{:.2}", p.price.to_decimal()))
                .unwrap_or_default();
            let lay_odds = lay
                .map(|p| format!("{:.2}", p.price.to_decimal()))
                .unwrap_or_default();
            let lay_sz = lay.map(|p| p.size.0.to_string()).unwrap_or_default();

            Row::new(vec![
                Cell::from(back_sz).style(Style::default().fg(pal.back).bg(pal.bg)),
                Cell::from(back_odds).style(Style::default().fg(pal.back).bg(pal.bg)),
                Cell::from(lay_odds).style(Style::default().fg(pal.lay).bg(pal.bg)),
                Cell::from(lay_sz).style(Style::default().fg(pal.lay).bg(pal.bg)),
            ])
        })
        .collect();

    let table = Table::new(
        rows,
        [
            Constraint::Length(10),
            Constraint::Length(8),
            Constraint::Length(8),
            Constraint::Length(10),
        ],
    )
    .header(Row::new(vec![
        Cell::from("BACK_SZ").style(header_style),
        Cell::from("BACK").style(header_style),
        Cell::from("LAY").style(header_style),
        Cell::from("LAY_SZ").style(header_style),
    ]))
    .column_spacing(1)
    .style(Style::default().bg(pal.bg));

    f.render_widget(table, layout[0]);

    let spread = Line::from(vec![
        Span::styled("matched: ", Style::default().fg(pal.muted).bg(pal.bg)),
        Span::styled(
            r.matched_volume.0.to_string(),
            Style::default().fg(pal.text).bg(pal.bg),
        ),
        Span::styled("  ", Style::default().bg(pal.bg)),
        Span::styled("spread: ", Style::default().fg(pal.muted).bg(pal.bg)),
        Span::styled(
            r.spread_ticks()
                .map(|t| format!("{t}t"))
                .unwrap_or_else(|| "-".to_string()),
            Style::default().fg(pal.text).bg(pal.bg),
        ),
        Span::styled("  ", Style::default().bg(pal.bg)),
        Span::styled(
            format!(
                "best(back={:<6} lay={:<6})",
                r.best_back()
                    .map(|p| format!("{:.2}", p.price.to_decimal()))
                    .unwrap_or_else(|| "-".to_string()),
                r.best_lay()
                    .map(|p| format!("{:.2}", p.price.to_decimal()))
                    .unwrap_or_else(|| "-".to_string()),
            ),
            Style::default().fg(pal.muted).bg(pal.bg),
        ),
    ]);
    f.render_widget(
        Paragraph::new(spread).style(Style::default().bg(pal.bg)),
        layout[1],
    );
}

fn render_binary_yes_panel(
    f: &mut ratatui::Frame,
    area: ratatui::layout::Rect,
    ui: &UiState,
    pal: Palette,
) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(pal.border))
        .title(Span::styled(
            "Binary (YES)",
            Style::default().fg(pal.title).add_modifier(Modifier::BOLD),
        ))
        .style(Style::default().bg(pal.bg));
    let inner = block.inner(area);
    f.render_widget(block, area);

    let Some(s) = ui.snapshot.as_ref() else {
        f.render_widget(
            Paragraph::new("no snapshot yet").style(Style::default().fg(pal.muted).bg(pal.bg)),
            inner,
        );
        return;
    };
    let Some(bin) = s.snapshot.binary() else {
        f.render_widget(
            Paragraph::new("binary depth unavailable")
                .style(Style::default().fg(pal.muted).bg(pal.bg)),
            inner,
        );
        return;
    };

    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(2), Constraint::Min(0)])
        .split(inner);

    let max = bin.max_price_ticks.max(1) as f64;
    let best_bid = bin.bids.first();
    let best_ask = bin.asks.first();

    let fmt_px = |ticks: u16| {
        let p = ticks as f64 / max;
        let dollars = p; // payout is $1.00 on YES; price is p dollars per share
        format!("{ticks:>5}  p={:>6.2}%  ${dollars:>5.2}", p * 100.0)
    };

    let summary = Line::from(vec![
        Span::styled("best_bid: ", Style::default().fg(pal.muted).bg(pal.bg)),
        Span::styled(
            best_bid
                .map(|l| format!("{} x{}", fmt_px(l.price_ticks), l.size_shares))
                .unwrap_or_else(|| "-".to_string()),
            Style::default().fg(pal.back).bg(pal.bg),
        ),
        Span::styled("   ", Style::default().bg(pal.bg)),
        Span::styled("best_ask: ", Style::default().fg(pal.muted).bg(pal.bg)),
        Span::styled(
            best_ask
                .map(|l| format!("{} x{}", fmt_px(l.price_ticks), l.size_shares))
                .unwrap_or_else(|| "-".to_string()),
            Style::default().fg(pal.lay).bg(pal.bg),
        ),
    ]);
    f.render_widget(
        Paragraph::new(summary).style(Style::default().bg(pal.bg)),
        layout[0],
    );

    let depth = bin.bids.len().max(bin.asks.len()).max(1);
    let rows: Vec<Row> = (0..depth)
        .map(|i| {
            let bid = bin.bids.get(i);
            let ask = bin.asks.get(i);
            Row::new(vec![
                Cell::from(
                    bid.map(|l| fmt_px(l.price_ticks))
                        .unwrap_or_else(String::new),
                )
                .style(Style::default().fg(pal.back).bg(pal.bg)),
                Cell::from(bid.map(|l| l.size_shares.to_string()).unwrap_or_default())
                    .style(Style::default().fg(pal.back).bg(pal.bg)),
                Cell::from(
                    ask.map(|l| fmt_px(l.price_ticks))
                        .unwrap_or_else(String::new),
                )
                .style(Style::default().fg(pal.lay).bg(pal.bg)),
                Cell::from(ask.map(|l| l.size_shares.to_string()).unwrap_or_default())
                    .style(Style::default().fg(pal.lay).bg(pal.bg)),
            ])
        })
        .collect();

    let header_style = Style::default().fg(pal.title).bg(pal.bg);
    let table = Table::new(
        rows,
        [
            Constraint::Length(22),
            Constraint::Length(10),
            Constraint::Length(22),
            Constraint::Length(10),
        ],
    )
    .header(Row::new(vec![
        Cell::from("BID").style(header_style),
        Cell::from("BID_Q").style(header_style),
        Cell::from("ASK").style(header_style),
        Cell::from("ASK_Q").style(header_style),
    ]))
    .column_spacing(1)
    .style(Style::default().bg(pal.bg));
    f.render_widget(table, layout[1]);
}

fn render_close_modal(
    f: &mut ratatui::Frame,
    area: ratatui::layout::Rect,
    modal: &CloseModalState,
    pal: Palette,
) {
    let popup_w = (area.width as f32 * 0.65) as u16;
    let popup_h = (area.height as f32 * 0.45) as u16;
    let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2;
    let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2;
    let popup = ratatui::layout::Rect::new(popup_x, popup_y, popup_w, popup_h);

    f.render_widget(Clear, popup);

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(pal.border))
        .style(Style::default().bg(pal.bg))
        .title(Span::styled(
            "Close Market",
            Style::default().fg(pal.title).add_modifier(Modifier::BOLD),
        ));
    let inner = block.inner(popup);
    f.render_widget(block, popup);

    let mut lines: Vec<Line> = Vec::with_capacity(modal.choices.len() + 4);
    lines.push(Line::from(Span::styled(
        "Select outcome (Enter) or Esc to cancel",
        Style::default().fg(pal.muted).bg(pal.bg),
    )));
    lines.push(Line::from(""));

    for (i, choice) in modal.choices.iter().enumerate() {
        let (label, kind_color) = match choice {
            CloseChoice::Settle { label, .. } => (label.as_str(), pal.text),
            CloseChoice::Void { label } => (label.as_str(), pal.lay),
        };

        let mut style = Style::default().fg(kind_color).bg(pal.bg);
        if i == modal.selected {
            style = style.fg(pal.bg).bg(pal.title).add_modifier(Modifier::BOLD);
        }

        lines.push(Line::from(vec![
            Span::styled(if i == modal.selected { "> " } else { "  " }, style),
            Span::styled(label, style),
        ]));
    }

    f.render_widget(
        Paragraph::new(lines).style(Style::default().bg(pal.bg)),
        inner,
    );
}

fn open_close_modal(ui: &UiState) -> Option<CloseModalState> {
    let snap = ui.snapshot.as_ref()?.snapshot.clone();
    let mut choices: Vec<CloseChoice> = snap
        .runner_refs()
        .into_iter()
        .map(|runner| CloseChoice::Settle {
            runner_id: runner.runner_id.0 as u64,
            label: format!(
                "Settle: {} (runner_id={})",
                runner.runner_label, runner.runner_id.0
            ),
        })
        .collect();
    choices.push(CloseChoice::Void {
        label: "Void market".to_string(),
    });

    Some(CloseModalState {
        choices,
        selected: 0,
    })
}

#[derive(Clone, Debug)]
struct CliConfig {
    url: String,
    high_throughput: bool,
    mm_bots: usize,
    noise_bots: usize,
    informed_bots: usize,
}

#[derive(Debug, Clone, Parser)]
#[command(name = "live_tui")]
struct CliArgs {
    /// Control websocket URL.
    #[arg(
        long,
        value_name = "WS_URL",
        default_value = "ws://127.0.0.1:3000/ws_ctl?depth=10"
    )]
    url: String,

    /// Faster bots + larger client buffers (does not imply --no-ack).
    #[arg(long)]
    high_throughput: bool,

    /// Append no_ack=1 to the WS URL (suppresses OrderResponse acks).
    #[arg(long)]
    no_ack: bool,

    /// Total bot count, distributed as 35% MM, 50% Noise, 15% Informed (default 20).
    #[arg(long, value_name = "N")]
    bots: Option<usize>,

    /// Number of market maker bots (overrides --bots distribution).
    #[arg(long, value_name = "N")]
    mm_bots: Option<usize>,

    /// Number of noise trader bots (overrides --bots distribution).
    #[arg(long, value_name = "N")]
    noise_bots: Option<usize>,

    /// Number of informed trader bots (overrides --bots distribution).
    #[arg(long, value_name = "N")]
    informed_bots: Option<usize>,
}

fn parse_cli() -> CliConfig {
    let args = CliArgs::parse();

    let mut url = args.url;
    if args.no_ack && !url.contains("no_ack=") {
        if url.contains('?') {
            url.push_str("&no_ack=1");
        } else {
            url.push_str("?no_ack=1");
        }
    }

    // Default total is 20 bots, distributed as 7 MM (35%), 10 Noise (50%), 3 Informed (15%)
    let total = args.bots.unwrap_or(20);
    let mm_default = (total as f64 * 0.35).round() as usize;
    let noise_default = (total as f64 * 0.50).round() as usize;
    let informed_default = total
        .saturating_sub(mm_default)
        .saturating_sub(noise_default);

    CliConfig {
        url,
        high_throughput: args.high_throughput,
        mm_bots: args.mm_bots.unwrap_or(mm_default),
        noise_bots: args.noise_bots.unwrap_or(noise_default),
        informed_bots: args.informed_bots.unwrap_or(informed_default),
    }
}

fn http_host_port_from_ws_url(ws_url: &str) -> Option<String> {
    let url = ws_url
        .strip_prefix("ws://")
        .or_else(|| ws_url.strip_prefix("wss://"))?;
    let end = url.find('/').or_else(|| url.find('?')).unwrap_or(url.len());
    Some(url[..end].to_string())
}

async fn fetch_http_json(host_port: &str, path: &str) -> anyhow::Result<Vec<u8>> {
    let mut stream = tokio::net::TcpStream::connect(host_port).await?;
    let req = format!(
        "GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\nAccept: application/json\r\n\r\n"
    );
    stream.write_all(req.as_bytes()).await?;
    stream.flush().await?;

    let mut buf = Vec::new();
    stream.read_to_end(&mut buf).await?;

    let sep = b"\r\n\r\n";
    let Some(ix) = buf.windows(sep.len()).position(|w| w == sep) else {
        return Err(anyhow::anyhow!(
            "invalid http response (no header/body separator)"
        ));
    };
    let (hdr, body) = buf.split_at(ix + sep.len());
    let headers = std::str::from_utf8(hdr)?;
    if !headers.starts_with("HTTP/1.1 200") && !headers.starts_with("HTTP/1.0 200") {
        return Err(anyhow::anyhow!(
            "http non-200: {}",
            headers.lines().next().unwrap_or("unknown")
        ));
    }

    let headers_lc = headers.to_ascii_lowercase();
    if headers_lc.contains("transfer-encoding: chunked") {
        let mut out = Vec::new();
        let mut i = 0usize;
        while i < body.len() {
            let Some(line_end) = body[i..].windows(2).position(|w| w == b"\r\n") else {
                break;
            };
            let line = std::str::from_utf8(&body[i..i + line_end])?;
            let size = usize::from_str_radix(line.trim(), 16)?;
            i += line_end + 2;
            if size == 0 {
                break;
            }
            if i + size > body.len() {
                return Err(anyhow::anyhow!("chunk exceeds body length"));
            }
            out.extend_from_slice(&body[i..i + size]);
            i += size + 2; // skip chunk + CRLF
        }
        return Ok(out);
    }

    if let Some(cl) = headers_lc
        .lines()
        .find(|l| l.starts_with("content-length:"))
        .and_then(|l| l.split(':').nth(1))
        .map(|v| v.trim())
        .and_then(|v| v.parse::<usize>().ok())
    {
        return Ok(body[..cl.min(body.len())].to_vec());
    }

    Ok(body.to_vec())
}

async fn metrics_loop(host_port: String, ui_tx: watch::Sender<UiState>) {
    // Poll fairly often, but compute ev/s over a sliding window to avoid flicker from batched counters.
    let mut interval = tokio::time::interval(Duration::from_millis(200));
    let mut eng_out: VecDeque<(Instant, u64)> = VecDeque::with_capacity(32);
    let mut eng_in: VecDeque<(Instant, u64)> = VecDeque::with_capacity(32);
    let mut wal_in: VecDeque<(Instant, u64)> = VecDeque::with_capacity(32);
    let mut wal_out: VecDeque<(Instant, u64)> = VecDeque::with_capacity(32);
    let window = Duration::from_secs(2);
    let mut prev_snapshot: Option<MetricsSnapshot> = None;
    let mut sticky_p50_by_stage: Vec<Option<u64>> = Vec::new();
    let mut sticky_p99_by_stage: Vec<Option<u64>> = Vec::new();

    loop {
        interval.tick().await;
        let res = fetch_http_json(&host_port, "/metrics").await;
        match res {
            Ok(body) => match serde_json::from_slice::<MetricsSnapshot>(&body) {
                Ok(snapshot) => {
                    let now = Instant::now();

                    let stage_ix =
                        |name: &str| snapshot.schema.stages.iter().position(|s| s == name);
                    let counter_ix =
                        |name: &str| snapshot.schema.counters.iter().position(|c| c.name == name);
                    let gauge_ix =
                        |name: &str| snapshot.schema.gauges.iter().position(|g| g.name == name);
                    let hist_ix = |name: &str| {
                        snapshot
                            .schema
                            .histograms
                            .iter()
                            .position(|h| h.metric.name == name)
                    };

                    let get_counter = |sx: Option<usize>, cx: Option<usize>| match (sx, cx) {
                        (Some(sx), Some(cx)) => snapshot
                            .counter_values
                            .get(sx)
                            .and_then(|row| row.get(cx))
                            .copied(),
                        _ => None,
                    };
                    let get_gauge = |sx: Option<usize>, gx: Option<usize>| match (sx, gx) {
                        (Some(sx), Some(gx)) => snapshot
                            .gauge_values
                            .get(sx)
                            .and_then(|row| row.get(gx))
                            .copied(),
                        _ => None,
                    };

                    let eng_sx = stage_ix("engine");
                    let wal_sx = stage_ix("wal_poller");

                    let in_cx = counter_ix("btx.stage.in_total");
                    let out_cx = counter_ix("btx.stage.out_total");
                    let q_gx = gauge_ix("btx.stage.queue_len");

                    if let Some(v) = get_counter(eng_sx, out_cx) {
                        eng_out.push_back((now, v));
                    }
                    if let Some(v) = get_counter(eng_sx, in_cx) {
                        eng_in.push_back((now, v));
                    }
                    if let Some(v) = get_counter(wal_sx, in_cx) {
                        wal_in.push_back((now, v));
                    }
                    if let Some(v) = get_counter(wal_sx, out_cx) {
                        wal_out.push_back((now, v));
                    }

                    for q in [&mut eng_out, &mut eng_in, &mut wal_in, &mut wal_out] {
                        while q
                            .front()
                            .is_some_and(|(t, _)| now.duration_since(*t) > window)
                        {
                            q.pop_front();
                        }
                    }

                    let rate = |q: &VecDeque<(Instant, u64)>| -> Option<f64> {
                        if q.len() < 2 {
                            return None;
                        }
                        let (t0, v0) = q.front().copied().unwrap();
                        let (t1, v1) = q.back().copied().unwrap();
                        let dt = t1.duration_since(t0).as_secs_f64();
                        if dt > 0.0 {
                            Some((v1.saturating_sub(v0)) as f64 / dt)
                        } else {
                            None
                        }
                    };

                    let engine_evps = rate(&eng_out);
                    let engine_cmdps = rate(&eng_in);
                    let wal_evps = rate(&wal_in);
                    let wal_commitps = rate(&wal_out);
                    let engine_queue_len = get_gauge(eng_sx, q_gx);

                    // Windowed p50/p99 for stage duration_ns (sticky: don't clear to "-" when no samples).
                    if sticky_p50_by_stage.len() != snapshot.schema.stages.len() {
                        sticky_p50_by_stage = vec![None; snapshot.schema.stages.len()];
                        sticky_p99_by_stage = vec![None; snapshot.schema.stages.len()];
                    }
                    let mut duration_samples_by_stage = vec![0u64; snapshot.schema.stages.len()];

                    if let (Some(hx), Some(prev)) =
                        (hist_ix("btx.stage.duration_ns"), prev_snapshot.as_ref())
                    {
                        let buckets: &[u64] = snapshot
                            .schema
                            .histograms
                            .get(hx)
                            .map(|h| h.buckets.as_slice())
                            .unwrap_or(&[]);
                        let bucket_count = buckets.len().saturating_add(1); // +Inf bucket

                        let cur_all = snapshot.histogram_values.get(hx);
                        let prev_all = prev.histogram_values.get(hx);

                        if let (Some(cur_all), Some(prev_all)) = (cur_all, prev_all) {
                            for sx in 0..snapshot.schema.stages.len() {
                                let Some(cur) = cur_all.get(sx) else { continue };
                                let Some(prev_row) = prev_all.get(sx) else {
                                    continue;
                                };
                                if cur.len() != bucket_count || prev_row.len() != bucket_count {
                                    continue;
                                }

                                let mut quantile = |q: f64| -> Option<u64> {
                                    let mut total = 0u64;
                                    for i in 0..bucket_count {
                                        let dv = cur[i].saturating_sub(prev_row[i]);
                                        total = total.saturating_add(dv);
                                    }
                                    duration_samples_by_stage[sx] = total;
                                    if total == 0 {
                                        return None;
                                    }
                                    let target =
                                        ((total as f64) * q).ceil().clamp(1.0, total as f64) as u64;
                                    let mut cum = 0u64;
                                    for i in 0..bucket_count {
                                        let dv = cur[i].saturating_sub(prev_row[i]);
                                        cum = cum.saturating_add(dv);
                                        if cum >= target {
                                            return Some(
                                                buckets.get(i).copied().unwrap_or(u64::MAX), // +Inf
                                            );
                                        }
                                    }
                                    Some(u64::MAX)
                                };

                                if let Some(v) = quantile(0.50) {
                                    sticky_p50_by_stage[sx] = Some(v);
                                }
                                if let Some(v) = quantile(0.99) {
                                    sticky_p99_by_stage[sx] = Some(v);
                                }
                            }
                        }
                    }

                    let snapshot_for_prev = snapshot.clone();
                    ui_tx.send_modify(|u| {
                        u.metrics = Some(MetricsState {
                            snapshot,
                            received_at: now,
                            window,
                            duration_samples_by_stage,
                            duration_p50_ns_by_stage: sticky_p50_by_stage.clone(),
                            duration_p99_ns_by_stage: sticky_p99_by_stage.clone(),
                        });
                        u.engine_ev_per_s = engine_evps;
                        u.engine_cmd_per_s = engine_cmdps;
                        u.wal_ev_per_s = wal_evps;
                        u.wal_commits_per_s = wal_commitps;
                        u.engine_queue_len = engine_queue_len;
                        u.last_metrics_error = None;
                    });

                    prev_snapshot = Some(snapshot_for_prev);
                }
                Err(e) => {
                    ui_tx.send_modify(|u| u.set_metrics_error(format!("decode: {e}")));
                }
            },
            Err(e) => {
                ui_tx.send_modify(|u| u.set_metrics_error(format!("fetch: {e}")));
            }
        }
    }
}

async fn ws_loop(
    url: String,
    ui_tx: watch::Sender<UiState>,
    bot_ctx_by_account: Arc<RwLock<HashMap<u64, Arc<BotContext>>>>,
    mut user_cmd_rx: mpsc::Receiver<ClientMsg>,
    mut bot_cmd_rx: mpsc::Receiver<ClientMsg>,
) {
    let mut backoff = Duration::from_millis(200);
    loop {
        ui_tx.send_modify(|u| {
            u.conn = ConnState::Connecting;
            u.last_error = None;
        });

        match connect_async(&url).await {
            Ok((ws, _resp)) => {
                backoff = Duration::from_millis(200);
                ui_tx.send_modify(|u| u.conn = ConnState::Connected);

                let (mut write, mut read) = ws.split();

                // Request initial market list on connect
                if let Ok(text) = ws_protocol::encode_msg(&ClientMsg::ListMarkets) {
                    let _ = write.send(WsMessage::Text(text.into())).await;
                }

                let describe_user_cmd = |cmd: &ClientMsg| match cmd {
                    ClientMsg::CreateMarket {
                        name,
                        market_model,
                        market_kind,
                        market_phase,
                        ..
                    } => {
                        format!(
                            "CreateMarket name=\"{name}\" model={market_model:?} kind={market_kind:?} phase={market_phase:?}"
                        )
                    }
                    ClientMsg::RemoveMarket { market_id } => {
                        format!("RemoveMarket id={}", market_id.0)
                    }
                    ClientMsg::ListMarkets => "ListMarkets".to_string(),
                    ClientMsg::Subscribe { market_id } => format!("Subscribe id={}", market_id.0),
                    ClientMsg::SetMarketState { market_id, state } => {
                        format!(
                            "SetMarketState id={:?} state={state:?}",
                            market_id.map(|m| m.0)
                        )
                    }
                    ClientMsg::SetMarketPhase { market_id, phase } => {
                        format!(
                            "SetMarketPhase id={:?} phase={phase:?}",
                            market_id.map(|m| m.0)
                        )
                    }
                    ClientMsg::CloseMarket {
                        market_id,
                        batch_max_events,
                    } => format!(
                        "CloseMarket id={:?} batch_max_events={batch_max_events}",
                        market_id.map(|m| m.0)
                    ),
                    ClientMsg::BatchCancelOrders {
                        correlation_id,
                        market_id,
                        batch_max_events,
                        ..
                    } => format!(
                        "BatchCancelOrders corr_id={} id={:?} batch_max_events={batch_max_events}",
                        correlation_id
                            .as_ref()
                            .map(|v| v.as_str())
                            .unwrap_or("none"),
                        market_id.map(|m| m.0)
                    ),
                    ClientMsg::ContinueBatchCancelOrders {
                        correlation_id,
                        market_id,
                    } => format!(
                        "ContinueBatchCancelOrders corr_id={} id={:?}",
                        correlation_id
                            .as_ref()
                            .map(|v| v.as_str())
                            .unwrap_or("none"),
                        market_id.map(|m| m.0)
                    ),
                    ClientMsg::SettleMarket {
                        market_id,
                        winner_runner_id,
                    } => format!(
                        "SettleMarket id={:?} winner_runner_id={}",
                        market_id.map(|m| m.0),
                        winner_runner_id.0
                    ),
                    ClientMsg::VoidMarket { market_id, .. } => {
                        format!("VoidMarket id={:?}", market_id.map(|m| m.0))
                    }
                    ClientMsg::HaltMarket { market_id, reason } => {
                        format!("HaltMarket id={:?} reason={reason}", market_id.map(|m| m.0))
                    }
                    ClientMsg::ResumeMarket { market_id } => {
                        format!("ResumeMarket id={:?}", market_id.map(|m| m.0))
                    }
                    ClientMsg::Ping => "Ping".to_string(),
                    ClientMsg::PlaceOrder { market_id, .. } => {
                        format!("PlaceOrder market_id={}", market_id.0)
                    }
                    ClientMsg::PlaceBinaryOrder { market_id, .. } => {
                        format!("PlaceBinaryOrder market_id={}", market_id.0)
                    }
                    ClientMsg::CancelOrder {
                        market_id,
                        order_id,
                        ..
                    } => {
                        format!(
                            "CancelOrder market_id={} order_id={}",
                            market_id.0, order_id.0
                        )
                    }
                };

                loop {
                    tokio::select! {
                        biased;
                        incoming = read.next() => {
                            let Some(incoming) = incoming else { break; };
                            match incoming {
                                Ok(WsMessage::Text(text)) => {
                                    match ws_protocol::decode_msg::<ServerMsg>(text.as_bytes()) {
                                        Ok(msg) => apply_server_msg(&ui_tx, &bot_ctx_by_account, msg),
                                        Err(e) => {
                                            let prefix: String = text.chars().take(64).collect();
                                            ui_tx.send_modify(|u| {
                                                u.set_error(format!("invalid server msg: json={e}; len={} prefix={prefix:?}", text.len()))
                                            });
                                        }
                                    }
                                }
                                Ok(WsMessage::Binary(bytes)) => {
                                    let raw = bytes.as_ref();
                                    match ws_protocol::decode_msg::<ServerMsg>(raw) {
                                        Ok(msg) => apply_server_msg(&ui_tx, &bot_ctx_by_account, msg),
                                        Err(e) => {
                                            let prefix: String = raw.iter().take(8).map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ");
                                            ui_tx.send_modify(|u| {
                                                u.set_error(format!("invalid server msg: json={e}; len={} prefix=[{prefix}]", raw.len()))
                                            });
                                        }
                                    }
                                }
                                Ok(WsMessage::Close(_)) => break,
                                Ok(_) => {}
                                Err(e) => {
                                    ui_tx.send_modify(|u| u.set_error(format!("ws read: {e}")));
                                    break;
                                }
                            }
                        }
                        cmd = user_cmd_rx.recv() => {
                            let Some(cmd) = cmd else { return; };
                            match ws_protocol::encode_msg(&cmd) {
                                Ok(text) => {
                                    if let Err(e) = write.send(WsMessage::Text(text.into())).await {
                                        ui_tx.send_modify(|u| u.set_error(format!("ws send: {e}")));
                                        break;
                                    }
                                    let desc = describe_user_cmd(&cmd);
                                    ui_tx.send_modify(|u| u.push_log(format!("ws send: {desc}")));
                                }
                                Err(e) => {
                                    ui_tx.send_modify(|u| u.set_error(format!("encode cmd: {e}")));
                                }
                            }
                        }
                        cmd = bot_cmd_rx.recv() => {
                            let Some(cmd) = cmd else { return; };
                            match ws_protocol::encode_msg(&cmd) {
                                Ok(text) => {
                                    if let Err(e) = write.send(WsMessage::Text(text.into())).await {
                                        ui_tx.send_modify(|u| u.set_error(format!("ws send: {e}")));
                                        break;
                                    }
                                }
                                Err(e) => {
                                    ui_tx.send_modify(|u| u.set_error(format!("encode cmd: {e}")));
                                }
                            }
                        }
                    }
                }

                ui_tx.send_modify(|u| u.conn = ConnState::Disconnected);
            }
            Err(e) => {
                let msg = format!("connect: {e}");
                ui_tx.send_modify(|u| {
                    u.conn = ConnState::Disconnected;
                    u.set_error(msg.clone());
                });
            }
        }

        tokio::time::sleep(backoff).await;
        backoff = (backoff * 2).min(Duration::from_secs(3));
    }
}

fn apply_server_msg(
    ui_tx: &watch::Sender<UiState>,
    bot_ctx_by_account: &Arc<RwLock<HashMap<u64, Arc<BotContext>>>>,
    msg: ServerMsg,
) {
    match msg {
        ServerMsg::Snapshot { snapshot } => {
            ui_tx.send_modify(|u| {
                u.snapshot = Some(SnapshotState {
                    snapshot,
                    received_at: Instant::now(),
                });
            });
        }
        ServerMsg::Status {
            global_seq,
            market_count,
        } => {
            ui_tx.send_modify(|u| {
                u.status = Some(WsStatus {
                    global_seq,
                    market_count,
                    received_at: Instant::now(),
                });
            });
        }
        ServerMsg::Ack {
            ok,
            response,
            error,
        } => {
            ui_tx.send_modify(|u| {
                if ok {
                    u.last_error = None;
                    if let Some(err) = error {
                        u.last_ack = Some(format!("ok (note): {err}"));
                        u.push_log(format!("ack ok (note): {err}"));
                    } else if let Some(resp) = response {
                        u.last_ack = Some(format!("ok: {resp:?}"));
                        u.push_log(format!("ack ok: {resp:?}"));
                    } else {
                        u.last_ack = Some("ok".to_string());
                        u.push_log("ack ok");
                    }
                } else {
                    u.last_ack = None;
                    u.set_error(error.unwrap_or_else(|| "command failed".to_string()));
                }
            });
        }
        ServerMsg::OrderResponse {
            correlation_id,
            ok,
            order_id,
            trades,
            error,
            ..
        } => {
            let corr_label = correlation_id
                .as_ref()
                .map(|v| v.as_str())
                .unwrap_or("none");
            ui_tx.send_modify(|u| {
                if ok {
                    u.last_error = None;
                    let mut msg = format!("order ok (corr_id={corr_label})");
                    if let Some(oid) = order_id {
                        msg.push_str(&format!(", order_id={}", oid.0));
                    }
                    let trades_n = trades.len();
                    if trades_n > 0 {
                        msg.push_str(&format!(", trades={trades_n}"));
                    }
                    if let Some(e) = &error {
                        msg.push_str(&format!(" (note): {e}"));
                    }
                    u.last_ack = Some(msg);
                } else {
                    u.last_ack = None;
                    u.set_error(
                        error.unwrap_or_else(|| format!("order failed (corr_id={corr_label})")),
                    );
                }
            });

            if ok && !trades.is_empty() {
                if let Some(correlation_id) = correlation_id.as_ref() {
                    if let Ok(encoded) = correlation_id.as_str().parse::<u64>() {
                        let account_id = encoded >> 32;
                        if let Some(ctx) = bot_ctx_by_account.read().unwrap().get(&account_id) {
                            for _ in 0..trades.len() {
                                ctx.record_trade();
                            }
                        }
                    }
                }
            }
        }
        ServerMsg::MarketCreated { market } => {
            ui_tx.send_modify(|u| {
                if let Some(existing) = u
                    .markets
                    .iter_mut()
                    .find(|m| m.market_id == market.market_id)
                {
                    *existing = market.clone();
                } else {
                    u.markets.push(market.clone());
                }
                u.markets.sort_by_key(|m| m.market_id.0);
                u.last_ack = Some(format!("Market created: {}", market.market_id.0));
                u.push_log(format!(
                    "market created: id={} model={:?} phase={:?} name=\"{}\"",
                    market.market_id.0, market.market_model, market.market_phase, market.name
                ));
            });
        }
        ServerMsg::MarketRemoved { market_id } => {
            ui_tx.send_modify(|u| {
                u.markets.retain(|m| m.market_id != market_id);
                u.bot_markets.remove(&market_id);
                // Adjust selection if needed
                if u.selected_market_index >= u.markets.len() && !u.markets.is_empty() {
                    u.selected_market_index = u.markets.len() - 1;
                }
                if u.selected_market_id == Some(market_id) {
                    u.selected_market_id = None;
                }
                u.last_ack = Some(format!("Market removed: {}", market_id.0));
            });
        }
        ServerMsg::MarketList { markets } => {
            ui_tx.send_modify(|u| {
                let mut merged = u
                    .markets
                    .iter()
                    .cloned()
                    .map(|m| (m.market_id, m))
                    .collect::<HashMap<_, _>>();

                for market in markets {
                    merged.insert(market.market_id, market);
                }

                let mut merged_markets: Vec<_> = merged.into_values().collect();
                merged_markets.sort_by_key(|m| m.market_id.0);
                u.markets = merged_markets;
                u.last_ack = Some(format!("Loaded {} markets", u.markets.len()));
                u.push_log(format!("market list: {} markets", u.markets.len()));
            });
        }
        ServerMsg::MarketStatusChanged { market_id, status } => {
            ui_tx.send_modify(|u| {
                if let Some(m) = u.markets.iter_mut().find(|m| m.market_id == market_id) {
                    m.status = status;
                    u.push_log(format!(
                        "market status: id={} status={}",
                        market_id.0, status
                    ));
                }
            });
        }
        ServerMsg::MarketPhaseChanged { market_id, phase } => {
            ui_tx.send_modify(|u| {
                if let Some(m) = u.markets.iter_mut().find(|m| m.market_id == market_id) {
                    m.market_phase = phase;
                    u.push_log(format!("market phase: id={} phase={phase}", market_id.0));
                }
            });
        }
    }
}

// ============================================================================
// Bot Infrastructure
// ============================================================================

const HOME_RUNNER: u64 = 1;
const DRAW_RUNNER: u64 = 2;
const AWAY_RUNNER: u64 = 3;

/// Shared bot context with counters for UI display
struct BotContext {
    name: String,
    account_id: u64,
    orders_sent: AtomicU64,
    trades: AtomicU64,
    paused: AtomicBool,
    correlation_seq: AtomicU64,
}

impl BotContext {
    fn new(name: &str, account_id: u64) -> Self {
        Self {
            name: name.to_string(),
            account_id,
            orders_sent: AtomicU64::new(0),
            trades: AtomicU64::new(0),
            paused: AtomicBool::new(false),
            correlation_seq: AtomicU64::new(0),
        }
    }

    fn next_correlation_id(&self) -> u64 {
        debug_assert!(
            self.account_id <= u32::MAX as u64,
            "account_id too large to encode in correlation_id"
        );
        let seq = self.correlation_seq.fetch_add(1, Ordering::Relaxed) & 0xFFFF_FFFF;
        (self.account_id << 32) | seq
    }

    fn record_order(&self) {
        self.orders_sent.fetch_add(1, Ordering::Relaxed);
    }

    fn record_trade(&self) {
        self.trades.fetch_add(1, Ordering::Relaxed);
    }

    fn is_paused(&self) -> bool {
        self.paused.load(Ordering::Relaxed)
    }

    fn set_paused(&self, paused: bool) {
        self.paused.store(paused, Ordering::Relaxed);
    }

    fn status(&self) -> BotEntry {
        BotEntry {
            name: self.name.clone(),
            orders_sent: self.orders_sent.load(Ordering::Relaxed),
            trades: self.trades.load(Ordering::Relaxed),
            paused: self.is_paused(),
        }
    }
}

/// Order that a bot wants to place
enum BotOrder {
    Odds {
        runner_id: u64,
        side: Side,
        odds: OddsX10000,
        stake: Money,
        time_in_force: TimeInForce,
    },
    Binary {
        side: BinarySide,
        price_ticks: u16,
        qty_shares: u64,
        time_in_force: BinaryTimeInForce,
    },
}

struct BotInstance {
    ctx: Arc<BotContext>,
}

impl BotInstance {
    fn entry(&self) -> BotEntry {
        self.ctx.status()
    }
}

#[derive(Default)]
struct BotCounters {
    mm: usize,
    noise: usize,
    informed: usize,
}

/// Market maker bot - quotes at multiple depth levels
struct MarketMakerBot {
    rng: SmallRng,
    base_odds: [f64; 3], // fair odds for each runner
    depth_levels: usize,
    base_stake: i64,
    binary_mid_p: f64,
    binary_depth_levels: usize,
    binary_base_qty: u64,
}

impl MarketMakerBot {
    fn new() -> Self {
        Self {
            rng: SmallRng::from_rng(&mut rand::rng()),
            base_odds: [2.5, 3.5, 3.0], // Home/Draw/Away
            depth_levels: 5,
            base_stake: 100,
            binary_mid_p: 0.5,
            binary_depth_levels: 10,
            binary_base_qty: 20,
        }
    }

    fn generate_quotes(&mut self, snapshot: &MarketSnapshot) -> Vec<BotOrder> {
        if !snapshot.state.is_matchable() {
            return vec![];
        }

        let mut orders = Vec::new();
        match snapshot.market_model() {
            betex::types::MarketModel::ExchangeOdds => {
                let runners = [HOME_RUNNER, DRAW_RUNNER, AWAY_RUNNER];

                for (idx, &runner_id) in runners.iter().enumerate() {
                    let fair = self.base_odds[idx];
                    // Slight random walk
                    self.base_odds[idx] =
                        (fair + self.rng.random_range(-0.02..0.02)).clamp(1.1, 20.0);

                    for level in 0..self.depth_levels {
                        let stake = self.base_stake * (level as i64 + 1);

                        // Back side (lower odds than fair) - use floor_tick for valid price
                        let back_odds = fair - 0.02 * (level as f64 + 1.0);
                        if let Some(odds) =
                            OddsX10000::from_decimal(back_odds).and_then(|o| o.floor_tick())
                        {
                            orders.push(BotOrder::Odds {
                                runner_id,
                                side: Side::Back,
                                odds,
                                stake: Money(stake),
                                time_in_force: TimeInForce::Gtc,
                            });
                        }

                        // Lay side (higher odds than fair) - use ceil_tick for valid price
                        let lay_odds = fair + 0.02 * (level as f64 + 1.0);
                        if let Some(odds) =
                            OddsX10000::from_decimal(lay_odds).and_then(|o| o.ceil_tick())
                        {
                            orders.push(BotOrder::Odds {
                                runner_id,
                                side: Side::Lay,
                                odds,
                                stake: Money(stake),
                                time_in_force: TimeInForce::Gtc,
                            });
                        }
                    }
                }
            }
            betex::types::MarketModel::BinaryYes { .. } => {
                let Some(bin) = snapshot.binary() else {
                    return vec![];
                };
                let max = bin.max_price_ticks.max(2) as i32;

                let mid_p = match (bin.bids.first(), bin.asks.first()) {
                    (Some(b), Some(a)) => {
                        (b.price_ticks as f64 + a.price_ticks as f64) / (2.0 * max as f64)
                    }
                    _ => self.binary_mid_p,
                };
                // Small random walk on belief, keep away from boundaries to avoid sticky prices.
                self.binary_mid_p = (mid_p + self.rng.random_range(-0.01..0.01)).clamp(0.1, 0.9);

                let mid_ticks = (self.binary_mid_p * max as f64).round() as i32;
                let spread_ticks = ((max as u64) / 50).clamp(2, 8) as i32; // 100->2 ticks
                let half = spread_ticks / 2;

                for level in 0..self.binary_depth_levels {
                    let step = level as i32;
                    let bid = (mid_ticks - half - step).clamp(1, max - 1) as u16;
                    let ask = (mid_ticks + half + step).clamp(1, max - 1) as u16;
                    let qty = self
                        .binary_base_qty
                        .saturating_mul((level as u64).saturating_add(1));

                    orders.push(BotOrder::Binary {
                        side: BinarySide::Buy,
                        price_ticks: bid,
                        qty_shares: qty,
                        time_in_force: BinaryTimeInForce::Gtc,
                    });
                    orders.push(BotOrder::Binary {
                        side: BinarySide::Sell,
                        price_ticks: ask,
                        qty_shares: qty,
                        time_in_force: BinaryTimeInForce::Gtc,
                    });
                }
            }
        }

        orders
    }
}

/// Noise trader - random IOC orders
struct NoiseTraderBot {
    rng: SmallRng,
}

impl NoiseTraderBot {
    fn new() -> Self {
        Self {
            rng: SmallRng::from_rng(&mut rand::rng()),
        }
    }

    fn generate_order(&mut self, snapshot: &MarketSnapshot) -> Option<BotOrder> {
        if !snapshot.state.is_matchable() {
            return None;
        }

        match snapshot.market_model() {
            betex::types::MarketModel::ExchangeOdds => {
                let runners = [HOME_RUNNER, DRAW_RUNNER, AWAY_RUNNER];
                let runner_id = runners[self.rng.random_range(0..3)];
                let side = if self.rng.random_bool(0.5) {
                    Side::Back
                } else {
                    Side::Lay
                };

                // Pick a price near current market
                let runner_snap = snapshot
                    .runners()
                    .iter()
                    .find(|r| r.runner_id.0 as u64 == runner_id)?;
                let base_odds = match side {
                    Side::Back => runner_snap.best_back().map(|p| p.price.to_decimal())?,
                    Side::Lay => runner_snap.best_lay().map(|p| p.price.to_decimal())?,
                };

                // Slight improvement to cross the spread sometimes
                let odds_decimal =
                    (base_odds + self.rng.random_range(-0.1..0.1)).clamp(1.01, 1000.0);
                // Round to valid tick - use ceil for back (want to pay more), floor for lay (want to receive less)
                let odds = match side {
                    Side::Back => {
                        OddsX10000::from_decimal(odds_decimal).and_then(|o| o.ceil_tick())?
                    }
                    Side::Lay => {
                        OddsX10000::from_decimal(odds_decimal).and_then(|o| o.floor_tick())?
                    }
                };
                let stake = Money(self.rng.random_range(10..50));

                Some(BotOrder::Odds {
                    runner_id,
                    side,
                    odds,
                    stake,
                    time_in_force: TimeInForce::FillOrKill {
                        min_fill: Some(Quantity(0)),
                    },
                })
            }
            betex::types::MarketModel::BinaryYes { max_price_ticks } => {
                let bin = snapshot.binary()?;
                let max = max_price_ticks.max(2) as i32;
                let best_bid = bin.bids.first().map(|l| l.price_ticks as i32);
                let best_ask = bin.asks.first().map(|l| l.price_ticks as i32);
                let mid = match (best_bid, best_ask) {
                    (Some(b), Some(a)) => (b + a) / 2,
                    _ => max / 2,
                };

                let side = if self.rng.random_bool(0.5) {
                    BinarySide::Buy
                } else {
                    BinarySide::Sell
                };
                let aggressive = self.rng.random_bool(0.6);
                let band = (max / 10).clamp(2, 15);
                let px = match (side, aggressive, best_bid, best_ask) {
                    (BinarySide::Buy, true, _, Some(a)) => a,
                    (BinarySide::Sell, true, Some(b), _) => b,
                    _ => (mid + self.rng.random_range(-band..=band)).clamp(1, max - 1),
                } as u16;
                let qty = self.rng.random_range(1..=25) as u64;

                Some(BotOrder::Binary {
                    side,
                    price_ticks: px,
                    qty_shares: qty,
                    time_in_force: BinaryTimeInForce::FillOrKill {
                        min_fill: Some(Quantity(0)),
                    },
                })
            }
        }
    }
}

/// Informed trader - occasionally sweeps liquidity
struct InformedTraderBot {
    rng: SmallRng,
}

impl InformedTraderBot {
    fn new() -> Self {
        Self {
            rng: SmallRng::from_rng(&mut rand::rng()),
        }
    }

    fn maybe_sweep(&mut self, snapshot: &MarketSnapshot) -> Option<BotOrder> {
        if !snapshot.state.is_matchable() {
            return None;
        }

        // Only trade 10% of the time
        if !self.rng.random_bool(0.1) {
            return None;
        }

        match snapshot.market_model() {
            betex::types::MarketModel::ExchangeOdds => {
                let runners = [HOME_RUNNER, DRAW_RUNNER, AWAY_RUNNER];
                let runner_id = runners[self.rng.random_range(0..3)];
                let runner_snap = snapshot
                    .runners()
                    .iter()
                    .find(|r| r.runner_id.0 as u64 == runner_id)?;

                // Sweep one side
                let (side, best_price) = if self.rng.random_bool(0.5) {
                    (Side::Back, runner_snap.best_back()?)
                } else {
                    (Side::Lay, runner_snap.best_lay()?)
                };

                // Use aggressive price to ensure fill - cross the spread
                // For back: use best_back price directly (it's already the best available)
                // For lay: use best_lay price directly
                let odds = best_price.price;

                Some(BotOrder::Odds {
                    runner_id,
                    side,
                    odds,
                    stake: Money(self.rng.random_range(100..500)),
                    time_in_force: TimeInForce::FillOrKill {
                        min_fill: Some(Quantity(0)),
                    },
                })
            }
            betex::types::MarketModel::BinaryYes { .. } => {
                let bin = snapshot.binary()?;
                let best_bid = bin.bids.first()?;
                let best_ask = bin.asks.first()?;

                let (side, px) = if self.rng.random_bool(0.5) {
                    (BinarySide::Buy, best_ask.price_ticks)
                } else {
                    (BinarySide::Sell, best_bid.price_ticks)
                };
                let qty = self.rng.random_range(50..=200) as u64;

                Some(BotOrder::Binary {
                    side,
                    price_ticks: px,
                    qty_shares: qty,
                    time_in_force: BinaryTimeInForce::FillOrKill {
                        min_fill: Some(Quantity(0)),
                    },
                })
            }
        }
    }
}

/// Run the market maker bot loop
async fn run_market_maker(
    ctx: Arc<BotContext>,
    cmd_tx: mpsc::Sender<ClientMsg>,
    mut snapshot_rx: watch::Receiver<UiState>,
    market_id: MarketId,
    tick_every: Duration,
) {
    let mut bot = MarketMakerBot::new();
    let mut interval = tokio::time::interval(tick_every);

    loop {
        interval.tick().await;

        if ctx.is_paused() {
            continue;
        }

        let ui = snapshot_rx.borrow_and_update().clone();
        if !matches!(&ui.conn, ConnState::Connected) {
            continue;
        }
        let Some(snap_state) = ui.snapshot else {
            continue;
        };
        if snap_state.snapshot.market_id != market_id {
            continue;
        }

        for order in bot.generate_quotes(&snap_state.snapshot) {
            let correlation_id = ctx.next_correlation_id();
            let msg = match order {
                BotOrder::Odds {
                    runner_id,
                    side,
                    odds,
                    stake,
                    time_in_force,
                } => ClientMsg::PlaceOrder {
                    correlation_id: Some(CorrelationId(correlation_id.to_string())),
                    order_id: OrderId(correlation_id),
                    market_id,
                    runner_id: RunnerId(runner_id as u32),
                    account_id: AccountId::from(ctx.account_id),
                    side,
                    odds,
                    stake: stake.0,
                    persistence: None,
                    time_in_force: Some(time_in_force),
                },
                BotOrder::Binary {
                    side,
                    price_ticks,
                    qty_shares,
                    time_in_force,
                } => ClientMsg::PlaceBinaryOrder {
                    correlation_id: Some(CorrelationId(correlation_id.to_string())),
                    order_id: OrderId(correlation_id),
                    market_id,
                    account_id: AccountId::from(ctx.account_id),
                    side,
                    price_ticks,
                    qty_shares,
                    time_in_force: Some(time_in_force),
                },
            };
            match cmd_tx.try_send(msg) {
                Ok(()) => ctx.record_order(),
                Err(mpsc::error::TrySendError::Full(_)) => {
                    // Channel full - skip this order to avoid backpressure
                    continue;
                }
                Err(mpsc::error::TrySendError::Closed(_)) => return,
            }
        }
    }
}

/// Run the noise trader bot loop
async fn run_noise_trader(
    ctx: Arc<BotContext>,
    cmd_tx: mpsc::Sender<ClientMsg>,
    mut snapshot_rx: watch::Receiver<UiState>,
    market_id: MarketId,
    tick_every: Duration,
) {
    let mut bot = NoiseTraderBot::new();
    let mut interval = tokio::time::interval(tick_every);

    loop {
        interval.tick().await;

        if ctx.is_paused() {
            continue;
        }

        let ui = snapshot_rx.borrow_and_update().clone();
        if !matches!(&ui.conn, ConnState::Connected) {
            continue;
        }
        let Some(snap_state) = ui.snapshot else {
            continue;
        };
        if snap_state.snapshot.market_id != market_id {
            continue;
        }

        if let Some(order) = bot.generate_order(&snap_state.snapshot) {
            let correlation_id = ctx.next_correlation_id();
            let msg = match order {
                BotOrder::Odds {
                    runner_id,
                    side,
                    odds,
                    stake,
                    time_in_force,
                } => ClientMsg::PlaceOrder {
                    correlation_id: Some(CorrelationId(correlation_id.to_string())),
                    order_id: OrderId(correlation_id),
                    market_id,
                    runner_id: RunnerId(runner_id as u32),
                    account_id: AccountId::from(ctx.account_id),
                    side,
                    odds,
                    stake: stake.0,
                    persistence: None,
                    time_in_force: Some(time_in_force),
                },
                BotOrder::Binary {
                    side,
                    price_ticks,
                    qty_shares,
                    time_in_force,
                } => ClientMsg::PlaceBinaryOrder {
                    correlation_id: Some(CorrelationId(correlation_id.to_string())),
                    order_id: OrderId(correlation_id),
                    market_id,
                    account_id: AccountId::from(ctx.account_id),
                    side,
                    price_ticks,
                    qty_shares,
                    time_in_force: Some(time_in_force),
                },
            };
            match cmd_tx.try_send(msg) {
                Ok(()) => ctx.record_order(),
                Err(mpsc::error::TrySendError::Full(_)) => {
                    // Channel full - skip this order to avoid backpressure
                }
                Err(mpsc::error::TrySendError::Closed(_)) => return,
            }
        }
    }
}

/// Run the informed trader bot loop
async fn run_informed_trader(
    ctx: Arc<BotContext>,
    cmd_tx: mpsc::Sender<ClientMsg>,
    mut snapshot_rx: watch::Receiver<UiState>,
    market_id: MarketId,
    tick_every: Duration,
) {
    let mut bot = InformedTraderBot::new();
    let mut interval = tokio::time::interval(tick_every);

    loop {
        interval.tick().await;

        if ctx.is_paused() {
            continue;
        }

        let ui = snapshot_rx.borrow_and_update().clone();
        if !matches!(&ui.conn, ConnState::Connected) {
            continue;
        }
        let Some(snap_state) = ui.snapshot else {
            continue;
        };
        if snap_state.snapshot.market_id != market_id {
            continue;
        }

        if let Some(order) = bot.maybe_sweep(&snap_state.snapshot) {
            let correlation_id = ctx.next_correlation_id();
            let msg = match order {
                BotOrder::Odds {
                    runner_id,
                    side,
                    odds,
                    stake,
                    time_in_force,
                } => ClientMsg::PlaceOrder {
                    correlation_id: Some(CorrelationId(correlation_id.to_string())),
                    order_id: OrderId(correlation_id),
                    market_id,
                    runner_id: RunnerId(runner_id as u32),
                    account_id: AccountId::from(ctx.account_id),
                    side,
                    odds,
                    stake: stake.0,
                    persistence: None,
                    time_in_force: Some(time_in_force),
                },
                BotOrder::Binary {
                    side,
                    price_ticks,
                    qty_shares,
                    time_in_force,
                } => ClientMsg::PlaceBinaryOrder {
                    correlation_id: Some(CorrelationId(correlation_id.to_string())),
                    order_id: OrderId(correlation_id),
                    market_id,
                    account_id: AccountId::from(ctx.account_id),
                    side,
                    price_ticks,
                    qty_shares,
                    time_in_force: Some(time_in_force),
                },
            };
            match cmd_tx.try_send(msg) {
                Ok(()) => ctx.record_order(),
                Err(mpsc::error::TrySendError::Full(_)) => {
                    // Channel full - skip this order to avoid backpressure
                }
                Err(mpsc::error::TrySendError::Closed(_)) => return,
            }
        }
    }
}

async fn send_user_cmd(
    ui_tx: &watch::Sender<UiState>,
    ui_rx: &watch::Receiver<UiState>,
    cmd_tx: &mpsc::Sender<ClientMsg>,
    cmd: ClientMsg,
) {
    if !matches!(&ui_rx.borrow().conn, ConnState::Connected) {
        ui_tx.send_modify(|u| u.set_error("not connected".to_string()));
        return;
    }

    let kind = match &cmd {
        ClientMsg::CreateMarket { .. } => "CreateMarket",
        ClientMsg::RemoveMarket { .. } => "RemoveMarket",
        ClientMsg::ListMarkets => "ListMarkets",
        ClientMsg::Subscribe { .. } => "Subscribe",
        ClientMsg::SetMarketState { .. } => "SetMarketState",
        ClientMsg::SetMarketPhase { .. } => "SetMarketPhase",
        ClientMsg::CloseMarket { .. } => "CloseMarket",
        ClientMsg::BatchCancelOrders { .. } => "BatchCancelOrders",
        ClientMsg::ContinueBatchCancelOrders { .. } => "ContinueBatchCancelOrders",
        ClientMsg::SettleMarket { .. } => "SettleMarket",
        ClientMsg::VoidMarket { .. } => "VoidMarket",
        ClientMsg::HaltMarket { .. } => "HaltMarket",
        ClientMsg::ResumeMarket { .. } => "ResumeMarket",
        ClientMsg::Ping => "Ping",
        ClientMsg::PlaceOrder { .. } => "PlaceOrder",
        ClientMsg::PlaceBinaryOrder { .. } => "PlaceBinaryOrder",
        ClientMsg::CancelOrder { .. } => "CancelOrder",
    };

    match tokio::time::timeout(Duration::from_millis(250), cmd_tx.send(cmd)).await {
        Ok(Ok(())) => ui_tx.send_modify(|u| {
            u.last_ack = Some(format!("sent: {kind}"));
            u.push_log(format!("sent: {kind}"));
        }),
        Ok(Err(_)) => ui_tx.send_modify(|u| u.set_error("command channel closed".to_string())),
        Err(_) => ui_tx.send_modify(|u| u.set_error("command queue busy".to_string())),
    }
}

fn current_market_id(ui: &UiState) -> Option<MarketId> {
    ui.snapshot.as_ref().map(|s| s.snapshot.market_id)
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let cli = parse_cli();
    let url = cli.url.clone();
    let (ui_tx, mut ui_rx) = watch::channel::<UiState>(UiState::default());
    let user_cmd_cap = 1024;
    // Scale bot command channel capacity based on total bot count
    let total_bots = cli.mm_bots + cli.noise_bots + cli.informed_bots;
    let base_cap = if cli.high_throughput { 65_536 } else { 2_048 };
    let bot_cmd_cap = base_cap.max(total_bots * 1024);
    let (user_cmd_tx, user_cmd_rx) = mpsc::channel::<ClientMsg>(user_cmd_cap);
    let (bot_cmd_tx, bot_cmd_rx) = mpsc::channel::<ClientMsg>(bot_cmd_cap);

    let bot_registry: Arc<RwLock<HashMap<MarketId, Vec<BotInstance>>>> =
        Arc::new(RwLock::new(HashMap::new()));
    let bot_ctx_by_account: Arc<RwLock<HashMap<u64, Arc<BotContext>>>> =
        Arc::new(RwLock::new(HashMap::new()));
    let mut bot_counters: HashMap<MarketId, BotCounters> = HashMap::new();

    ui_tx.send_modify(|u| u.url = url.clone());
    if let Some(host_port) = http_host_port_from_ws_url(&url) {
        tokio::spawn(metrics_loop(host_port, ui_tx.clone()));
    } else {
        ui_tx.send_modify(|u| u.set_metrics_error("cannot derive metrics host from --url"));
    }

    tokio::spawn(ws_loop(
        url.clone(),
        ui_tx.clone(),
        bot_ctx_by_account.clone(),
        user_cmd_rx,
        bot_cmd_rx,
    ));

    let (mm_tick, noise_tick, informed_tick) = if cli.high_throughput {
        (
            Duration::from_millis(20),
            Duration::from_millis(5),
            Duration::from_millis(50),
        )
    } else {
        (
            Duration::from_millis(500),
            Duration::from_millis(200),
            Duration::from_millis(1000),
        )
    };

    let mut next_mm_account: u64 = 1000;
    let mut next_noise_account: u64 = 2000;
    let mut next_informed_account: u64 = 3000;

    let mut tui = Tui::new()?;
    let mut last_tick = Instant::now();
    let tick_rate = Duration::from_millis(100);

    loop {
        while event::poll(Duration::from_millis(0))? {
            if let Event::Key(key) = event::read()? {
                let ui_state = ui_rx.borrow().clone();
                let modal_open = ui_state.close_modal.is_some();
                let modal_state = ui_state.modal.clone();
                let current_view = ui_state.view;

                // Handle CreateMarket modal input first
                if let ModalState::CreateMarket(ref modal) = modal_state {
                    match key.code {
                        KeyCode::Esc => {
                            ui_tx.send_modify(|u| u.modal = ModalState::None);
                            continue;
                        }
                        KeyCode::Tab => {
                            ui_tx.send_modify(|u| {
                                if let ModalState::CreateMarket(ref mut m) = u.modal {
                                    m.focused_field = match m.focused_field {
                                        CreateMarketField::Name => CreateMarketField::Preset,
                                        CreateMarketField::Preset => CreateMarketField::Name,
                                    };
                                }
                            });
                            continue;
                        }
                        KeyCode::Up => {
                            ui_tx.send_modify(|u| {
                                if let ModalState::CreateMarket(ref mut m) = u.modal {
                                    if matches!(m.focused_field, CreateMarketField::Preset) {
                                        m.selected_preset = m.selected_preset.saturating_sub(1);
                                    }
                                }
                            });
                            continue;
                        }
                        KeyCode::Down => {
                            ui_tx.send_modify(|u| {
                                if let ModalState::CreateMarket(ref mut m) = u.modal {
                                    if matches!(m.focused_field, CreateMarketField::Preset)
                                        && m.selected_preset + 1 < m.preset_options.len()
                                    {
                                        m.selected_preset += 1;
                                    }
                                }
                            });
                            continue;
                        }
                        KeyCode::Char(c) => {
                            ui_tx.send_modify(|u| {
                                if let ModalState::CreateMarket(ref mut m) = u.modal {
                                    if matches!(m.focused_field, CreateMarketField::Name)
                                        && m.name_input.len() < 32
                                    {
                                        m.name_input.push(c);
                                    }
                                }
                            });
                            continue;
                        }
                        KeyCode::Backspace => {
                            ui_tx.send_modify(|u| {
                                if let ModalState::CreateMarket(ref mut m) = u.modal {
                                    if matches!(m.focused_field, CreateMarketField::Name) {
                                        m.name_input.pop();
                                    }
                                }
                            });
                            continue;
                        }
                        KeyCode::Enter => {
                            // Create market
                            let preset = modal.preset_options[modal.selected_preset];
                            let name = if modal.name_input.is_empty() {
                                preset.to_string()
                            } else {
                                modal.name_input.clone()
                            };
                            let (market_model, runner_ids, runner_labels) = match preset {
                                MarketPreset::Tennis2Runner => (
                                    betex::types::MarketModel::ExchangeOdds,
                                    vec![RunnerId(1), RunnerId(2)],
                                    vec!["Player 1".to_string(), "Player 2".to_string()],
                                ),
                                MarketPreset::Soccer3Runner => (
                                    betex::types::MarketModel::ExchangeOdds,
                                    vec![RunnerId(1), RunnerId(2), RunnerId(3)],
                                    vec![
                                        "Home".to_string(),
                                        "Draw".to_string(),
                                        "Away".to_string(),
                                    ],
                                ),
                                MarketPreset::BinaryYesNo => (
                                    betex::types::MarketModel::BinaryYes {
                                        max_price_ticks: 100,
                                    },
                                    vec![RunnerId(1), RunnerId(2)],
                                    vec!["YES".to_string(), "NO".to_string()],
                                ),
                            };
                            let market_kind = MarketKind::InPlayCapable;
                            let market_phase = match market_kind {
                                MarketKind::LiveOnly => MarketPhase::Live,
                                MarketKind::PreEventOnly | MarketKind::InPlayCapable => {
                                    MarketPhase::Pre
                                }
                            };
                            ui_tx.send_modify(|u| {
                                u.push_log(format!(
                                    "create market submit: name=\"{}\" model={:?} kind={market_kind:?} phase={market_phase:?} runners={}",
                                    name,
                                    market_model,
                                    runner_ids.len()
                                ));
                            });
                            ui_tx.send_modify(|u| u.modal = ModalState::None);
                            send_user_cmd(
                                &ui_tx,
                                &ui_rx,
                                &user_cmd_tx,
                                ClientMsg::CreateMarket {
                                    name,
                                    market_model,
                                    market_kind,
                                    market_phase,
                                    runner_ids,
                                    runner_labels,
                                },
                            )
                            .await;
                            continue;
                        }
                        _ => continue,
                    }
                }

                // Handle ConfirmDelete modal
                if let ModalState::ConfirmDelete {
                    market_id,
                    name: _,
                    selected_yes,
                } = modal_state
                {
                    match key.code {
                        KeyCode::Esc => {
                            ui_tx.send_modify(|u| u.modal = ModalState::None);
                            continue;
                        }
                        KeyCode::Left | KeyCode::Right | KeyCode::Tab => {
                            ui_tx.send_modify(|u| {
                                if let ModalState::ConfirmDelete {
                                    selected_yes: ref mut sel,
                                    ..
                                } = u.modal
                                {
                                    *sel = !*sel;
                                }
                            });
                            continue;
                        }
                        KeyCode::Enter => {
                            ui_tx.send_modify(|u| u.modal = ModalState::None);
                            if selected_yes {
                                send_user_cmd(
                                    &ui_tx,
                                    &ui_rx,
                                    &user_cmd_tx,
                                    ClientMsg::RemoveMarket { market_id },
                                )
                                .await;
                            }
                            continue;
                        }
                        KeyCode::Char('y') | KeyCode::Char('Y') => {
                            ui_tx.send_modify(|u| u.modal = ModalState::None);
                            send_user_cmd(
                                &ui_tx,
                                &ui_rx,
                                &user_cmd_tx,
                                ClientMsg::RemoveMarket { market_id },
                            )
                            .await;
                            continue;
                        }
                        KeyCode::Char('n') | KeyCode::Char('N') => {
                            ui_tx.send_modify(|u| u.modal = ModalState::None);
                            continue;
                        }
                        _ => continue,
                    }
                }

                // Handle ControlPlane view keys
                if matches!(current_view, UiView::ControlPlane) && !modal_open {
                    match key.code {
                        KeyCode::Char('n') => {
                            // Open create market modal
                            ui_tx.send_modify(|u| {
                                u.modal = ModalState::CreateMarket(CreateMarketModal {
                                    name_input: String::new(),
                                    preset_options: MarketPreset::iter().collect(),
                                    selected_preset: 0,
                                    focused_field: CreateMarketField::Name,
                                });
                            });
                            continue;
                        }
                        KeyCode::Up => {
                            ui_tx.send_modify(|u| {
                                u.selected_market_index = u.selected_market_index.saturating_sub(1);
                            });
                            continue;
                        }
                        KeyCode::Down => {
                            ui_tx.send_modify(|u| {
                                if !u.markets.is_empty()
                                    && u.selected_market_index + 1 < u.markets.len()
                                {
                                    u.selected_market_index += 1;
                                }
                            });
                            continue;
                        }
                        KeyCode::Enter => {
                            // Select market and switch to Market view
                            let markets = ui_state.markets.clone();
                            let idx = ui_state.selected_market_index;
                            if let Some(market) = markets.get(idx) {
                                let market_id = market.market_id;
                                ui_tx.send_modify(|u| {
                                    u.selected_market_id = Some(market_id);
                                    u.view = UiView::Market;
                                });
                                // Subscribe to this market
                                send_user_cmd(
                                    &ui_tx,
                                    &ui_rx,
                                    &user_cmd_tx,
                                    ClientMsg::Subscribe { market_id },
                                )
                                .await;
                            }
                            continue;
                        }
                        KeyCode::Char('d') => {
                            // Delete terminal market
                            let markets = ui_state.markets.clone();
                            let idx = ui_state.selected_market_index;
                            if let Some(market) = markets.get(idx) {
                                if market.status.is_terminal() {
                                    ui_tx.send_modify(|u| {
                                        u.modal = ModalState::ConfirmDelete {
                                            market_id: market.market_id,
                                            name: market.name.clone(),
                                            selected_yes: false,
                                        };
                                    });
                                } else {
                                    ui_tx.send_modify(|u| {
                                        u.set_error(
                                            "Can only delete terminal (Settled/Voided) markets",
                                        );
                                    });
                                }
                            }
                            continue;
                        }
                        KeyCode::Char('m') => {
                            ui_tx.send_modify(|u| u.view = UiView::Metrics);
                            continue;
                        }
                        KeyCode::Char('l') | KeyCode::Char('L') => {
                            ui_tx.send_modify(|u| u.view = UiView::Logs);
                            continue;
                        }
                        _ => {}
                    }
                }

                if matches!(current_view, UiView::Bots) && !modal_open {
                    match key.code {
                        KeyCode::Up => {
                            if let Some(market_id) = active_market_id(&ui_state) {
                                ui_tx.send_modify(|u| {
                                    if let Some(state) = u.bot_markets.get_mut(&market_id) {
                                        state.selected_index =
                                            state.selected_index.saturating_sub(1);
                                    }
                                });
                            }
                            continue;
                        }
                        KeyCode::Down => {
                            if let Some(market_id) = active_market_id(&ui_state) {
                                ui_tx.send_modify(|u| {
                                    if let Some(state) = u.bot_markets.get_mut(&market_id) {
                                        if state.selected_index + 1 < state.bots.len() {
                                            state.selected_index += 1;
                                        }
                                    }
                                });
                            }
                            continue;
                        }
                        KeyCode::Enter => {
                            let Some(market_id) = active_market_id(&ui_state) else {
                                ui_tx.send_modify(|u| {
                                    u.set_error("select a market before toggling bots")
                                });
                                continue;
                            };
                            let bots = bot_registry.read().unwrap();
                            let Some(instances) = bots.get(&market_id) else {
                                ui_tx.send_modify(|u| u.set_error("no bots to toggle"));
                                continue;
                            };
                            let selected = ui_state
                                .bot_markets
                                .get(&market_id)
                                .map(|s| s.selected_index)
                                .unwrap_or(0);
                            if let Some(instance) = instances.get(selected) {
                                let new_paused = !instance.ctx.is_paused();
                                instance.ctx.set_paused(new_paused);
                                ui_tx.send_modify(|u| {
                                    if let Some(state) = u.bot_markets.get_mut(&market_id) {
                                        if let Some(entry) = state.bots.get_mut(selected) {
                                            entry.paused = new_paused;
                                        }
                                    }
                                });
                            } else {
                                ui_tx.send_modify(|u| u.set_error("selected bot not found"));
                            }
                            continue;
                        }
                        KeyCode::Char('s') => {
                            let Some(market_id) = active_market_id(&ui_state) else {
                                ui_tx.send_modify(|u| {
                                    u.set_error("select a market before toggling bots")
                                });
                                continue;
                            };
                            let bots = bot_registry.read().unwrap();
                            let Some(instances) = bots.get(&market_id) else {
                                ui_tx.send_modify(|u| u.set_error("no bots to toggle"));
                                continue;
                            };
                            let all_running = instances.iter().all(|b| !b.ctx.is_paused());
                            let new_paused = all_running;
                            for instance in instances {
                                instance.ctx.set_paused(new_paused);
                            }
                            ui_tx.send_modify(|u| {
                                if let Some(state) = u.bot_markets.get_mut(&market_id) {
                                    for entry in &mut state.bots {
                                        entry.paused = new_paused;
                                    }
                                }
                            });
                            continue;
                        }
                        KeyCode::Char('m') | KeyCode::Char('n') | KeyCode::Char('i') => {
                            let Some(market_id) = active_market_id(&ui_state) else {
                                ui_tx.send_modify(|u| {
                                    u.set_error("select a market before adding bots")
                                });
                                continue;
                            };
                            let counters = bot_counters.entry(market_id).or_default();
                            let (kind, name, account_id, tick_every) = match key.code {
                                KeyCode::Char('m') => {
                                    counters.mm += 1;
                                    let name = format!("MM{}@{}", counters.mm, market_id.0);
                                    let account_id = next_mm_account;
                                    next_mm_account += 1;
                                    (BotKind::MarketMaker, name, account_id, mm_tick)
                                }
                                KeyCode::Char('n') => {
                                    counters.noise += 1;
                                    let name = format!("Noise{}@{}", counters.noise, market_id.0);
                                    let account_id = next_noise_account;
                                    next_noise_account += 1;
                                    (BotKind::Noise, name, account_id, noise_tick)
                                }
                                KeyCode::Char('i') => {
                                    counters.informed += 1;
                                    let name = format!("Inf{}@{}", counters.informed, market_id.0);
                                    let account_id = next_informed_account;
                                    next_informed_account += 1;
                                    (BotKind::Informed, name, account_id, informed_tick)
                                }
                                _ => continue,
                            };

                            let ctx = Arc::new(BotContext::new(&name, account_id));
                            ctx.set_paused(true);
                            bot_ctx_by_account
                                .write()
                                .unwrap()
                                .insert(account_id, ctx.clone());
                            bot_registry
                                .write()
                                .unwrap()
                                .entry(market_id)
                                .or_default()
                                .push(BotInstance { ctx: ctx.clone() });

                            ui_tx.send_modify(|u| {
                                let state = u.bot_markets.entry(market_id).or_default();
                                state.bots.push(ctx.status());
                                state.selected_index = state.bots.len().saturating_sub(1);
                                u.last_ack = Some(format!("Added bot: {name}"));
                            });

                            let cmd_tx = bot_cmd_tx.clone();
                            let ui_rx = ui_rx.clone();
                            match kind {
                                BotKind::MarketMaker => {
                                    tokio::spawn(run_market_maker(
                                        ctx, cmd_tx, ui_rx, market_id, tick_every,
                                    ));
                                }
                                BotKind::Noise => {
                                    tokio::spawn(run_noise_trader(
                                        ctx, cmd_tx, ui_rx, market_id, tick_every,
                                    ));
                                }
                                BotKind::Informed => {
                                    tokio::spawn(run_informed_trader(
                                        ctx, cmd_tx, ui_rx, market_id, tick_every,
                                    ));
                                }
                            }
                            continue;
                        }
                        _ => {}
                    }
                }

                match key.code {
                    KeyCode::Esc => {
                        if modal_open {
                            ui_tx.send_modify(|u| u.close_modal = None);
                        } else if let Some(target) = esc_target(current_view) {
                            ui_tx.send_modify(|u| u.view = target);
                        }
                    }
                    KeyCode::Char('q') => {
                        if modal_open {
                            ui_tx.send_modify(|u| u.close_modal = None);
                        } else if matches!(current_view, UiView::ControlPlane) {
                            return Ok(());
                        }
                    }
                    KeyCode::Char('b') => {
                        if modal_open || !matches!(current_view, UiView::Market) {
                            continue;
                        }
                        let Some(market_id) = active_market_id(&ui_state) else {
                            ui_tx.send_modify(|u| {
                                u.set_error("select a market before viewing bots")
                            });
                            continue;
                        };
                        ui_tx.send_modify(|u| {
                            u.selected_market_id = Some(market_id);
                            u.view = UiView::Bots;
                        });
                    }
                    KeyCode::Char('f') => {
                        if modal_open {
                            continue;
                        }
                        let model = ui_state
                            .snapshot
                            .as_ref()
                            .map(|s| s.snapshot.market_model());
                        if model != Some(betex::types::MarketModel::ExchangeOdds) {
                            ui_tx.send_modify(|u| {
                                u.betfair_mode = false;
                                u.set_error("Betfair ladder only supports ODDS markets");
                            });
                            continue;
                        }

                        ui_tx.send_modify(|u| {
                            u.betfair_mode = !u.betfair_mode;
                            if u.betfair_mode {
                                u.betfair_sel_runner = 0;
                                u.betfair_sel_side_lay = false;
                                u.betfair_sel_col = 0;
                            }
                        });
                    }
                    KeyCode::Char('o') => {
                        let Some(market_id) = current_market_id(&ui_rx.borrow()) else {
                            ui_tx.send_modify(|u| u.set_error("no snapshot (market_id unknown)"));
                            continue;
                        };
                        send_user_cmd(
                            &ui_tx,
                            &ui_rx,
                            &user_cmd_tx,
                            ClientMsg::SetMarketState {
                                market_id: Some(market_id),
                                state: MarketState::Open,
                            },
                        )
                        .await;
                    }
                    KeyCode::Char('u') => {
                        let Some(market_id) = current_market_id(&ui_rx.borrow()) else {
                            ui_tx.send_modify(|u| u.set_error("no snapshot (market_id unknown)"));
                            continue;
                        };
                        send_user_cmd(
                            &ui_tx,
                            &ui_rx,
                            &user_cmd_tx,
                            ClientMsg::SetMarketState {
                                market_id: Some(market_id),
                                state: MarketState::Suspended,
                            },
                        )
                        .await;
                    }
                    KeyCode::Char('g') => {
                        let Some(market_id) = current_market_id(&ui_rx.borrow()) else {
                            ui_tx.send_modify(|u| u.set_error("no snapshot (market_id unknown)"));
                            continue;
                        };
                        send_user_cmd(
                            &ui_tx,
                            &ui_rx,
                            &user_cmd_tx,
                            ClientMsg::SetMarketPhase {
                                market_id: Some(market_id),
                                phase: MarketPhase::Live,
                            },
                        )
                        .await;
                    }
                    KeyCode::Char('c') => {
                        if modal_open {
                            continue;
                        }
                        let ui = ui_rx.borrow().clone();
                        if let Some(modal) = open_close_modal(&ui) {
                            ui_tx.send_modify(|u| {
                                u.close_modal = Some(modal);
                            });
                        } else {
                            ui_tx.send_modify(|u| u.set_error("no snapshot to build close modal"));
                        }
                    }
                    KeyCode::Char('C') => {
                        // Close-only: close market immediately.
                        let Some(market_id) = current_market_id(&ui_rx.borrow()) else {
                            ui_tx.send_modify(|u| u.set_error("no snapshot (market_id unknown)"));
                            continue;
                        };
                        send_user_cmd(
                            &ui_tx,
                            &ui_rx,
                            &user_cmd_tx,
                            ClientMsg::CloseMarket {
                                market_id: Some(market_id),
                                batch_max_events: 2000,
                            },
                        )
                        .await;
                    }
                    KeyCode::Char('h') => {
                        let Some(market_id) = current_market_id(&ui_rx.borrow()) else {
                            ui_tx.send_modify(|u| u.set_error("no snapshot (market_id unknown)"));
                            continue;
                        };
                        send_user_cmd(
                            &ui_tx,
                            &ui_rx,
                            &user_cmd_tx,
                            ClientMsg::HaltMarket {
                                market_id: Some(market_id),
                                reason: 1,
                            },
                        )
                        .await;
                    }
                    KeyCode::Char('r') => {
                        let Some(market_id) = current_market_id(&ui_rx.borrow()) else {
                            ui_tx.send_modify(|u| u.set_error("no snapshot (market_id unknown)"));
                            continue;
                        };
                        send_user_cmd(
                            &ui_tx,
                            &ui_rx,
                            &user_cmd_tx,
                            ClientMsg::ResumeMarket {
                                market_id: Some(market_id),
                            },
                        )
                        .await;
                    }
                    KeyCode::Char('p') => {
                        send_user_cmd(&ui_tx, &ui_rx, &user_cmd_tx, ClientMsg::Ping).await;
                    }
                    KeyCode::Char('s') => {
                        let ui = ui_rx.borrow().clone();
                        if let Some(snap) = ui.snapshot.as_ref() {
                            let market_id = snap.snapshot.market_id;
                            let desired = if snap.snapshot.state.is_matchable() {
                                MarketState::Suspended
                            } else {
                                MarketState::Open
                            };
                            send_user_cmd(
                                &ui_tx,
                                &ui_rx,
                                &user_cmd_tx,
                                ClientMsg::SetMarketState {
                                    market_id: Some(market_id),
                                    state: desired,
                                },
                            )
                            .await;
                        } else {
                            send_user_cmd(&ui_tx, &ui_rx, &user_cmd_tx, ClientMsg::Ping).await;
                        }
                    }
                    KeyCode::Up => {
                        if modal_open {
                            ui_tx.send_modify(|u| {
                                if let Some(m) = u.close_modal.as_mut() {
                                    m.selected = m.selected.saturating_sub(1);
                                }
                            });
                            continue;
                        }
                        ui_tx.send_modify(|u| {
                            if !u.betfair_mode || u.view != UiView::Market {
                                return;
                            }
                            let runners = u
                                .snapshot
                                .as_ref()
                                .map(|s| s.snapshot.runner_count())
                                .unwrap_or(0);
                            if runners == 0 {
                                return;
                            }
                            u.betfair_sel_runner = u.betfair_sel_runner.saturating_sub(1);
                        });
                    }
                    KeyCode::Down => {
                        if modal_open {
                            ui_tx.send_modify(|u| {
                                if let Some(m) = u.close_modal.as_mut() {
                                    if m.selected + 1 < m.choices.len() {
                                        m.selected += 1;
                                    }
                                }
                            });
                            continue;
                        }
                        ui_tx.send_modify(|u| {
                            if !u.betfair_mode || u.view != UiView::Market {
                                return;
                            }
                            let runners = u
                                .snapshot
                                .as_ref()
                                .map(|s| s.snapshot.runner_count())
                                .unwrap_or(0);
                            if runners == 0 {
                                return;
                            }
                            u.betfair_sel_runner = (u.betfair_sel_runner + 1).min(runners - 1);
                        });
                    }
                    KeyCode::Left => {
                        if modal_open {
                            continue;
                        }
                        ui_tx.send_modify(|u| {
                            if !u.betfair_mode || u.view != UiView::Market {
                                return;
                            }
                            let idx = if u.betfair_sel_side_lay {
                                3 + u.betfair_sel_col.min(2)
                            } else {
                                u.betfair_sel_col.min(2)
                            };
                            let next = (idx + 6 - 1) % 6;
                            u.betfair_sel_side_lay = next >= 3;
                            u.betfair_sel_col = next % 3;
                        });
                    }
                    KeyCode::Right => {
                        if modal_open {
                            continue;
                        }
                        ui_tx.send_modify(|u| {
                            if !u.betfair_mode || u.view != UiView::Market {
                                return;
                            }
                            let idx = if u.betfair_sel_side_lay {
                                3 + u.betfair_sel_col.min(2)
                            } else {
                                u.betfair_sel_col.min(2)
                            };
                            let next = (idx + 1) % 6;
                            u.betfair_sel_side_lay = next >= 3;
                            u.betfair_sel_col = next % 3;
                        });
                    }
                    KeyCode::Enter => {
                        if !modal_open {
                            continue;
                        }

                        let (market_id, choice) = {
                            let ui = ui_rx.borrow().clone();
                            let market_id = ui.snapshot.as_ref().map(|s| s.snapshot.market_id);
                            (
                                market_id,
                                ui.close_modal
                                    .as_ref()
                                    .and_then(|m| m.choices.get(m.selected).cloned()),
                            )
                        };

                        ui_tx.send_modify(|u| u.close_modal = None);

                        let Some(choice) = choice else {
                            continue;
                        };
                        let Some(market_id) = market_id else {
                            ui_tx.send_modify(|u| u.set_error("no snapshot (market_id unknown)"));
                            continue;
                        };

                        // Run close+settle in the background so the UI stays responsive.
                        let ui_tx2 = ui_tx.clone();
                        let mut ui_rx2 = ui_rx.clone();
                        let user_cmd_tx2 = user_cmd_tx.clone();
                        tokio::spawn(async move {
                            send_user_cmd(
                                &ui_tx2,
                                &ui_rx2,
                                &user_cmd_tx2,
                                ClientMsg::CloseMarket {
                                    market_id: Some(market_id),
                                    batch_max_events: 2000,
                                },
                            )
                            .await;

                            // Wait for close process to complete (terminal close state observed).
                            let deadline = Instant::now() + Duration::from_secs(120);
                            loop {
                                let state =
                                    ui_rx2.borrow().snapshot.as_ref().map(|s| s.snapshot.state);
                                if matches!(
                                    state,
                                    Some(
                                        BookMarketState::Closed
                                            | BookMarketState::Voided
                                            | BookMarketState::Settled
                                    )
                                ) {
                                    break;
                                }
                                if Instant::now() >= deadline {
                                    ui_tx2.send_modify(|u| {
                                        u.set_error("close did not complete before timeout")
                                    });
                                    return;
                                }
                                if ui_rx2.changed().await.is_err() {
                                    return;
                                }
                                let _ = ui_rx2.borrow_and_update();
                            }

                            // If we're already terminal, don't try to settle/void again.
                            let state = ui_rx2.borrow().snapshot.as_ref().map(|s| s.snapshot.state);
                            if matches!(
                                state,
                                Some(BookMarketState::Voided | BookMarketState::Settled)
                            ) {
                                return;
                            }

                            match choice {
                                CloseChoice::Settle { runner_id, .. } => {
                                    send_user_cmd(
                                        &ui_tx2,
                                        &ui_rx2,
                                        &user_cmd_tx2,
                                        ClientMsg::SettleMarket {
                                            market_id: Some(market_id),
                                            winner_runner_id: RunnerId(runner_id as u32),
                                        },
                                    )
                                    .await;
                                }
                                CloseChoice::Void { .. } => {
                                    send_user_cmd(
                                        &ui_tx2,
                                        &ui_rx2,
                                        &user_cmd_tx2,
                                        ClientMsg::VoidMarket {
                                            market_id: Some(market_id),
                                            reason: None,
                                        },
                                    )
                                    .await;
                                }
                            }
                        });
                    }
                    _ => {}
                }
            }
        }

        // Update bot stats periodically
        if last_tick.elapsed() >= tick_rate {
            let registry = bot_registry.read().unwrap();
            ui_tx.send_modify(|u| {
                for (market_id, bots) in registry.iter() {
                    let state = u.bot_markets.entry(*market_id).or_default();
                    state.bots = bots.iter().map(|b| b.entry()).collect();
                    if state.selected_index >= state.bots.len() {
                        state.selected_index = state.bots.len().saturating_sub(1);
                    }
                }
            });
            tui.draw(&ui_rx.borrow())?;
            last_tick = Instant::now();
        }

        // If we got a snapshot, draw again sooner.
        if ui_rx.has_changed().unwrap_or(false) {
            let _ = ui_rx.borrow_and_update();
            tui.draw(&ui_rx.borrow())?;
        }

        tokio::time::sleep(Duration::from_millis(10)).await;
    }
}
