//! Live bar engine for real-time open deviation bar construction (Issue #91)
//! # FILE-SIZE-OK
//!
//! Multiplexes Binance WebSocket streams across symbols and fans out trades
//! to canonical `OpenDeviationBarProcessor` instances (full 58-column microstructure).
//!
//! Architecture:
//! - One tokio task per symbol (independent reconnection via barter-rs pattern)
//! - One `OpenDeviationBarProcessor` per (symbol, threshold) pair
//! - Completed bars emitted to a bounded channel for Python consumption
//! - Graceful shutdown via `CancellationToken`

use opendeviationbar_core::IntraBarConfig;
use opendeviationbar_core::checkpoint::Checkpoint;
use opendeviationbar_core::interbar::InterBarConfig;
use opendeviationbar_core::processor::{OpenDeviationBarProcessor, ProcessingError};
use opendeviationbar_core::{AggTrade, OpenDeviationBar};
use opendeviationbar_providers::binance::{
    AdaptiveRateLimiter, BinanceWebSocketStream, ReconnectionPolicy,
};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
// Issue #96: Arc<str> for CompletedBar.symbol — cheap clone instead of per-bar String allocation
use tokio::sync::{mpsc, watch};
use tokio::time::Duration;
use tokio_util::sync::CancellationToken;

// Issue #96 Task #9: Ring buffer replaces mpsc channel for memory efficiency
use crate::ring_buffer::ConcurrentRingBuffer;

// Issue #257: Gap detection infrastructure
use crate::gap::{GapEvent, GapFillResult, GapFillSender, TradeIdGapDetector};

/// Microseconds per UTC day — used for midnight boundary detection.
const DAY_US: i64 = 86_400_000_000;

/// A completed bar with metadata identifying its source.
/// Issue #96: `symbol` is `Arc<str>` — created once per symbol task, cheap clone per bar.
#[derive(Debug, Clone)]
pub struct CompletedBar {
    pub symbol: Arc<str>,
    pub threshold_decimal_bps: u32,
    pub bar: OpenDeviationBar,
}

/// Issue #214: A forming (incomplete) bar snapshot for SSE push to consumers.
///
/// Published at 1Hz via `tokio::sync::watch` channels. Each (symbol, threshold)
/// pair has its own watch channel. The daemon thread reads snapshots without
/// blocking the trade-processing hot path.
#[derive(Debug, Clone)]
pub struct FormingBar {
    pub symbol: Arc<str>,
    pub threshold_decimal_bps: u32,
    pub bar: OpenDeviationBar,
    /// Microsecond timestamp of the last trade that updated this forming bar.
    /// Used by the daemon thread to skip broadcasting stale forming bars
    /// (e.g., when markets are closed and no trades arrive).
    pub last_trade_timestamp_us: i64,
}

/// Key type for forming bar watch channels: (symbol, threshold).
type FormingBarKey = (Arc<str>, u32);

/// Map of watch receivers for forming bar snapshots (Issue #214).
/// Extracted from the engine before `start()` (like `take_checkpoint_receiver()`).
pub type FormingBarWatches = HashMap<FormingBarKey, watch::Receiver<Option<FormingBar>>>;

/// Metrics for the live bar engine.
/// Issue #96 Task #6: Added backpressure metrics for monitoring queue behavior
/// Issue #96 Task #12: Expose ring buffer metrics (dropped bars, queue depth)
#[derive(Debug)]
pub struct LiveEngineMetrics {
    pub trades_received: AtomicU64,
    pub bars_emitted: AtomicU64,
    pub reconnections: AtomicU64,
    pub backpressure_events: AtomicU64, // Times ring buffer was full and bar dropped
    pub dropped_bars: AtomicU64,        // Total bars dropped due to full ring buffer
    pub max_queue_depth: AtomicU64,     // Maximum observed queue depth
    pub total_block_time_ms: AtomicU64, // Accumulated time producer waited for queue space
    pub gap_fills: AtomicU64,           // Number of reconnection gap fills performed
    pub gap_trades_recovered: AtomicU64, // Total trades recovered via REST gap fill
}

impl Default for LiveEngineMetrics {
    fn default() -> Self {
        Self {
            trades_received: AtomicU64::new(0),
            bars_emitted: AtomicU64::new(0),
            reconnections: AtomicU64::new(0),
            backpressure_events: AtomicU64::new(0),
            dropped_bars: AtomicU64::new(0),
            max_queue_depth: AtomicU64::new(0),
            total_block_time_ms: AtomicU64::new(0),
            gap_fills: AtomicU64::new(0),
            gap_trades_recovered: AtomicU64::new(0),
        }
    }
}

/// Configuration for the live bar engine.
#[derive(Debug, Clone)]
pub struct LiveEngineConfig {
    /// Symbols to stream (e.g., ["BTCUSDT", "ETHUSDT"])
    pub symbols: Vec<String>,
    /// Thresholds in decimal basis points (e.g., [250, 500, 750, 1000])
    pub thresholds: Vec<u32>,
    /// Whether to compute inter-bar + intra-bar microstructure features
    pub include_microstructure: bool,
    /// Channel capacity for completed bars (backpressure)
    pub bar_channel_capacity: usize,
    /// Reconnection policy for WebSocket connections
    pub reconnection_policy: ReconnectionPolicy,
    /// Initial checkpoints keyed by (symbol, threshold) for resuming incomplete bars
    pub initial_checkpoints: HashMap<(String, u32), Checkpoint>,
    /// Optional rate limiter for REST API gap-fill pacing (Issue #162).
    /// When set, replaces fixed 100ms sleep with budget-aware acquire().
    pub rate_limiter: Option<Arc<AdaptiveRateLimiter>>,
    /// Issue #128: Per-feature computation toggles
    pub compute_tier2: bool,
    pub compute_tier3: bool,
    pub compute_hurst: Option<bool>,
    pub compute_permutation_entropy: Option<bool>,
}

impl LiveEngineConfig {
    /// Get bar channel capacity from environment or use default (10K)
    /// Issue #96 Task #6: OPENDEVIATIONBAR_MAX_PENDING_BARS env var support
    fn get_bar_channel_capacity() -> usize {
        std::env::var("OPENDEVIATIONBAR_MAX_PENDING_BARS")
            .ok()
            .and_then(|v| v.parse::<usize>().ok())
            .unwrap_or(10_000)
    }

    /// Create config with sensible defaults.
    pub fn new(symbols: Vec<String>, thresholds: Vec<u32>) -> Self {
        Self {
            symbols,
            thresholds,
            include_microstructure: true,
            bar_channel_capacity: Self::get_bar_channel_capacity(),
            reconnection_policy: ReconnectionPolicy::default(),
            initial_checkpoints: HashMap::new(),
            compute_tier2: true,
            compute_tier3: false,
            compute_hurst: None,
            compute_permutation_entropy: None,
            rate_limiter: None,
        }
    }

    /// Set rate limiter for REST API gap-fill pacing (Issue #162).
    pub fn with_rate_limiter(mut self, limiter: Arc<AdaptiveRateLimiter>) -> Self {
        self.rate_limiter = Some(limiter);
        self
    }

    /// Inject a checkpoint for a specific (symbol, threshold) pair.
    /// Must be called before `LiveBarEngine::start()`.
    pub fn with_checkpoint(
        mut self,
        symbol: String,
        threshold: u32,
        checkpoint: Checkpoint,
    ) -> Self {
        self.initial_checkpoints
            .insert((symbol, threshold), checkpoint);
        self
    }

    /// Set bar channel capacity explicitly (overrides env var)
    pub fn with_bar_channel_capacity(mut self, capacity: usize) -> Self {
        self.bar_channel_capacity = capacity;
        self
    }
}

/// Live bar engine: multiplexes WebSocket streams → canonical processors → completed bars.
///
/// Issue #91: Uses `OpenDeviationBarProcessor` (NOT `ExportOpenDeviationBarProcessor`) for full
/// 3-step feature finalization producing all 58 columns.
pub struct LiveBarEngine {
    config: LiveEngineConfig,
    /// Issue #96 Task #9: Ring buffer replaces mpsc for 10-20% memory savings
    bar_buffer: ConcurrentRingBuffer<CompletedBar>,
    shutdown: CancellationToken,
    metrics: Arc<LiveEngineMetrics>,
    started: bool,
    /// Channel for receiving processor checkpoints on shutdown
    checkpoint_rx: Option<mpsc::Receiver<(String, u32, Checkpoint)>>,
    checkpoint_tx: mpsc::Sender<(String, u32, Checkpoint)>,
    /// Issue #214: Watch senders for forming bar snapshots (one per symbol×threshold).
    /// Passed into symbol tasks on start(). Senders are moved, receivers extracted via
    /// take_forming_bar_watches() before start().
    forming_bar_txs: HashMap<FormingBarKey, watch::Sender<Option<FormingBar>>>,
    /// Issue #214: Watch receivers for forming bar snapshots.
    /// Extracted before start() via take_forming_bar_watches().
    forming_bar_watches: Option<FormingBarWatches>,
    /// Issue #257: Channel for emitting gap events to Python consumers.
    /// Created in `new()`, passed to symbol tasks in `start()`.
    gap_event_tx: mpsc::Sender<GapEvent>,
    /// Issue #257: Receiver half, extracted via `take_gap_event_receiver()`.
    gap_event_rx: Option<mpsc::Receiver<GapEvent>>,
    /// Issue #257: Per-symbol gap-fill command senders for on-demand fill_gap().
    /// Cloneable — Python can call fill_gap(&self) without borrow conflicts.
    gap_fill_senders: HashMap<String, GapFillSender>,
    /// Issue #257: Per-symbol gap-fill receivers, passed to symbol_tasks on start().
    gap_fill_receivers:
        Option<HashMap<String, crate::gap::GapFillReceiver>>,
}

impl LiveBarEngine {
    /// Create a new live bar engine.
    ///
    /// Does NOT start streaming — call `start()` after creation.
    pub fn new(config: LiveEngineConfig) -> Self {
        // Issue #96 Task #9: Initialize ring buffer instead of mpsc channel
        let bar_buffer = ConcurrentRingBuffer::new(config.bar_channel_capacity);
        // Channel for extracting checkpoints on shutdown (one per symbol×threshold)
        let max_checkpoints = config.symbols.len() * config.thresholds.len();
        let (checkpoint_tx, checkpoint_rx) = mpsc::channel(max_checkpoints.max(1));

        // Issue #214: Create watch channels for forming bar snapshots.
        // One (tx, rx) pair per (symbol, threshold). Created here so receivers
        // can be extracted before start() (avoiding borrow conflicts with next_bar).
        let mut forming_bar_txs = HashMap::new();
        let mut forming_bar_rxs = HashMap::new();
        for symbol in &config.symbols {
            let sym_arc: Arc<str> = Arc::from(symbol.to_uppercase().as_str());
            for &threshold in &config.thresholds {
                let (tx, rx) = watch::channel(None);
                let key = (sym_arc.clone(), threshold);
                forming_bar_txs.insert(key.clone(), tx);
                forming_bar_rxs.insert(key, rx);
            }
        }

        // Issue #257: Gap event channel (bounded, non-blocking sends from hot path)
        let (gap_event_tx, gap_event_rx) = mpsc::channel(256);

        // Issue #257: Per-symbol gap-fill command channels
        let mut gap_fill_senders = HashMap::new();
        let mut gap_fill_receivers = HashMap::new();
        for symbol in &config.symbols {
            let sym = symbol.to_uppercase();
            let (tx, rx) = crate::gap::gap_fill_channel(16);
            gap_fill_senders.insert(sym.clone(), tx);
            gap_fill_receivers.insert(sym, rx);
        }

        Self {
            config,
            bar_buffer,
            shutdown: CancellationToken::new(),
            metrics: Arc::new(LiveEngineMetrics::default()),
            started: false,
            checkpoint_rx: Some(checkpoint_rx),
            checkpoint_tx,
            forming_bar_txs,
            forming_bar_watches: Some(forming_bar_rxs),
            gap_event_tx,
            gap_event_rx: Some(gap_event_rx),
            gap_fill_senders,
            gap_fill_receivers: Some(gap_fill_receivers),
        }
    }

    /// Inject a checkpoint for a specific (symbol, threshold) pair.
    /// Must be called before `start()`.
    pub fn set_initial_checkpoint(&mut self, symbol: &str, threshold: u32, checkpoint: Checkpoint) {
        self.config
            .initial_checkpoints
            .insert((symbol.to_uppercase(), threshold), checkpoint);
    }

    /// Start all WebSocket connections and processing loops.
    ///
    /// Spawns one tokio task per symbol. Each task:
    /// 1. Connects via `run_with_reconnect()` (independent backoff per symbol)
    /// 2. Fans trades out to N `OpenDeviationBarProcessor` instances (one per threshold)
    /// 3. Sends completed bars to the shared output channel
    pub fn start(&mut self) -> Result<(), ProcessingError> {
        if self.started {
            return Ok(());
        }

        for symbol in &self.config.symbols {
            let symbol = symbol.to_uppercase();
            let thresholds = self.config.thresholds.clone();
            let include_microstructure = self.config.include_microstructure;
            // Issue #128: Build tier-aware InterBarConfig + IntraBarConfig
            let inter_bar_config = InterBarConfig {
                compute_tier2: self.config.compute_tier2,
                compute_tier3: self.config.compute_tier3,
                compute_hurst: self.config.compute_hurst,
                compute_permutation_entropy: self.config.compute_permutation_entropy,
                ..Default::default()
            };
            let intra_hurst = self
                .config
                .compute_hurst
                .unwrap_or(self.config.compute_tier3);
            let intra_pe = self
                .config
                .compute_permutation_entropy
                .unwrap_or(self.config.compute_tier3);
            let intra_bar_config = IntraBarConfig {
                compute_hurst: intra_hurst,
                compute_permutation_entropy: intra_pe,
            };
            // Issue #96 Task #9: Clone ring buffer reference for symbol task
            let bar_buffer = self.bar_buffer.clone_ref();
            let shutdown = self.shutdown.clone();
            let policy = self.config.reconnection_policy.clone();
            let metrics = Arc::clone(&self.metrics);
            let checkpoint_tx = self.checkpoint_tx.clone();

            // Create processors for this symbol (one per threshold)
            let mut processors: Vec<(u32, OpenDeviationBarProcessor)> =
                Vec::with_capacity(thresholds.len());
            for &threshold in &thresholds {
                // Try to restore from checkpoint, fall back to fresh processor
                let key = (symbol.clone(), threshold);
                let p = if let Some(cp) = self.config.initial_checkpoints.remove(&key) {
                    match OpenDeviationBarProcessor::from_checkpoint(cp) {
                        Ok(mut restored) => {
                            // Re-enable microstructure features after checkpoint restore
                            if include_microstructure {
                                restored = restored
                                    .with_inter_bar_config(inter_bar_config.clone())
                                    .with_intra_bar_features()
                                    .with_intra_bar_config(intra_bar_config.clone());
                            }
                            tracing::info!(
                                %symbol, threshold,
                                has_incomplete_bar = restored.get_incomplete_bar().is_some(),
                                "processor restored from checkpoint"
                            );
                            restored
                        }
                        Err(e) => {
                            tracing::warn!(
                                %symbol, threshold, ?e,
                                "checkpoint restore failed, starting fresh"
                            );
                            let mut fresh = OpenDeviationBarProcessor::new(threshold)?;
                            if include_microstructure {
                                fresh = fresh
                                    .with_inter_bar_config(inter_bar_config.clone())
                                    .with_intra_bar_features()
                                    .with_intra_bar_config(intra_bar_config.clone());
                            }
                            fresh
                        }
                    }
                } else {
                    let mut fresh = OpenDeviationBarProcessor::new(threshold)?;
                    if include_microstructure {
                        fresh = fresh
                            .with_inter_bar_config(inter_bar_config.clone())
                            .with_intra_bar_features()
                            .with_intra_bar_config(intra_bar_config.clone());
                    }
                    fresh
                };
                processors.push((threshold, p));
            }

            // Issue #214: Extract forming bar senders for this symbol's thresholds
            let symbol_arc: Arc<str> = Arc::from(symbol.as_str());
            let mut forming_txs: Vec<(u32, watch::Sender<Option<FormingBar>>)> = Vec::new();
            for &threshold in &self.config.thresholds {
                let key = (symbol_arc.clone(), threshold);
                if let Some(tx) = self.forming_bar_txs.remove(&key) {
                    forming_txs.push((threshold, tx));
                }
            }

            // Spawn per-symbol task
            let rate_limiter = self.config.rate_limiter.clone();
            let gap_event_tx = self.gap_event_tx.clone();
            // Issue #257: Extract per-symbol gap-fill receiver for command channel
            let gap_fill_rx = self
                .gap_fill_receivers
                .as_mut()
                .and_then(|m| m.remove(&symbol));
            tokio::spawn(symbol_task(
                symbol,
                processors,
                bar_buffer,
                checkpoint_tx,
                policy,
                shutdown,
                metrics,
                rate_limiter,
                forming_txs,
                gap_event_tx,
                gap_fill_rx,
            ));
        }

        self.started = true;
        tracing::info!(
            symbols = ?self.config.symbols,
            thresholds = ?self.config.thresholds,
            microstructure = self.config.include_microstructure,
            "live bar engine started"
        );
        Ok(())
    }

    /// Receive next completed bar. Returns `None` on timeout or shutdown.
    /// Issue #96 Task #9: Poll ring buffer with timeout, replacing async channel
    /// Memory v2: Exponential backoff 100μs→10ms reduces idle CPU ~100x
    pub async fn next_bar(&mut self, timeout: Duration) -> Option<CompletedBar> {
        let start = tokio::time::Instant::now();
        let mut poll_interval = Duration::from_micros(100);
        let max_interval = Duration::from_millis(10);

        loop {
            // Try to pop from ring buffer (non-blocking)
            if let Some(bar) = self.bar_buffer.pop() {
                return Some(bar);
            }

            // Check timeout
            if start.elapsed() >= timeout {
                return None;
            }

            // Check shutdown
            if self.shutdown.is_cancelled() {
                return None;
            }

            // Sleep with exponential backoff (resets on successful pop above)
            tokio::time::sleep(poll_interval).await;
            poll_interval = (poll_interval * 2).min(max_interval);
        }
    }

    /// Graceful shutdown — cancels all WebSocket tasks.
    pub fn stop(&self) {
        tracing::info!("live bar engine stopping");
        self.shutdown.cancel();
    }

    /// Get engine metrics snapshot.
    pub fn metrics(&self) -> &LiveEngineMetrics {
        &self.metrics
    }

    /// Whether the engine has been started.
    pub fn is_started(&self) -> bool {
        self.started
    }

    /// Collect checkpoints from all processors after shutdown.
    ///
    /// Call after `stop()`. Each symbol task sends its processor checkpoints
    /// through the channel before exiting. Returns a map of `"SYMBOL:THRESHOLD"` → `Checkpoint`.
    pub async fn collect_checkpoints(&mut self, timeout: Duration) -> HashMap<String, Checkpoint> {
        let mut result = HashMap::new();
        let rx = match self.checkpoint_rx.take() {
            Some(rx) => rx,
            None => return result,
        };

        // Drain all available checkpoints within timeout
        let deadline = tokio::time::Instant::now() + timeout;
        let mut rx = rx;
        loop {
            tokio::select! {
                item = rx.recv() => {
                    match item {
                        Some((symbol, threshold, cp)) => {
                            let key = format!("{symbol}:{threshold}");
                            result.insert(key, cp);
                        }
                        None => break, // Channel closed, all senders dropped
                    }
                }
                () = tokio::time::sleep_until(deadline) => {
                    tracing::warn!(
                        collected = result.len(),
                        "checkpoint collection timed out"
                    );
                    break;
                }
            }
        }

        tracing::info!(count = result.len(), "checkpoints collected");
        result
    }

    /// Get the shutdown token (for external coordination).
    pub fn shutdown_token(&self) -> CancellationToken {
        self.shutdown.clone()
    }

    /// Take the checkpoint receiver for external collection (Issue #178).
    ///
    /// Used by OdbEngine to extract the receiver before the engine is moved
    /// into a tokio task. The caller is responsible for draining the receiver
    /// after shutdown.
    pub fn take_checkpoint_receiver(
        &mut self,
    ) -> Option<mpsc::Receiver<(String, u32, Checkpoint)>> {
        self.checkpoint_rx.take()
    }

    /// Take the forming bar watch receivers (Issue #214).
    ///
    /// Must be called BEFORE `start()`. Returns watch receivers keyed by
    /// (symbol, threshold). The caller reads these at 1Hz to get the latest
    /// forming bar snapshot for each pair — zero contention with the trade
    /// processing hot path.
    ///
    /// Follows the same pattern as `take_checkpoint_receiver()` (Issue #178).
    pub fn take_forming_bar_watches(&mut self) -> Option<FormingBarWatches> {
        self.forming_bar_watches.take()
    }

    /// Get a shared reference to the metrics (Issue #178).
    pub fn shared_metrics(&self) -> Arc<LiveEngineMetrics> {
        Arc::clone(&self.metrics)
    }

    /// Take the gap event receiver (Issue #257).
    ///
    /// Returns the receiver half of the gap event channel. Gap events are
    /// emitted by `TradeIdGapDetector` in each symbol task when trade-ID
    /// discontinuities are detected. The caller (StreamManager/Python)
    /// polls this to react to gaps.
    ///
    /// Can only be called once — subsequent calls return None.
    pub fn take_gap_event_receiver(&mut self) -> Option<mpsc::Receiver<GapEvent>> {
        self.gap_event_rx.take()
    }

    /// Get a clone of the per-symbol gap-fill senders (Issue #257).
    ///
    /// Returns a map of symbol → `GapFillSender`. Each sender can be used
    /// with `&self` (no borrow conflict) to submit on-demand gap-fill
    /// commands that are processed inline by the symbol's `tokio::select!` loop.
    pub fn gap_fill_senders(&self) -> HashMap<String, GapFillSender> {
        self.gap_fill_senders.clone()
    }
}

impl LiveEngineMetrics {
    /// Snapshot of current metrics.
    pub fn snapshot(&self) -> LiveEngineMetricsSnapshot {
        LiveEngineMetricsSnapshot {
            trades_received: self.trades_received.load(Ordering::Relaxed),
            bars_emitted: self.bars_emitted.load(Ordering::Relaxed),
            reconnections: self.reconnections.load(Ordering::Relaxed),
            dropped_bars: self.dropped_bars.load(Ordering::Relaxed),
            max_queue_depth: self.max_queue_depth.load(Ordering::Relaxed),
            backpressure_events: self.backpressure_events.load(Ordering::Relaxed),
            gap_fills: self.gap_fills.load(Ordering::Relaxed),
            gap_trades_recovered: self.gap_trades_recovered.load(Ordering::Relaxed),
        }
    }

    /// Get ring buffer queue depth estimate (from snapshot timing).
    /// Note: This is approximate due to concurrent updates.
    pub fn estimate_queue_depth(&self) -> u64 {
        let emitted = self.bars_emitted.load(Ordering::Relaxed);
        let dropped = self.dropped_bars.load(Ordering::Relaxed);
        let received = self.trades_received.load(Ordering::Relaxed);
        // Rough estimate: bars emitted + dropped vs trades received
        // This is not exact but gives a sense of queue pressure
        if emitted + dropped > received {
            0
        } else {
            received - emitted - dropped
        }
    }
}

/// Immutable metrics snapshot for reporting.
#[derive(Debug, Clone)]
pub struct LiveEngineMetricsSnapshot {
    pub trades_received: u64,
    pub bars_emitted: u64,
    pub reconnections: u64,
    pub dropped_bars: u64,
    pub max_queue_depth: u64,
    pub backpressure_events: u64,
    pub gap_fills: u64,
    pub gap_trades_recovered: u64,
}

/// Maximum number of trades to recover via REST gap fill.
/// Beyond this, defer to backfill rectification (WS-2 safety net).
/// 100,000 trades ≈ 1.4 hours of BTCUSDT at ~20 aggTrades/s.
const MAX_GAP_FILL_TRADES: i64 = 100_000;


/// Check if a trade crosses a UTC midnight boundary relative to the processor's last trade,
/// and if so, reset the processor at ouroboros. Returns the orphan bar if one was in progress.
///
/// This prevents cross-midnight bars from being produced. The orphan bar (if any) is emitted
/// as a completed bar with `is_orphan=true`. The trade that triggered the reset is NOT consumed —
/// it will be processed normally after this function returns.
fn maybe_reset_at_midnight(
    processor: &mut OpenDeviationBarProcessor,
    trade_timestamp_us: i64,
    last_day: &mut i64,
) -> Option<OpenDeviationBar> {
    let trade_day = trade_timestamp_us / DAY_US;
    if *last_day >= 0 && trade_day != *last_day {
        *last_day = trade_day;
        // Reset processor — any in-progress bar becomes an orphan
        processor.reset_at_ouroboros()
    } else {
        *last_day = trade_day;
        None
    }
}

/// Puck: inline gap fill on trade-ID discontinuity.
///
/// When a gap is detected between the last processed trade and an incoming
/// WebSocket trade, fetches the missing trades using Binance REST API's
/// parallel `fromId` fetch and processes them through all threshold processors.
/// Sub-3s recovery for typical reconnection gaps.
///
/// Returns the number of trades recovered (0 on error or skip — non-fatal).
#[allow(clippy::too_many_arguments)]
async fn puck_fill(
    symbol: &str,
    last_known_id: i64,
    first_ws_id: i64,
    processors: &mut [(u32, OpenDeviationBarProcessor)],
    bar_buffer: &ConcurrentRingBuffer<CompletedBar>,
    metrics: &LiveEngineMetrics,
    shutdown: &CancellationToken,
    rate_limiter: Option<Arc<AdaptiveRateLimiter>>,
    forming_tx_map: &HashMap<u32, watch::Sender<Option<FormingBar>>>, // Issue #214
) -> u64 {
    let gap_size = first_ws_id - last_known_id - 1;

    if gap_size > MAX_GAP_FILL_TRADES {
        tracing::warn!(
            %symbol, gap_size, MAX_GAP_FILL_TRADES,
            "gap too large for REST fill, deferring to backfill service"
        );
        return 0;
    }

    tracing::info!(
        %symbol, gap_size, last_known_id, first_ws_id,
        "puck: filling gap via parallel REST API"
    );

    // Issue #257: Use parallel fetch for sub-3s gap recovery
    let from_id = last_known_id + 1;
    let trades = match opendeviationbar_providers::binance::parallel_fetch::fetch_aggtrades_parallel(
        symbol,
        from_id,
        first_ws_id,
        rate_limiter,
        None, // Use default concurrency (5)
    )
    .await
    {
        Ok(t) => t,
        Err(e) => {
            tracing::warn!(
                %symbol, ?e,
                "parallel REST gap-fill failed, aborting (backfill service will rectify)"
            );
            return 0;
        }
    };

    // Process fetched trades sequentially through all threshold processors
    // (CRITICAL: processor is order-sensitive, trades must be in agg_trade_id order)
    let mut trades_recovered = 0u64;
    let symbol_arc: Arc<str> = Arc::from(symbol);

    // Track current UTC day per processor for midnight reset
    let mut last_days: Vec<i64> = processors.iter().map(|_| -1i64).collect();

    for trade in &trades {
        // Check shutdown periodically
        if shutdown.is_cancelled() {
            tracing::info!(%symbol, trades_recovered, "gap fill interrupted by shutdown");
            break;
        }

        // Fan out to all threshold processors
        for (idx, (threshold, processor)) in processors.iter_mut().enumerate() {
            // Midnight reset: if trade crosses day boundary, emit orphan and reset
            if let Some(orphan) = maybe_reset_at_midnight(processor, trade.timestamp, &mut last_days[idx]) {
                metrics.bars_emitted.fetch_add(1, Ordering::Relaxed);
                let completed = CompletedBar {
                    symbol: symbol_arc.clone(),
                    threshold_decimal_bps: *threshold,
                    bar: orphan,
                };
                if !bar_buffer.push(completed) {
                    metrics.dropped_bars.fetch_add(1, Ordering::Relaxed);
                    metrics.backpressure_events.fetch_add(1, Ordering::Relaxed);
                }
            }

            match processor.process_single_trade(trade) {
                Ok(Some(bar)) => {
                    // Issue #214: Clear forming bar BEFORE emitting completed bar
                    if let Some(tx) = forming_tx_map.get(threshold) {
                        let _ = tx.send(None);
                    }
                    metrics.bars_emitted.fetch_add(1, Ordering::Relaxed);
                    let completed = CompletedBar {
                        symbol: symbol_arc.clone(),
                        threshold_decimal_bps: *threshold,
                        bar,
                    };
                    if !bar_buffer.push(completed) {
                        metrics.dropped_bars.fetch_add(1, Ordering::Relaxed);
                        metrics.backpressure_events.fetch_add(1, Ordering::Relaxed);
                    }
                }
                Ok(None) => {
                    // Issue #214: Update forming bar snapshot during gap fill
                    if let Some(tx) = forming_tx_map.get(threshold)
                        && let Some(incomplete) = processor.get_incomplete_bar()
                    {
                        let _ = tx.send(Some(FormingBar {
                            symbol: symbol_arc.clone(),
                            threshold_decimal_bps: *threshold,
                            bar: incomplete,
                            last_trade_timestamp_us: trade.timestamp,
                        }));
                    }
                }
                Err(e) => {
                    tracing::warn!(
                        %symbol, threshold = *threshold, ?e,
                        "gap-fill trade processing error"
                    );
                }
            }
        }
        trades_recovered += 1;
    }

    tracing::info!(
        %symbol, trades_recovered, gap_size,
        "reconnection gap fill complete"
    );
    trades_recovered
}

/// Per-symbol WebSocket task with independent reconnection.
/// Issue #91: Each symbol gets its own backoff state (barter-rs pattern).
#[allow(clippy::too_many_arguments)]
async fn symbol_task(
    symbol: String,
    mut processors: Vec<(u32, OpenDeviationBarProcessor)>,
    bar_buffer: ConcurrentRingBuffer<CompletedBar>, // Issue #96 Task #9: Ring buffer replaces mpsc
    checkpoint_tx: mpsc::Sender<(String, u32, Checkpoint)>,
    policy: ReconnectionPolicy,
    shutdown: CancellationToken,
    metrics: Arc<LiveEngineMetrics>,
    rate_limiter: Option<Arc<AdaptiveRateLimiter>>,
    forming_txs: Vec<(u32, watch::Sender<Option<FormingBar>>)>, // Issue #214
    gap_event_tx: mpsc::Sender<GapEvent>, // Issue #257
    mut gap_fill_rx: Option<crate::gap::GapFillReceiver>, // Issue #257: on-demand fill commands
) {
    // Issue #96: Create Arc<str> once per symbol task — cheap clone for each CompletedBar
    let symbol_arc: Arc<str> = Arc::from(symbol.as_str());

    // Issue #214: Index forming bar senders by threshold for O(1) lookup
    let forming_tx_map: HashMap<u32, watch::Sender<Option<FormingBar>>> =
        forming_txs.into_iter().collect();

    // Trade channel for this symbol's WebSocket
    let (trade_tx, mut trade_rx) = mpsc::channel::<AggTrade>(1000);

    // Spawn reconnecting WebSocket connection
    let ws_shutdown = shutdown.clone();
    let ws_symbol = symbol.clone();
    tokio::spawn(async move {
        BinanceWebSocketStream::run_with_reconnect(&ws_symbol, trade_tx, policy, ws_shutdown).await;
    });

    // Issue #257: Create gap detector for this symbol
    let mut gap_detector = TradeIdGapDetector::new(
        symbol_arc.clone(),
        Some(gap_event_tx),
    );
    // Seed from first processor's last known trade ID (if restored from checkpoint)
    if let Some(last_id) = processors.first().and_then(|(_, p)| p.last_agg_trade_id()) {
        gap_detector.seed(last_id);
    }

    tracing::info!(%symbol, thresholds = ?processors.iter().map(|(t, _)| *t).collect::<Vec<_>>(), "symbol task started");

    // Track current UTC day per processor for midnight reset (#264)
    let mut last_days: Vec<i64> = processors.iter().map(|_| -1i64).collect();

    // Process trades → bars
    loop {
        tokio::select! {
            // Issue #257: On-demand gap-fill commands from Python fill_gap()
            Some(cmd) = async {
                match gap_fill_rx.as_mut() {
                    Some(rx) => rx.recv().await,
                    None => std::future::pending().await,
                }
            } => {
                let start_time = std::time::Instant::now();
                tracing::info!(
                    %symbol, from_tid = cmd.from_tid, to_tid = cmd.to_tid,
                    "processing on-demand gap-fill command"
                );

                let gap_size = cmd.to_tid - cmd.from_tid;
                let (trades_fetched, bars_produced, partial) = if gap_size > MAX_GAP_FILL_TRADES {
                    tracing::warn!(
                        %symbol, gap_size, MAX_GAP_FILL_TRADES,
                        "on-demand gap too large, returning partial"
                    );
                    (0, 0, true)
                } else {
                    let recovered = puck_fill(
                        &symbol,
                        cmd.from_tid - 1, // last_known_id (fill expects exclusive start)
                        cmd.to_tid,
                        &mut processors,
                        &bar_buffer,
                        &metrics,
                        &shutdown,
                        rate_limiter.clone(),
                        &forming_tx_map,
                    )
                    .await;
                    // Count bars produced during this fill (approximate from metrics delta)
                    (recovered as usize, 0usize, false)
                };

                let duration_ms = start_time.elapsed().as_millis() as u64;
                let result = GapFillResult {
                    trades_fetched,
                    bars_produced,
                    duration_ms,
                    partial,
                };
                // Send result back — ignore error if caller dropped
                let _ = cmd.response_tx.send(result);
            }
            trade = trade_rx.recv() => {
                match trade {
                    Some(trade) => {
                        metrics.trades_received.fetch_add(1, Ordering::Relaxed);

                        // Issue #257: Gap detection via TradeIdGapDetector
                        if let Some((last_id, gap_size)) = gap_detector.check(trade.agg_trade_id) {
                            metrics.reconnections.fetch_add(1, Ordering::Relaxed);
                            metrics.gap_fills.fetch_add(1, Ordering::Relaxed);
                            let recovered = puck_fill(
                                &symbol,
                                last_id,
                                trade.agg_trade_id,
                                &mut processors,
                                &bar_buffer,
                                &metrics,
                                &shutdown,
                                rate_limiter.clone(),
                                &forming_tx_map,
                            )
                            .await;
                            metrics
                                .gap_trades_recovered
                                .fetch_add(recovered, Ordering::Relaxed);

                            // Mark gap as filled inline if trades were recovered
                            if recovered > 0 {
                                gap_detector.mark_filled(last_id, gap_size);
                            }
                        }

                        // Issue #96 Task #78: Fan out using borrowed trade (0 clones)
                        // Previously cloned trade for each threshold processor (~57 bytes each).
                        // With &trade, eliminates 4-8x unnecessary allocations per trade.
                        for (idx, (threshold, processor)) in processors.iter_mut().enumerate() {
                            // Midnight reset: if trade crosses day boundary, emit orphan and reset
                            if let Some(orphan) = maybe_reset_at_midnight(processor, trade.timestamp, &mut last_days[idx]) {
                                metrics.bars_emitted.fetch_add(1, Ordering::Relaxed);
                                let completed = CompletedBar {
                                    symbol: symbol_arc.clone(),
                                    threshold_decimal_bps: *threshold,
                                    bar: orphan,
                                };
                                let was_added = bar_buffer.push(completed);
                                if !was_added {
                                    metrics.dropped_bars.fetch_add(1, Ordering::Relaxed);
                                    metrics.backpressure_events.fetch_add(1, Ordering::Relaxed);
                                    tracing::warn!(%symbol, threshold, "ring buffer full, old bar dropped (midnight orphan)");
                                }
                            }

                            match processor.process_single_trade(&trade) {
                                Ok(Some(bar)) => {
                                    // Issue #214: Clear forming bar BEFORE emitting completed bar
                                    // to ring buffer. This prevents the daemon thread from seeing
                                    // a stale forming bar after the completed bar is already sent.
                                    if let Some(tx) = forming_tx_map.get(threshold) {
                                        let _ = tx.send(None); // Err only if all receivers dropped — safe
                                    }

                                    metrics.bars_emitted.fetch_add(1, Ordering::Relaxed);
                                    let completed = CompletedBar {
                                        symbol: symbol_arc.clone(),
                                        threshold_decimal_bps: *threshold,
                                        bar,
                                    };
                                    // Issue #96 Task #9: Push to ring buffer (non-blocking, may drop old bars)
                                    let was_added = bar_buffer.push(completed);
                                    if !was_added {
                                        // Ring buffer was full, old bar was dropped
                                        metrics.dropped_bars.fetch_add(1, Ordering::Relaxed);
                                        metrics.backpressure_events.fetch_add(1, Ordering::Relaxed);
                                        tracing::warn!(%symbol, threshold, "ring buffer full, old bar dropped");
                                    }
                                    // Issue #96 Task #12: Track queue depth metrics
                                    let current_depth = bar_buffer.len() as u64;
                                    let _ = metrics.max_queue_depth.fetch_max(current_depth, Ordering::Relaxed);
                                }
                                Ok(None) => {
                                    // Trade absorbed, no bar completed yet.
                                    // Issue #214: Update forming bar snapshot for consumers.
                                    if let Some(tx) = forming_tx_map.get(threshold)
                                        && let Some(incomplete) = processor.get_incomplete_bar()
                                    {
                                        let _ = tx.send(Some(FormingBar {
                                            symbol: symbol_arc.clone(),
                                            threshold_decimal_bps: *threshold,
                                            bar: incomplete,
                                            last_trade_timestamp_us: trade.timestamp,
                                        }));
                                    }
                                }
                                Err(e) => {
                                    tracing::warn!(%symbol, threshold = *threshold, ?e, "trade processing error");
                                }
                            }
                        }

                        // Issue #257: Update gap detector with this trade's ID
                        gap_detector.update_last_tid(trade.agg_trade_id);
                    }
                    None => {
                        // WebSocket channel closed (terminal error or shutdown)
                        tracing::info!(%symbol, "trade channel closed");
                        break;
                    }
                }
            }
            () = shutdown.cancelled() => {
                tracing::info!(%symbol, "shutdown requested");
                break;
            }
        }
    }

    // Extract checkpoints from all processors before task exits
    for (threshold, processor) in &processors {
        let cp = processor.create_checkpoint(&symbol);
        tracing::info!(
            %symbol, threshold,
            has_incomplete_bar = cp.has_incomplete_bar(),
            "checkpoint extracted on shutdown"
        );
        if checkpoint_tx
            .send((symbol.clone(), *threshold, cp))
            .await
            .is_err()
        {
            tracing::warn!(%symbol, threshold, "checkpoint channel closed");
        }
    }

    tracing::info!(%symbol, "symbol task ended");
}

#[cfg(test)]
mod tests {
    use super::*;
    use opendeviationbar_core::FixedPoint;

    fn make_trade(id: i64, price: f64, timestamp_ms: u64, is_buyer_maker: bool) -> AggTrade {
        let price_str = format!("{price:.8}");
        AggTrade {
            agg_trade_id: id,
            price: FixedPoint::from_str(&price_str).unwrap(),
            volume: FixedPoint::from_str("1.00000000").unwrap(),
            first_trade_id: id,
            last_trade_id: id,
            timestamp: opendeviationbar_core::normalize_timestamp(timestamp_ms),
            is_buyer_maker,
            is_best_match: None,
        }
    }

    #[test]
    fn test_live_engine_config_defaults() {
        let config = LiveEngineConfig::new(
            vec!["BTCUSDT".into(), "ETHUSDT".into()],
            vec![250, 500, 1000],
        );
        assert_eq!(config.symbols.len(), 2);
        assert_eq!(config.thresholds.len(), 3);
        assert!(config.include_microstructure);
        // Default from get_bar_channel_capacity() when OPENDEVIATIONBAR_MAX_PENDING_BARS is unset
        assert_eq!(config.bar_channel_capacity, 10_000);
        // Issue #128: Verify tier config defaults
        assert!(config.compute_tier2);
        assert!(!config.compute_tier3);
        assert_eq!(config.compute_hurst, None);
        assert_eq!(config.compute_permutation_entropy, None);
    }

    #[test]
    fn test_live_engine_config_rate_limiter() {
        let config = LiveEngineConfig::new(vec!["BTCUSDT".into()], vec![250]);
        assert!(config.rate_limiter.is_none());

        let limiter = Arc::new(AdaptiveRateLimiter::binance_default());
        let config = config.with_rate_limiter(limiter);
        assert!(config.rate_limiter.is_some());
        assert_eq!(config.rate_limiter.as_ref().unwrap().remaining(), 1920);
    }

    #[test]
    fn test_completed_bar_metadata() {
        let trade = make_trade(1, 50000.0, 1700000000000, false);
        let bar = OpenDeviationBar::new(&trade);
        let completed = CompletedBar {
            symbol: Arc::from("BTCUSDT"),
            threshold_decimal_bps: 250,
            bar,
        };
        assert_eq!(&*completed.symbol, "BTCUSDT");
        assert_eq!(completed.threshold_decimal_bps, 250);
    }

    #[test]
    fn test_live_engine_creation() {
        let config = LiveEngineConfig::new(vec!["BTCUSDT".into()], vec![250]);
        let engine = LiveBarEngine::new(config);
        assert!(!engine.is_started());
    }

    #[test]
    fn test_metrics_snapshot() {
        let metrics = LiveEngineMetrics::default();
        metrics.trades_received.store(100, Ordering::Relaxed);
        metrics.bars_emitted.store(5, Ordering::Relaxed);
        metrics.reconnections.store(2, Ordering::Relaxed);
        metrics.dropped_bars.store(1, Ordering::Relaxed);
        metrics.max_queue_depth.store(50, Ordering::Relaxed);
        metrics.backpressure_events.store(3, Ordering::Relaxed);
        metrics.gap_fills.store(2, Ordering::Relaxed);
        metrics.gap_trades_recovered.store(150, Ordering::Relaxed);

        let snap = metrics.snapshot();
        assert_eq!(snap.trades_received, 100);
        assert_eq!(snap.bars_emitted, 5);
        assert_eq!(snap.reconnections, 2);
        assert_eq!(snap.dropped_bars, 1);
        assert_eq!(snap.max_queue_depth, 50);
        assert_eq!(snap.backpressure_events, 3);
        assert_eq!(snap.gap_fills, 2);
        assert_eq!(snap.gap_trades_recovered, 150);
    }

    #[test]
    fn test_ring_buffer_metrics() {
        // Issue #96 Task #12: Verify ring buffer metrics tracking
        let metrics = LiveEngineMetrics::default();

        // Initially all zeros
        assert_eq!(metrics.dropped_bars.load(Ordering::Relaxed), 0);
        assert_eq!(metrics.backpressure_events.load(Ordering::Relaxed), 0);
        assert_eq!(metrics.max_queue_depth.load(Ordering::Relaxed), 0);

        // Simulate ring buffer drops
        metrics.dropped_bars.fetch_add(5, Ordering::Relaxed);
        metrics.backpressure_events.fetch_add(5, Ordering::Relaxed);
        let _ = metrics.max_queue_depth.fetch_max(100, Ordering::Relaxed);

        assert_eq!(metrics.dropped_bars.load(Ordering::Relaxed), 5);
        assert_eq!(metrics.backpressure_events.load(Ordering::Relaxed), 5);
        assert_eq!(metrics.max_queue_depth.load(Ordering::Relaxed), 100);

        // Verify snapshot captures all metrics
        let snap = metrics.snapshot();
        assert_eq!(snap.dropped_bars, 5);
        assert_eq!(snap.backpressure_events, 5);
        assert_eq!(snap.max_queue_depth, 100);
    }

    #[tokio::test]
    async fn test_engine_start_and_stop() {
        let config = LiveEngineConfig::new(vec!["BTCUSDT".into()], vec![250]);
        let mut engine = LiveBarEngine::new(config);

        // Start should succeed (spawns tasks but WS won't connect in test)
        assert!(engine.start().is_ok());
        assert!(engine.is_started());

        // Double-start is idempotent
        assert!(engine.start().is_ok());

        // Stop
        engine.stop();

        // next_bar should return None after stop
        let bar = engine.next_bar(Duration::from_millis(50)).await;
        assert!(bar.is_none());
    }

    #[tokio::test]
    async fn test_processor_fan_out() {
        // Verify that trades fan out to all threshold processors correctly
        let thresholds = vec![250u32, 500, 1000];
        let mut processors: Vec<(u32, OpenDeviationBarProcessor)> = thresholds
            .iter()
            .map(|&t| (t, OpenDeviationBarProcessor::new(t).unwrap()))
            .collect();

        // Process a series of trades — each processor should maintain independent state
        let base_price = 50000.0;
        for i in 0..10 {
            let trade = make_trade(
                i,
                base_price + (i as f64) * 0.01,
                1700000000000 + (i as u64) * 1000,
                i % 2 == 0,
            );
            for (_, processor) in &mut processors {
                let _ = processor.process_single_trade(&trade);
            }
        }

        // All processors should have processed trades independently
        for (threshold, processor) in &processors {
            let incomplete = processor.get_incomplete_bar();
            assert!(
                incomplete.is_some(),
                "threshold {threshold} should have an incomplete bar"
            );
        }
    }

    #[tokio::test]
    async fn test_microstructure_processor_creation() {
        // Verify processors can be created with full microstructure features
        let threshold = 250u32;
        let p = OpenDeviationBarProcessor::new(threshold)
            .unwrap()
            .with_inter_bar_config(InterBarConfig::default())
            .with_intra_bar_features();

        assert!(p.inter_bar_enabled());
        assert!(p.intra_bar_enabled());
    }

    #[test]
    fn test_ring_buffer_drop_tracking() {
        // Issue #96 Task #12: Verify ring buffer correctly tracks dropped bars
        use crate::ring_buffer::ConcurrentRingBuffer;

        let ring_buf = ConcurrentRingBuffer::new(3);
        let metrics = LiveEngineMetrics::default();

        // Fill the buffer
        let bar1 = CompletedBar {
            symbol: Arc::from("BTC"),
            threshold_decimal_bps: 250,
            bar: OpenDeviationBar::new(&make_trade(1, 50000.0, 1700000000000, false)),
        };
        let bar2 = CompletedBar {
            symbol: Arc::from("BTC"),
            threshold_decimal_bps: 250,
            bar: OpenDeviationBar::new(&make_trade(2, 50001.0, 1700000000001, false)),
        };
        let bar3 = CompletedBar {
            symbol: Arc::from("BTC"),
            threshold_decimal_bps: 250,
            bar: OpenDeviationBar::new(&make_trade(3, 50002.0, 1700000000002, false)),
        };
        let bar4 = CompletedBar {
            symbol: Arc::from("BTC"),
            threshold_decimal_bps: 250,
            bar: OpenDeviationBar::new(&make_trade(4, 50003.0, 1700000000003, false)),
        };

        assert!(ring_buf.push(bar1));
        assert!(ring_buf.push(bar2));
        assert!(ring_buf.push(bar3));

        // Buffer is full (capacity=3), so next push should fail and drop oldest
        assert!(!ring_buf.push(bar4));

        // Simulate metrics tracking (as symbol_task would do)
        metrics.dropped_bars.fetch_add(1, Ordering::Relaxed);
        metrics.backpressure_events.fetch_add(1, Ordering::Relaxed);
        let current_depth = ring_buf.len() as u64;
        let _ = metrics
            .max_queue_depth
            .fetch_max(current_depth, Ordering::Relaxed);

        assert_eq!(metrics.dropped_bars.load(Ordering::Relaxed), 1);
        assert_eq!(metrics.backpressure_events.load(Ordering::Relaxed), 1);
        assert_eq!(metrics.max_queue_depth.load(Ordering::Relaxed), 3);

        // Verify we can still retrieve bars
        assert_eq!(ring_buf.pop().is_some(), true); // bar2
        assert_eq!(ring_buf.pop().is_some(), true); // bar3
        assert_eq!(ring_buf.pop().is_some(), true); // bar4
        assert_eq!(ring_buf.pop().is_none(), true); // empty
    }

    #[test]
    fn test_live_engine_metrics_estimate_queue_depth() {
        // Issue #96 Task #12: Verify queue depth estimation
        let metrics = LiveEngineMetrics::default();

        metrics.trades_received.store(1000, Ordering::Relaxed);
        metrics.bars_emitted.store(500, Ordering::Relaxed);
        metrics.dropped_bars.store(0, Ordering::Relaxed);

        let estimated = metrics.estimate_queue_depth();
        assert_eq!(estimated, 500); // 1000 - 500 - 0
    }

    #[test]
    fn test_forming_bar_watch_channels_created() {
        // Issue #214: Verify watch channels are created for each (symbol, threshold) pair
        let config = LiveEngineConfig::new(
            vec!["BTCUSDT".into(), "ETHUSDT".into()],
            vec![250, 500],
        );
        let mut engine = LiveBarEngine::new(config);

        // Should have 2 symbols × 2 thresholds = 4 watch channels
        let watches = engine.take_forming_bar_watches();
        assert!(watches.is_some());
        let watches = watches.unwrap();
        assert_eq!(watches.len(), 4);

        // Verify all expected keys exist
        let btc: Arc<str> = Arc::from("BTCUSDT");
        let eth: Arc<str> = Arc::from("ETHUSDT");
        assert!(watches.contains_key(&(btc.clone(), 250)));
        assert!(watches.contains_key(&(btc, 500)));
        assert!(watches.contains_key(&(eth.clone(), 250)));
        assert!(watches.contains_key(&(eth, 500)));

        // Second call returns None (already taken)
        assert!(engine.take_forming_bar_watches().is_none());
    }

    #[test]
    fn test_forming_bar_watch_send_receive() {
        // Issue #214: Verify watch channel send/receive semantics
        let (tx, rx) = watch::channel::<Option<FormingBar>>(None);

        // Initially None
        assert!(rx.borrow().is_none());

        // Send a forming bar
        let trade = make_trade(1, 50000.0, 1700000000000, false);
        let bar = OpenDeviationBar::new(&trade);
        let forming = FormingBar {
            symbol: Arc::from("BTCUSDT"),
            threshold_decimal_bps: 250,
            bar: bar.clone(),
            last_trade_timestamp_us: 1700000000000000,
        };
        tx.send(Some(forming)).unwrap();

        // Receiver sees the latest value
        let snapshot = rx.borrow();
        assert!(snapshot.is_some());
        let fb = snapshot.as_ref().unwrap();
        assert_eq!(&*fb.symbol, "BTCUSDT");
        assert_eq!(fb.threshold_decimal_bps, 250);
        assert_eq!(fb.last_trade_timestamp_us, 1700000000000000);
        drop(snapshot);

        // Clear on bar completion (send None)
        tx.send(None).unwrap();
        assert!(rx.borrow().is_none());
    }
}
