// FILE-SIZE-OK: 1706 lines — consolidated test suite extracted from processor.rs
//! Integration tests for OpenDeviationBarProcessor
//!
//! Extracted from processor.rs to reduce file size (was 2,883 lines, 59% tests).

use opendeviationbar_core::checkpoint::{Checkpoint, CheckpointError, PositionVerification};
use opendeviationbar_core::fixed_point::FixedPoint;
use opendeviationbar_core::interbar::InterBarConfig;
use opendeviationbar_core::processor::{
    ExportOpenDeviationBarProcessor, OpenDeviationBarProcessor, ProcessingError,
};
use opendeviationbar_core::test_utils::{self, scenarios};
use opendeviationbar_core::types::{AggTrade, OpenDeviationBar};

#[test]
fn test_single_bar_no_breach() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap(); // 250 dbps = 0.25%

    // Create trades that stay within 250 dbps threshold
    let trades = scenarios::no_breach_sequence(250);

    // Test strict algorithm compliance: no bars should be created without breach
    let bars = processor.process_agg_trade_records(&trades).unwrap();
    assert_eq!(
        bars.len(),
        0,
        "Strict algorithm should not create bars without breach"
    );

    // Test analysis mode: incomplete bar should be available for analysis
    let bars_with_incomplete = processor
        .process_agg_trade_records_with_incomplete(&trades)
        .unwrap();
    assert_eq!(
        bars_with_incomplete.len(),
        1,
        "Analysis mode should include incomplete bar"
    );

    let bar = &bars_with_incomplete[0];
    assert_eq!(bar.open.to_string(), "50000.00000000");
    assert_eq!(bar.high.to_string(), "50100.00000000");
    assert_eq!(bar.low.to_string(), "49900.00000000");
    assert_eq!(bar.close.to_string(), "49900.00000000");
}

#[test]
fn test_exact_breach_upward() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap(); // 250 dbps = 0.25%

    let trades = scenarios::exact_breach_upward(250);

    // Test strict algorithm: only completed bars (with breach)
    let bars = processor.process_agg_trade_records(&trades).unwrap();
    assert_eq!(
        bars.len(),
        1,
        "Strict algorithm should only return completed bars"
    );

    // First bar should close at breach
    let bar1 = &bars[0];
    assert_eq!(bar1.open.to_string(), "50000.00000000");
    // Breach at 250 dbps = 0.25% = 50000 * 1.0025 = 50125
    assert_eq!(bar1.close.to_string(), "50125.00000000"); // Breach tick included
    assert_eq!(bar1.high.to_string(), "50125.00000000");
    assert_eq!(bar1.low.to_string(), "50000.00000000");

    // Test analysis mode: includes incomplete second bar
    let bars_with_incomplete = processor
        .process_agg_trade_records_with_incomplete(&trades)
        .unwrap();
    assert_eq!(
        bars_with_incomplete.len(),
        2,
        "Analysis mode should include incomplete bars"
    );

    // Second bar should start at next tick price (not breach price)
    let bar2 = &bars_with_incomplete[1];
    assert_eq!(bar2.open.to_string(), "50500.00000000"); // Next tick after breach
    assert_eq!(bar2.close.to_string(), "50500.00000000");
}

#[test]
fn test_exact_breach_downward() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap(); // 250 × 0.1bps = 25bps = 0.25%

    let trades = scenarios::exact_breach_downward(250);

    let bars = processor.process_agg_trade_records(&trades).unwrap();

    assert_eq!(bars.len(), 1);

    let bar = &bars[0];
    assert_eq!(bar.open.to_string(), "50000.00000000");
    assert_eq!(bar.close.to_string(), "49875.00000000"); // Breach tick included
    assert_eq!(bar.high.to_string(), "50000.00000000");
    assert_eq!(bar.low.to_string(), "49875.00000000");
}

#[test]
fn test_large_gap_single_bar() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap(); // 250 × 0.1bps = 25bps = 0.25%

    let trades = scenarios::large_gap_sequence();

    let bars = processor.process_agg_trade_records(&trades).unwrap();

    // Should create exactly ONE bar, not multiple bars to "fill the gap"
    assert_eq!(bars.len(), 1);

    let bar = &bars[0];
    assert_eq!(bar.open.to_string(), "50000.00000000");
    assert_eq!(bar.close.to_string(), "51000.00000000");
    assert_eq!(bar.high.to_string(), "51000.00000000");
    assert_eq!(bar.low.to_string(), "50000.00000000");
}

#[test]
fn test_unsorted_trades_error() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap(); // 250 × 0.1bps = 25bps

    let trades = scenarios::unsorted_sequence();

    let result = processor.process_agg_trade_records(&trades);
    assert!(result.is_err());

    match result {
        Err(ProcessingError::UnsortedTrades { index, .. }) => {
            assert_eq!(index, 1);
        }
        _ => panic!("Expected UnsortedTrades error"),
    }
}

#[test]
fn test_threshold_calculation() {
    let processor = OpenDeviationBarProcessor::new(250).unwrap(); // 250 × 0.1bps = 25bps = 0.25%

    // Verify threshold computation via public FixedPoint API (same math as OpenDeviationBarState::new)
    let price = FixedPoint::from_str("50000.0").unwrap();
    let (upper, lower) = price.compute_range_thresholds_cached(processor.threshold_ratio);

    // 50000 * 0.0025 = 125 (25bps = 0.25%)
    assert_eq!(upper.to_string(), "50125.00000000");
    assert_eq!(lower.to_string(), "49875.00000000");
}

#[test]
fn test_empty_trades() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap(); // 250 × 0.1bps = 25bps
    let trades = scenarios::empty_sequence();
    let bars = processor.process_agg_trade_records(&trades).unwrap();
    assert_eq!(bars.len(), 0);
}

#[test]
fn test_debug_streaming_data() {
    let mut processor = OpenDeviationBarProcessor::new(100).unwrap(); // 100 × 0.1bps = 10bps = 0.1%

    // Create trades similar to our test data
    let trades = vec![
        test_utils::create_test_agg_trade(1, "50014.00859087", "0.12019569", 1756710002083),
        test_utils::create_test_agg_trade(2, "50163.87750994", "1.01283708", 1756710005113), // ~0.3% increase
        test_utils::create_test_agg_trade(3, "50032.44128269", "0.69397094", 1756710008770),
    ];

    println!("Test data prices: 50014 -> 50163 -> 50032");
    println!("Expected price movements: +0.3% then -0.26%");

    let bars = processor.process_agg_trade_records(&trades).unwrap();
    println!("Generated {} open deviation bars", bars.len());

    for (i, bar) in bars.iter().enumerate() {
        println!(
            "  Bar {}: O={} H={} L={} C={}",
            i + 1,
            bar.open,
            bar.high,
            bar.low,
            bar.close
        );
    }

    // With a 0.1% threshold and 0.3% price movement, we should get at least 1 bar
    assert!(
        !bars.is_empty(),
        "Expected at least 1 open deviation bar with 0.3% price movement and 0.1% threshold"
    );
}

#[test]
fn test_threshold_validation() {
    // Valid threshold
    assert!(OpenDeviationBarProcessor::new(250).is_ok());

    // Invalid: too low (0 × 0.1bps = 0%)
    assert!(matches!(
        OpenDeviationBarProcessor::new(0),
        Err(ProcessingError::InvalidThreshold {
            threshold_decimal_bps: 0
        })
    ));

    // Invalid: too high (150,000 × 0.1bps = 15,000bps = 150%)
    assert!(matches!(
        OpenDeviationBarProcessor::new(150_000),
        Err(ProcessingError::InvalidThreshold {
            threshold_decimal_bps: 150_000
        })
    ));

    // Valid boundary: minimum (1 × 0.1bps = 0.1bps = 0.001%)
    assert!(OpenDeviationBarProcessor::new(1).is_ok());

    // Valid boundary: maximum (100,000 × 0.1bps = 10,000bps = 100%)
    assert!(OpenDeviationBarProcessor::new(100_000).is_ok());
}

#[test]
fn test_export_processor_with_manual_trades() {
    println!("Testing ExportOpenDeviationBarProcessor with same trade data...");

    let mut export_processor = ExportOpenDeviationBarProcessor::new(100).unwrap(); // 100 × 0.1bps = 10bps = 0.1%

    // Use same trades as the working basic test
    let trades = vec![
        test_utils::create_test_agg_trade(1, "50014.00859087", "0.12019569", 1756710002083),
        test_utils::create_test_agg_trade(2, "50163.87750994", "1.01283708", 1756710005113), // ~0.3% increase
        test_utils::create_test_agg_trade(3, "50032.44128269", "0.69397094", 1756710008770),
    ];

    println!(
        "Processing {} trades with ExportOpenDeviationBarProcessor...",
        trades.len()
    );

    export_processor.process_trades_continuously(&trades);
    let bars = export_processor.get_all_completed_bars();

    println!(
        "ExportOpenDeviationBarProcessor generated {} open deviation bars",
        bars.len()
    );
    for (i, bar) in bars.iter().enumerate() {
        println!(
            "  Bar {}: O={} H={} L={} C={}",
            i + 1,
            bar.open,
            bar.high,
            bar.low,
            bar.close
        );
    }

    // Should match the basic processor results (1 bar)
    assert!(
        !bars.is_empty(),
        "ExportOpenDeviationBarProcessor should generate same results as basic processor"
    );
}

// === CHECKPOINT TESTS (Issues #2 and #3) ===

#[test]
fn test_checkpoint_creation() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    // Process some trades that don't complete a bar
    let trades = scenarios::no_breach_sequence(250);
    let _bars = processor.process_agg_trade_records(&trades).unwrap();

    // Create checkpoint
    let checkpoint = processor.create_checkpoint("BTCUSDT");

    assert_eq!(checkpoint.symbol, "BTCUSDT");
    assert_eq!(checkpoint.threshold_decimal_bps, 250);
    assert!(checkpoint.has_incomplete_bar()); // Should have incomplete bar
    assert!(checkpoint.thresholds.is_some()); // Thresholds should be saved
    assert!(checkpoint.last_trade_id.is_some()); // Should track last trade
}

#[test]
fn test_checkpoint_serialization_roundtrip() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    // Process trades
    let trades = scenarios::no_breach_sequence(250);
    let _bars = processor.process_agg_trade_records(&trades).unwrap();

    // Create checkpoint
    let checkpoint = processor.create_checkpoint("BTCUSDT");

    // Serialize to JSON
    let json = serde_json::to_string(&checkpoint).expect("Serialization should succeed");

    // Deserialize back
    let restored: Checkpoint = serde_json::from_str(&json).expect("Deserialization should succeed");

    assert_eq!(restored.symbol, checkpoint.symbol);
    assert_eq!(
        restored.threshold_decimal_bps,
        checkpoint.threshold_decimal_bps
    );
    assert_eq!(
        restored.incomplete_bar.is_some(),
        checkpoint.incomplete_bar.is_some()
    );
}

#[test]
fn test_cross_file_bar_continuation() {
    // This is the PRIMARY test for Issues #2 and #3
    // Verifies that incomplete bars continue correctly across file boundaries

    // Create trades that span multiple bars
    let mut all_trades = Vec::new();

    // Generate enough trades to produce multiple bars
    // Using 100bps threshold (1%) for clearer price movements
    let base_timestamp = 1640995200000000i64; // Microseconds

    // Create a sequence where we'll have ~3-4 completed bars with remainder
    for i in 0..20 {
        let price = 50000.0 + (i as f64 * 100.0) * if i % 4 < 2 { 1.0 } else { -1.0 };
        let trade = test_utils::create_test_agg_trade(
            i + 1,
            &format!("{:.8}", price),
            "1.0",
            base_timestamp + (i * 1000000),
        );
        all_trades.push(trade);
    }

    // === FULL PROCESSING (baseline) ===
    let mut processor_full = OpenDeviationBarProcessor::new(100).unwrap(); // 100 × 0.1bps = 10bps = 0.1%
    let bars_full = processor_full
        .process_agg_trade_records(&all_trades)
        .unwrap();

    // === SPLIT PROCESSING WITH CHECKPOINT ===
    let split_point = 10; // Split in the middle

    // Part 1: Process first half
    let mut processor_1 = OpenDeviationBarProcessor::new(100).unwrap();
    let part1_trades = &all_trades[0..split_point];
    let bars_1 = processor_1.process_agg_trade_records(part1_trades).unwrap();

    // Create checkpoint
    let checkpoint = processor_1.create_checkpoint("TEST");

    // Part 2: Resume from checkpoint and process second half
    let mut processor_2 = OpenDeviationBarProcessor::from_checkpoint(checkpoint).unwrap();
    let part2_trades = &all_trades[split_point..];
    let bars_2 = processor_2.process_agg_trade_records(part2_trades).unwrap();

    // === VERIFY CONTINUATION ===
    // Total completed bars should match full processing
    let split_total = bars_1.len() + bars_2.len();

    println!("Full processing: {} bars", bars_full.len());
    println!(
        "Split processing: {} + {} = {} bars",
        bars_1.len(),
        bars_2.len(),
        split_total
    );

    assert_eq!(
        split_total,
        bars_full.len(),
        "Split processing should produce same bar count as full processing"
    );

    // Verify the bars themselves match
    let all_split_bars: Vec<_> = bars_1.iter().chain(bars_2.iter()).collect();
    for (i, (full, split)) in bars_full.iter().zip(all_split_bars.iter()).enumerate() {
        assert_eq!(full.open.0, split.open.0, "Bar {} open price mismatch", i);
        assert_eq!(
            full.close.0, split.close.0,
            "Bar {} close price mismatch",
            i
        );
    }
}

#[test]
fn test_verify_position_exact() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    // Process some trades
    let trade1 = test_utils::create_test_agg_trade(100, "50000.0", "1.0", 1640995200000000);
    let trade2 = test_utils::create_test_agg_trade(101, "50010.0", "1.0", 1640995201000000);

    let _ = processor.process_single_trade(&trade1);
    let _ = processor.process_single_trade(&trade2);

    // Create next trade in sequence
    let next_trade = test_utils::create_test_agg_trade(102, "50020.0", "1.0", 1640995202000000);

    // Verify position
    let verification = processor.verify_position(&next_trade);

    assert_eq!(verification, PositionVerification::Exact);
}

#[test]
fn test_verify_position_gap() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    // Process some trades
    let trade1 = test_utils::create_test_agg_trade(100, "50000.0", "1.0", 1640995200000000);
    let trade2 = test_utils::create_test_agg_trade(101, "50010.0", "1.0", 1640995201000000);

    let _ = processor.process_single_trade(&trade1);
    let _ = processor.process_single_trade(&trade2);

    // Create next trade with gap (skip IDs 102-104)
    let next_trade = test_utils::create_test_agg_trade(105, "50020.0", "1.0", 1640995202000000);

    // Verify position
    let verification = processor.verify_position(&next_trade);

    match verification {
        PositionVerification::Gap {
            expected_id,
            actual_id,
            missing_count,
        } => {
            assert_eq!(expected_id, 102);
            assert_eq!(actual_id, 105);
            assert_eq!(missing_count, 3);
        }
        _ => panic!("Expected Gap verification, got {:?}", verification),
    }
}

// Issue #96 Task #97: Test TimestampOnly branch (Exness path, no trade IDs)
#[test]
fn test_verify_position_timestamp_only() {
    // Fresh processor has last_trade_id = None (simulates Exness path)
    let processor = OpenDeviationBarProcessor::new(250).unwrap();

    let trade = test_utils::create_test_agg_trade(1, "50000.0", "1.0", 5000000);
    let verification = processor.verify_position(&trade);

    // last_trade_id is None → TimestampOnly branch
    // gap_ms = (5000000 - 0) / 1000 = 5000
    match verification {
        PositionVerification::TimestampOnly { gap_ms } => {
            assert_eq!(gap_ms, 5000, "gap_ms should be (timestamp - 0) / 1000");
        }
        _ => panic!(
            "Expected TimestampOnly verification, got {:?}",
            verification
        ),
    }
}

#[test]
fn test_checkpoint_clean_completion() {
    // Test when last trade completes a bar with no remainder
    // In open deviation bar algorithm: breach trade closes bar, NEXT trade opens new bar
    // If there's no next trade, there's no incomplete bar
    let mut processor = OpenDeviationBarProcessor::new(100).unwrap(); // 10bps

    // Create trades that complete exactly one bar
    let trades = vec![
        test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1640995200000000),
        test_utils::create_test_agg_trade(2, "50100.0", "1.0", 1640995201000000), // ~0.2% move, breaches 0.1%
    ];

    let bars = processor.process_agg_trade_records(&trades).unwrap();
    assert_eq!(bars.len(), 1, "Should have exactly one completed bar");

    // Create checkpoint - should NOT have incomplete bar
    // (breach trade closes bar, no next trade to open new bar)
    let checkpoint = processor.create_checkpoint("TEST");

    // With defer_open logic, the next bar isn't started until the next trade
    assert!(
        !checkpoint.has_incomplete_bar(),
        "No incomplete bar when last trade was a breach with no following trade"
    );
}

#[test]
fn test_checkpoint_with_remainder() {
    // Test when we have trades remaining after a completed bar
    let mut processor = OpenDeviationBarProcessor::new(100).unwrap(); // 10bps

    // Create trades: bar completes at trade 2, trade 3 starts new bar
    let trades = vec![
        test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1640995200000000),
        test_utils::create_test_agg_trade(2, "50100.0", "1.0", 1640995201000000), // Breach
        test_utils::create_test_agg_trade(3, "50110.0", "1.0", 1640995202000000), // Opens new bar
    ];

    let bars = processor.process_agg_trade_records(&trades).unwrap();
    assert_eq!(bars.len(), 1, "Should have exactly one completed bar");

    // Create checkpoint - should have incomplete bar from trade 3
    let checkpoint = processor.create_checkpoint("TEST");

    assert!(
        checkpoint.has_incomplete_bar(),
        "Should have incomplete bar from trade 3"
    );

    // Verify the incomplete bar has correct data
    let incomplete = checkpoint.incomplete_bar.unwrap();
    assert_eq!(
        incomplete.open.to_string(),
        "50110.00000000",
        "Incomplete bar should open at trade 3 price"
    );
}

/// Issue #46: Verify streaming and batch paths produce identical bars
///
/// The batch path (`process_agg_trade_records`) and streaming path
/// (`process_single_trade`) must produce identical OHLCV output for
/// the same input trades. This test catches regressions where the
/// breaching trade is double-counted or bar boundaries differ.
#[test]
fn test_streaming_batch_parity() {
    let threshold = 250; // 250 dbps = 0.25%

    // Build a sequence with multiple breaches
    let trades = test_utils::AggTradeBuilder::new()
        .add_trade(1, 1.0, 0) // Open first bar at 50000
        .add_trade(2, 1.001, 1000) // +0.1% - accumulate
        .add_trade(3, 1.003, 2000) // +0.3% - breach (>0.25%)
        .add_trade(4, 1.004, 3000) // Opens second bar
        .add_trade(5, 1.005, 4000) // Accumulate
        .add_trade(6, 1.008, 5000) // +0.4% from bar 2 open - breach
        .add_trade(7, 1.009, 6000) // Opens third bar
        .build();

    // === BATCH PATH ===
    let mut batch_processor = OpenDeviationBarProcessor::new(threshold).unwrap();
    let batch_bars = batch_processor.process_agg_trade_records(&trades).unwrap();
    let batch_incomplete = batch_processor.get_incomplete_bar();

    // === STREAMING PATH ===
    let mut stream_processor = OpenDeviationBarProcessor::new(threshold).unwrap();
    let mut stream_bars: Vec<OpenDeviationBar> = Vec::new();
    for trade in &trades {
        if let Some(bar) = stream_processor.process_single_trade(trade).unwrap() {
            stream_bars.push(bar);
        }
    }
    let stream_incomplete = stream_processor.get_incomplete_bar();

    // === VERIFY PARITY ===
    assert_eq!(
        batch_bars.len(),
        stream_bars.len(),
        "Batch and streaming should produce same number of completed bars"
    );

    for (i, (batch_bar, stream_bar)) in batch_bars.iter().zip(stream_bars.iter()).enumerate() {
        assert_eq!(
            batch_bar.open, stream_bar.open,
            "Bar {i}: open price mismatch"
        );
        assert_eq!(
            batch_bar.close, stream_bar.close,
            "Bar {i}: close price mismatch"
        );
        assert_eq!(
            batch_bar.high, stream_bar.high,
            "Bar {i}: high price mismatch"
        );
        assert_eq!(batch_bar.low, stream_bar.low, "Bar {i}: low price mismatch");
        assert_eq!(
            batch_bar.volume, stream_bar.volume,
            "Bar {i}: volume mismatch (double-counting?)"
        );
        assert_eq!(
            batch_bar.open_time, stream_bar.open_time,
            "Bar {i}: open_time mismatch"
        );
        assert_eq!(
            batch_bar.close_time, stream_bar.close_time,
            "Bar {i}: close_time mismatch"
        );
        assert_eq!(
            batch_bar.individual_trade_count, stream_bar.individual_trade_count,
            "Bar {i}: trade count mismatch"
        );
    }

    // Verify incomplete bars match
    match (batch_incomplete, stream_incomplete) {
        (Some(b), Some(s)) => {
            assert_eq!(b.open, s.open, "Incomplete bar: open mismatch");
            assert_eq!(b.close, s.close, "Incomplete bar: close mismatch");
            assert_eq!(b.volume, s.volume, "Incomplete bar: volume mismatch");
        }
        (None, None) => {} // Both finished cleanly
        _ => panic!("Incomplete bar presence mismatch between batch and streaming"),
    }
}

/// Issue #96: Proptest-enhanced batch vs streaming parity
///
/// Generates random trade sequences (200-500 trades) with realistic price
/// movements and verifies bit-exact parity between batch and streaming paths
/// on ALL bar fields including microstructure features.
mod proptest_batch_streaming_parity {
    use super::*;
    use proptest::prelude::*;

    /// Generate a realistic trade sequence with random price movements
    fn trade_sequence(n: usize, base_price: f64, volatility: f64) -> Vec<AggTrade> {
        let mut trades = Vec::with_capacity(n);
        let mut price = base_price;
        let base_ts = 1640995200000i64; // 2022-01-01

        for i in 0..n {
            // Deterministic price walk using sin/cos (proptest handles seed)
            let step = ((i as f64 * 0.3).sin() * volatility)
                + ((i as f64 * 0.07).cos() * volatility * 0.5);
            price += step;
            // Clamp to avoid negative prices
            if price < 100.0 {
                price = 100.0 + (i as f64 * 0.01).sin().abs() * 50.0;
            }

            let trade = test_utils::create_test_agg_trade_with_range(
                i as i64 + 1,
                &format!("{:.8}", price),
                "1.50000000",
                base_ts + (i as i64 * 500), // 500ms apart
                (i as i64 + 1) * 10,
                (i as i64 + 1) * 10,
                i % 3 != 0, // Mix of buy/sell sides
            );
            trades.push(trade);
        }
        trades
    }

    /// Assert two bars are bit-exact on all OHLCV and microstructure fields
    fn assert_bar_parity(i: usize, batch: &OpenDeviationBar, stream: &OpenDeviationBar) {
        // Tier 1: OHLCV core
        assert_eq!(batch.open_time, stream.open_time, "Bar {i}: open_time");
        assert_eq!(batch.close_time, stream.close_time, "Bar {i}: close_time");
        assert_eq!(batch.open, stream.open, "Bar {i}: open");
        assert_eq!(batch.high, stream.high, "Bar {i}: high");
        assert_eq!(batch.low, stream.low, "Bar {i}: low");
        assert_eq!(batch.close, stream.close, "Bar {i}: close");

        // Tier 2: Volume accumulators
        assert_eq!(batch.volume, stream.volume, "Bar {i}: volume");
        assert_eq!(batch.turnover, stream.turnover, "Bar {i}: turnover");
        assert_eq!(batch.buy_volume, stream.buy_volume, "Bar {i}: buy_volume");
        assert_eq!(
            batch.sell_volume, stream.sell_volume,
            "Bar {i}: sell_volume"
        );
        assert_eq!(
            batch.buy_turnover, stream.buy_turnover,
            "Bar {i}: buy_turnover"
        );
        assert_eq!(
            batch.sell_turnover, stream.sell_turnover,
            "Bar {i}: sell_turnover"
        );

        // Tier 3: Trade tracking
        assert_eq!(
            batch.individual_trade_count, stream.individual_trade_count,
            "Bar {i}: trade_count"
        );
        assert_eq!(
            batch.agg_record_count, stream.agg_record_count,
            "Bar {i}: agg_record_count"
        );
        assert_eq!(
            batch.first_trade_id, stream.first_trade_id,
            "Bar {i}: first_trade_id"
        );
        assert_eq!(
            batch.last_trade_id, stream.last_trade_id,
            "Bar {i}: last_trade_id"
        );
        assert_eq!(
            batch.first_agg_trade_id, stream.first_agg_trade_id,
            "Bar {i}: first_agg_trade_id"
        );
        assert_eq!(
            batch.last_agg_trade_id, stream.last_agg_trade_id,
            "Bar {i}: last_agg_trade_id"
        );
        assert_eq!(
            batch.buy_trade_count, stream.buy_trade_count,
            "Bar {i}: buy_trade_count"
        );
        assert_eq!(
            batch.sell_trade_count, stream.sell_trade_count,
            "Bar {i}: sell_trade_count"
        );

        // Tier 4: VWAP
        assert_eq!(batch.vwap, stream.vwap, "Bar {i}: vwap");

        // Tier 5: Microstructure f64 features (bit-exact comparison)
        assert_eq!(
            batch.duration_us, stream.duration_us,
            "Bar {i}: duration_us"
        );
        assert_eq!(batch.ofi.to_bits(), stream.ofi.to_bits(), "Bar {i}: ofi");
        assert_eq!(
            batch.vwap_close_deviation.to_bits(),
            stream.vwap_close_deviation.to_bits(),
            "Bar {i}: vwap_close_dev"
        );
        assert_eq!(
            batch.price_impact.to_bits(),
            stream.price_impact.to_bits(),
            "Bar {i}: price_impact"
        );
        assert_eq!(
            batch.kyle_lambda_proxy.to_bits(),
            stream.kyle_lambda_proxy.to_bits(),
            "Bar {i}: kyle_lambda"
        );
        assert_eq!(
            batch.trade_intensity.to_bits(),
            stream.trade_intensity.to_bits(),
            "Bar {i}: trade_intensity"
        );
        assert_eq!(
            batch.volume_per_trade.to_bits(),
            stream.volume_per_trade.to_bits(),
            "Bar {i}: vol_per_trade"
        );
        assert_eq!(
            batch.aggression_ratio.to_bits(),
            stream.aggression_ratio.to_bits(),
            "Bar {i}: aggression_ratio"
        );
        assert_eq!(
            batch.aggregation_density_f64.to_bits(),
            stream.aggregation_density_f64.to_bits(),
            "Bar {i}: agg_density"
        );
        assert_eq!(
            batch.turnover_imbalance.to_bits(),
            stream.turnover_imbalance.to_bits(),
            "Bar {i}: turnover_imbalance"
        );
    }

    proptest! {
        /// 200-500 random trades, 250 dbps threshold
        #[test]
        fn batch_streaming_parity_random(
            n in 200usize..500,
            volatility in 10.0f64..200.0,
        ) {
            let trades = trade_sequence(n, 50000.0, volatility);

            // Batch path
            let mut batch_proc = OpenDeviationBarProcessor::new(250).unwrap();
            let batch_bars = batch_proc.process_agg_trade_records(&trades).unwrap();
            let batch_incomplete = batch_proc.get_incomplete_bar();

            // Streaming path
            let mut stream_proc = OpenDeviationBarProcessor::new(250).unwrap();
            let mut stream_bars: Vec<OpenDeviationBar> = Vec::new();
            for trade in &trades {
                if let Some(bar) = stream_proc.process_single_trade(trade).unwrap() {
                    stream_bars.push(bar);
                }
            }
            let stream_incomplete = stream_proc.get_incomplete_bar();

            // Bar count parity
            prop_assert_eq!(batch_bars.len(), stream_bars.len(),
                "Completed bar count mismatch: batch={}, stream={} for n={}, vol={}",
                batch_bars.len(), stream_bars.len(), n, volatility);

            // Field-level parity for all completed bars
            for (i, (b, s)) in batch_bars.iter().zip(stream_bars.iter()).enumerate() {
                assert_bar_parity(i, b, s);
            }

            // Incomplete bar parity
            match (&batch_incomplete, &stream_incomplete) {
                (Some(b), Some(s)) => assert_bar_parity(batch_bars.len(), b, s),
                (None, None) => {}
                _ => prop_assert!(false,
                    "Incomplete bar presence mismatch: batch={}, stream={}",
                    batch_incomplete.is_some(), stream_incomplete.is_some()),
            }
        }

        /// Vary threshold: 100-1000 dbps
        #[test]
        fn batch_streaming_parity_thresholds(
            threshold in 100u32..1000,
        ) {
            let trades = trade_sequence(300, 50000.0, 80.0);

            let mut batch_proc = OpenDeviationBarProcessor::new(threshold).unwrap();
            let batch_bars = batch_proc.process_agg_trade_records(&trades).unwrap();

            let mut stream_proc = OpenDeviationBarProcessor::new(threshold).unwrap();
            let mut stream_bars: Vec<OpenDeviationBar> = Vec::new();
            for trade in &trades {
                if let Some(bar) = stream_proc.process_single_trade(trade).unwrap() {
                    stream_bars.push(bar);
                }
            }

            prop_assert_eq!(batch_bars.len(), stream_bars.len(),
                "Bar count mismatch at threshold={}", threshold);

            for (i, (b, s)) in batch_bars.iter().zip(stream_bars.iter()).enumerate() {
                assert_bar_parity(i, b, s);
            }
        }
    }
}

/// Issue #46: After breach, next trade opens new bar (not breaching trade)
#[test]
fn test_defer_open_new_bar_opens_with_next_trade() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    // Trade 1: Opens bar at 50000
    let t1 = test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1000);
    assert!(processor.process_single_trade(&t1).unwrap().is_none());

    // Trade 2: Breaches threshold (+0.3%)
    let t2 = test_utils::create_test_agg_trade(2, "50150.0", "2.0", 2000);
    let bar = processor.process_single_trade(&t2).unwrap();
    assert!(bar.is_some(), "Should close bar on breach");

    let closed_bar = bar.unwrap();
    assert_eq!(closed_bar.open.to_string(), "50000.00000000");
    assert_eq!(closed_bar.close.to_string(), "50150.00000000");

    // After breach, no incomplete bar should exist
    assert!(
        processor.get_incomplete_bar().is_none(),
        "No incomplete bar after breach - defer_open is true"
    );

    // Trade 3: Should open NEW bar (not the breaching trade)
    let t3 = test_utils::create_test_agg_trade(3, "50100.0", "3.0", 3000);
    assert!(processor.process_single_trade(&t3).unwrap().is_none());

    let incomplete = processor.get_incomplete_bar().unwrap();
    assert_eq!(
        incomplete.open.to_string(),
        "50100.00000000",
        "New bar should open at trade 3's price, not trade 2's"
    );
}

// === Memory efficiency tests (R1/R2/R3) ===

#[test]
fn test_bar_close_take_single_trade() {
    // R1: Verify bar close via single-trade path produces correct OHLCV after
    // clone→take optimization. Uses single_breach_sequence that triggers breach.
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();
    let trades = scenarios::single_breach_sequence(250);

    for trade in &trades[..trades.len() - 1] {
        let result = processor.process_single_trade(trade).unwrap();
        assert!(result.is_none());
    }

    // Last trade triggers breach
    let bar = processor
        .process_single_trade(trades.last().unwrap())
        .unwrap()
        .expect("Should produce completed bar");

    // Verify OHLCV integrity after take() optimization
    assert_eq!(bar.open.to_string(), "50000.00000000");
    assert!(bar.high >= bar.open.max(bar.close));
    assert!(bar.low <= bar.open.min(bar.close));
    assert!(bar.volume > 0);

    // Verify processor state is clean after bar close
    assert!(processor.get_incomplete_bar().is_none());
}

#[test]
fn test_bar_close_take_batch() {
    // R2: Verify batch path produces correct bars after clone→take optimization.
    // large_sequence generates enough trades to trigger multiple breaches.
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();
    let trades = scenarios::large_sequence(500);

    let bars = processor.process_agg_trade_records(&trades).unwrap();
    assert!(
        !bars.is_empty(),
        "Should produce at least one completed bar"
    );

    // Verify every bar has valid OHLCV invariants
    for bar in &bars {
        assert!(bar.high >= bar.open.max(bar.close));
        assert!(bar.low <= bar.open.min(bar.close));
        assert!(bar.volume > 0);
        assert!(bar.close_time >= bar.open_time);
    }
}

#[test]
fn test_checkpoint_conditional_clone() {
    // R3: Verify checkpoint state is preserved correctly with both
    // include_incomplete=true and include_incomplete=false.
    let trades = scenarios::no_breach_sequence(250);

    // Test with include_incomplete=false (move, no clone)
    let mut processor1 = OpenDeviationBarProcessor::new(250).unwrap();
    let bars_without = processor1.process_agg_trade_records(&trades).unwrap();
    assert_eq!(bars_without.len(), 0);
    // Checkpoint should be preserved
    assert!(processor1.get_incomplete_bar().is_some());

    // Test with include_incomplete=true (clone + consume)
    let mut processor2 = OpenDeviationBarProcessor::new(250).unwrap();
    let bars_with = processor2
        .process_agg_trade_records_with_incomplete(&trades)
        .unwrap();
    assert_eq!(bars_with.len(), 1);
    // Checkpoint should ALSO be preserved (cloned before consume)
    assert!(processor2.get_incomplete_bar().is_some());

    // Both checkpoints should have identical bar content
    let cp1 = processor1.get_incomplete_bar().unwrap();
    let cp2 = processor2.get_incomplete_bar().unwrap();
    assert_eq!(cp1.open, cp2.open);
    assert_eq!(cp1.close, cp2.close);
    assert_eq!(cp1.high, cp2.high);
    assert_eq!(cp1.low, cp2.low);
}

#[test]
fn test_checkpoint_v1_to_v2_migration() {
    // Issue #85 Phase 2: Verify v1→v2 checkpoint migration
    // Simulate loading an old v1 checkpoint (without version field)
    let v1_json = r#"{
        "symbol": "BTCUSDT",
        "threshold_decimal_bps": 250,
        "incomplete_bar": null,
        "thresholds": null,
        "last_timestamp_us": 1640995200000000,
        "last_trade_id": 5000,
        "price_hash": 0,
        "anomaly_summary": {"gaps_detected": 0, "overlaps_detected": 0, "timestamp_anomalies": 0},
        "prevent_same_timestamp_close": true,
        "defer_open": false
    }"#;

    // Deserialize old v1 checkpoint
    let checkpoint: Checkpoint = serde_json::from_str(v1_json).unwrap();
    assert_eq!(
        checkpoint.version, 1,
        "Old checkpoints should default to v1"
    );
    assert_eq!(checkpoint.symbol, "BTCUSDT");
    assert_eq!(checkpoint.threshold_decimal_bps, 250);

    // Restore processor from v1 checkpoint (triggers migration)
    let mut processor = OpenDeviationBarProcessor::from_checkpoint(checkpoint).unwrap();

    // Verify processor is ready to continue
    assert!(
        !processor.get_incomplete_bar().is_some(),
        "No incomplete bar before processing"
    );

    // Process some trades to verify migration worked
    let trades = scenarios::single_breach_sequence(250);
    let bars = processor.process_agg_trade_records(&trades).unwrap();

    // Should produce a bar after migration
    assert!(
        !bars.is_empty(),
        "Should produce bars after v1→v2 migration"
    );
    // Verify bar has valid OHLCV (symbol is in Checkpoint, not OpenDeviationBar)
    assert!(bars[0].volume > 0, "Bar should have volume after migration");
    assert!(
        bars[0].close_time >= bars[0].open_time,
        "Bar times should be valid"
    );

    // Create new checkpoint from processor after migration
    let new_checkpoint = processor.create_checkpoint("BTCUSDT");
    assert_eq!(new_checkpoint.version, 2, "New checkpoints should be v2");
    assert_eq!(new_checkpoint.symbol, "BTCUSDT");

    // Verify new checkpoint can be serialized and deserialized
    let json = serde_json::to_string(&new_checkpoint).unwrap();
    let restored: Checkpoint = serde_json::from_str(&json).unwrap();
    assert_eq!(restored.version, 2);
    assert_eq!(restored.symbol, "BTCUSDT");
}

// =========================================================================
// Issue #96: Checkpoint error path tests
// =========================================================================

#[test]
fn test_from_checkpoint_invalid_threshold_zero() {
    let checkpoint = Checkpoint::new("BTCUSDT".to_string(), 0, None, None, 0, None, 0, true);
    match OpenDeviationBarProcessor::from_checkpoint(checkpoint) {
        Err(CheckpointError::InvalidThreshold { threshold: 0, .. }) => {}
        other => panic!("Expected InvalidThreshold(0), got {:?}", other.err()),
    }
}

#[test]
fn test_from_checkpoint_invalid_threshold_too_high() {
    let checkpoint = Checkpoint::new("BTCUSDT".to_string(), 200_000, None, None, 0, None, 0, true);
    match OpenDeviationBarProcessor::from_checkpoint(checkpoint) {
        Err(CheckpointError::InvalidThreshold {
            threshold: 200_000, ..
        }) => {}
        other => panic!("Expected InvalidThreshold(200000), got {:?}", other.err()),
    }
}

#[test]
fn test_from_checkpoint_missing_thresholds() {
    let bar = OpenDeviationBar::new(&test_utils::create_test_agg_trade(
        1, "50000.0", "1.0", 1000,
    ));
    let mut checkpoint = Checkpoint::new("BTCUSDT".to_string(), 250, None, None, 0, None, 0, true);
    checkpoint.incomplete_bar = Some(bar);
    checkpoint.thresholds = None;

    match OpenDeviationBarProcessor::from_checkpoint(checkpoint) {
        Err(CheckpointError::MissingThresholds) => {}
        other => panic!("Expected MissingThresholds, got {:?}", other.err()),
    }
}

#[test]
fn test_from_checkpoint_unknown_version_treated_as_v2() {
    let mut checkpoint = Checkpoint::new("BTCUSDT".to_string(), 250, None, None, 0, None, 0, true);
    checkpoint.version = 99;

    let processor = OpenDeviationBarProcessor::from_checkpoint(checkpoint).unwrap();
    assert_eq!(processor.threshold_decimal_bps(), 250);
}

#[test]
fn test_from_checkpoint_valid_with_incomplete_bar() {
    use opendeviationbar_core::fixed_point::FixedPoint;
    let bar = OpenDeviationBar::new(&test_utils::create_test_agg_trade(
        1, "50000.0", "1.0", 1000,
    ));
    let upper = FixedPoint::from_str("50125.0").unwrap();
    let lower = FixedPoint::from_str("49875.0").unwrap();

    let checkpoint = Checkpoint::new(
        "BTCUSDT".to_string(),
        250,
        Some(bar),
        Some((upper, lower)),
        0,
        None,
        0,
        true,
    );

    let processor = OpenDeviationBarProcessor::from_checkpoint(checkpoint).unwrap();
    assert!(
        processor.get_incomplete_bar().is_some(),
        "Should restore incomplete bar"
    );
}

// =========================================================================
// Issue #96: Ouroboros reset tests
// =========================================================================

#[test]
fn test_reset_at_ouroboros_with_orphan() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    // Feed trades to create incomplete bar
    let t1 = test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1000);
    let t2 = test_utils::create_test_agg_trade(2, "50050.0", "1.0", 2000);
    assert!(processor.process_single_trade(&t1).unwrap().is_none());
    assert!(processor.process_single_trade(&t2).unwrap().is_none());
    assert!(
        processor.get_incomplete_bar().is_some(),
        "Should have incomplete bar"
    );

    // Reset at ouroboros boundary - should return orphaned bar
    let orphan = processor.reset_at_ouroboros();
    assert!(orphan.is_some(), "Should return orphaned bar");
    let orphan_bar = orphan.unwrap();
    assert_eq!(orphan_bar.open.to_string(), "50000.00000000");

    // After reset, no incomplete bar
    assert!(
        processor.get_incomplete_bar().is_none(),
        "No bar after reset"
    );
}

#[test]
fn test_reset_at_ouroboros_clean_state() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    // Reset without any trades processed - should return None
    let orphan = processor.reset_at_ouroboros();
    assert!(orphan.is_none(), "No orphan when state is clean");
    assert!(processor.get_incomplete_bar().is_none());
}

#[test]
fn test_reset_at_ouroboros_clears_defer_open() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    // Create a breach to set defer_open = true
    let t1 = test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1000);
    let t2 = test_utils::create_test_agg_trade(2, "50200.0", "1.0", 2000); // +0.4% breach
    processor.process_single_trade(&t1).unwrap();
    let bar = processor.process_single_trade(&t2).unwrap();
    assert!(bar.is_some(), "Should breach");

    // After breach, defer_open is true - no incomplete bar
    assert!(processor.get_incomplete_bar().is_none());

    // Reset at ouroboros should clear defer_open
    processor.reset_at_ouroboros();

    // New trade should open fresh bar (defer_open was cleared)
    let t3 = test_utils::create_test_agg_trade(3, "50000.0", "1.0", 3000);
    processor.process_single_trade(&t3).unwrap();
    assert!(
        processor.get_incomplete_bar().is_some(),
        "Should have new bar after reset"
    );
}

// Issue #275: Orphan bars from reset_at_ouroboros must have computed microstructure
#[test]
fn test_reset_at_ouroboros_computes_duration_us() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    // Feed two trades with different timestamps to create an incomplete bar
    // with non-zero duration
    let t1 = test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1_000_000); // 1s in µs
    let t2 = test_utils::create_test_agg_trade(2, "50050.0", "1.0", 2_000_000); // 2s in µs
    processor.process_single_trade(&t1).unwrap();
    processor.process_single_trade(&t2).unwrap();

    // Reset at ouroboros boundary - orphan bar must have duration_us computed
    let orphan = processor.reset_at_ouroboros().expect("Should return orphaned bar");

    // duration_us = close_time - open_time = 2_000_000 - 1_000_000 = 1_000_000
    assert_eq!(
        orphan.duration_us, 1_000_000,
        "Orphan bar from reset_at_ouroboros must have computed duration_us (Issue #275)"
    );

    // trade_intensity should also be computed (not 0)
    assert!(
        orphan.trade_intensity > 0.0,
        "Orphan bar must have non-zero trade_intensity when duration > 0"
    );
}

// Issue #275: get_incomplete_bar must also compute microstructure features
#[test]
fn test_get_incomplete_bar_computes_duration_us() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    let t1 = test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1_000_000);
    let t2 = test_utils::create_test_agg_trade(2, "50050.0", "1.0", 2_000_000);
    processor.process_single_trade(&t1).unwrap();
    processor.process_single_trade(&t2).unwrap();

    let incomplete = processor.get_incomplete_bar().expect("Should have incomplete bar");
    assert_eq!(
        incomplete.duration_us, 1_000_000,
        "Incomplete bar from get_incomplete_bar must have computed duration_us (Issue #275)"
    );
    assert!(
        incomplete.trade_intensity > 0.0,
        "Incomplete bar must have non-zero trade_intensity when duration > 0"
    );
}

// === EDGE CASE TESTS (Issue #96 Task #21) ===

#[test]
fn test_single_trade_no_bar() {
    // A single trade cannot breach — no bar should be produced
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();
    let trade = test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1000);
    let bars = processor.process_agg_trade_records(&[trade]).unwrap();
    assert_eq!(
        bars.len(),
        0,
        "Single trade should not produce a completed bar"
    );
    assert!(
        processor.get_incomplete_bar().is_some(),
        "Should have incomplete bar"
    );
}

#[test]
fn test_identical_timestamps_no_close() {
    // Issue #36: Bar cannot close when breach tick has same timestamp as open
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();
    let t1 = test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1000);
    let t2 = test_utils::create_test_agg_trade(2, "50200.0", "1.0", 1000); // Same timestamp, breaches
    let bars = processor.process_agg_trade_records(&[t1, t2]).unwrap();
    assert_eq!(
        bars.len(),
        0,
        "Bar should not close on same timestamp as open (Issue #36)"
    );
}

#[test]
fn test_identical_timestamps_then_different_closes() {
    // Same-timestamp trades followed by different-timestamp breach should close
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();
    let t1 = test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1000);
    let t2 = test_utils::create_test_agg_trade(2, "50050.0", "1.0", 1000); // Same ts
    let t3 = test_utils::create_test_agg_trade(3, "50200.0", "1.0", 2000); // Different ts, breach
    let bars = processor.process_agg_trade_records(&[t1, t2, t3]).unwrap();
    assert_eq!(
        bars.len(),
        1,
        "Should close when breach at different timestamp"
    );
}

#[test]
fn test_streaming_defer_open_semantics() {
    // After breach via process_single_trade, next trade should open new bar
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();
    let t1 = test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1000);
    let t2 = test_utils::create_test_agg_trade(2, "50200.0", "1.0", 2000); // Breach
    let t3 = test_utils::create_test_agg_trade(3, "51000.0", "1.0", 3000); // Opens new bar

    processor.process_single_trade(&t1).unwrap();
    let bar = processor.process_single_trade(&t2).unwrap();
    assert!(bar.is_some(), "Trade 2 should cause a breach");

    // After breach, no incomplete bar (defer_open state)
    assert!(processor.get_incomplete_bar().is_none());

    // Next trade opens a fresh bar
    let bar2 = processor.process_single_trade(&t3).unwrap();
    assert!(bar2.is_none(), "Trade 3 should open new bar, not breach");
    let incomplete = processor.get_incomplete_bar().unwrap();
    assert_eq!(
        incomplete.open.to_f64(),
        51000.0,
        "New bar should open at t3 price"
    );
}

#[test]
fn test_process_empty_then_trades() {
    // Processing empty slice should be no-op, then normal processing works
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();
    let bars = processor.process_agg_trade_records(&[]).unwrap();
    assert_eq!(bars.len(), 0);
    assert!(processor.get_incomplete_bar().is_none());

    // Now process a real trade
    let trade = test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1000);
    let bars = processor.process_agg_trade_records(&[trade]).unwrap();
    assert_eq!(bars.len(), 0);
    assert!(processor.get_incomplete_bar().is_some());
}

#[test]
fn test_multiple_breaches_in_batch() {
    // Multiple bars should form from a batch with repeated breaches
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();
    let trades = vec![
        test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1000),
        test_utils::create_test_agg_trade(2, "50200.0", "1.0", 2000), // Breach 1
        test_utils::create_test_agg_trade(3, "50500.0", "1.0", 3000), // Opens bar 2
        test_utils::create_test_agg_trade(4, "50700.0", "1.0", 4000), // Breach 2
        test_utils::create_test_agg_trade(5, "51000.0", "1.0", 5000), // Opens bar 3
    ];
    let bars = processor.process_agg_trade_records(&trades).unwrap();
    assert_eq!(
        bars.len(),
        2,
        "Should produce 2 completed bars from 2 breaches"
    );
}

#[test]
fn test_streaming_batch_parity_extended() {
    // Task #29: Comprehensive streaming/batch parity with 20 trades producing 5+ bars
    // Uses 100 dbps (0.1%) threshold for more frequent breaches
    let threshold = 100;

    // Build a zigzag price sequence that repeatedly breaches 0.1%
    let mut trades = Vec::new();
    let mut price = 50000.0;
    for i in 0..20 {
        // Alternate up and down movements exceeding 0.1%
        if i % 3 == 0 && i > 0 {
            price *= 1.002; // +0.2% → breach upward
        } else if i % 3 == 1 && i > 1 {
            price *= 0.998; // -0.2% → may breach downward
        } else {
            price *= 1.0005; // Small move, no breach
        }
        trades.push(test_utils::create_test_agg_trade(
            (i + 1) as i64,
            &format!("{:.8}", price),
            "1.0",
            (i as i64 + 1) * 1000,
        ));
    }

    // === BATCH PATH ===
    let mut batch_processor = OpenDeviationBarProcessor::new(threshold).unwrap();
    let batch_bars = batch_processor.process_agg_trade_records(&trades).unwrap();

    // === STREAMING PATH ===
    let mut stream_processor = OpenDeviationBarProcessor::new(threshold).unwrap();
    let mut stream_bars: Vec<OpenDeviationBar> = Vec::new();
    for trade in &trades {
        if let Some(bar) = stream_processor.process_single_trade(trade).unwrap() {
            stream_bars.push(bar);
        }
    }

    // === VERIFY PARITY ===
    assert!(
        batch_bars.len() >= 3,
        "Should produce at least 3 bars from zigzag pattern"
    );
    assert_eq!(
        batch_bars.len(),
        stream_bars.len(),
        "Batch ({}) and streaming ({}) bar count mismatch",
        batch_bars.len(),
        stream_bars.len()
    );

    for (i, (b, s)) in batch_bars.iter().zip(stream_bars.iter()).enumerate() {
        assert_eq!(b.open, s.open, "Bar {i}: open mismatch");
        assert_eq!(b.close, s.close, "Bar {i}: close mismatch");
        assert_eq!(b.high, s.high, "Bar {i}: high mismatch");
        assert_eq!(b.low, s.low, "Bar {i}: low mismatch");
        assert_eq!(b.volume, s.volume, "Bar {i}: volume mismatch");
        assert_eq!(b.open_time, s.open_time, "Bar {i}: open_time mismatch");
        assert_eq!(b.close_time, s.close_time, "Bar {i}: close_time mismatch");
        assert_eq!(
            b.individual_trade_count, s.individual_trade_count,
            "Bar {i}: trade_count mismatch"
        );
    }

    // Verify incomplete bars match
    let batch_inc = batch_processor.get_incomplete_bar();
    let stream_inc = stream_processor.get_incomplete_bar();
    match (&batch_inc, &stream_inc) {
        (Some(b), Some(s)) => {
            assert_eq!(b.open, s.open, "Incomplete: open mismatch");
            assert_eq!(b.volume, s.volume, "Incomplete: volume mismatch");
        }
        (None, None) => {}
        _ => panic!("Incomplete bar presence mismatch"),
    }
}

#[test]
fn test_multi_batch_sequential_state_continuity() {
    // Send 3 separate batches, each producing 1+ bars
    // Verify state carries correctly across batch boundaries
    let mut processor = OpenDeviationBarProcessor::new(100).unwrap(); // 100 dbps = 0.10%
    let mut all_bars = Vec::new();

    // Batch 1: open at 50000, breach needs > 0.10% = price > 50050
    let batch1 = vec![
        test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1000),
        test_utils::create_test_agg_trade(2, "50020.0", "1.0", 2000),
        test_utils::create_test_agg_trade(3, "50060.0", "1.0", 3000), // Breach (>0.10%)
    ];
    let bars1 = processor.process_agg_trade_records(&batch1).unwrap();
    all_bars.extend(bars1);

    // Batch 2: next trade opens new bar, breach again
    let batch2 = vec![
        test_utils::create_test_agg_trade(4, "50100.0", "1.0", 4000), // Opens new bar
        test_utils::create_test_agg_trade(5, "50120.0", "1.0", 5000),
        test_utils::create_test_agg_trade(6, "50170.0", "1.0", 6000), // Breach (>0.10%)
    ];
    let bars2 = processor.process_agg_trade_records(&batch2).unwrap();
    all_bars.extend(bars2);

    // Batch 3: another new bar from fresh state
    let batch3 = vec![
        test_utils::create_test_agg_trade(7, "50200.0", "1.0", 7000), // Opens new bar
        test_utils::create_test_agg_trade(8, "50220.0", "1.0", 8000),
        test_utils::create_test_agg_trade(9, "50280.0", "1.0", 9000), // Breach (>0.10%)
    ];
    let bars3 = processor.process_agg_trade_records(&batch3).unwrap();
    all_bars.extend(bars3);

    // Should have produced at least 3 bars (one per batch boundary)
    assert!(
        all_bars.len() >= 3,
        "Expected at least 3 bars from 3 batches, got {}",
        all_bars.len()
    );

    // Timestamps must be strictly monotonic
    for i in 1..all_bars.len() {
        assert!(
            all_bars[i].close_time >= all_bars[i - 1].close_time,
            "Bar {i}: close_time {} < previous {}",
            all_bars[i].close_time,
            all_bars[i - 1].close_time
        );
    }

    // Trade IDs should be continuous across batches
    for i in 1..all_bars.len() {
        assert_eq!(
            all_bars[i].first_agg_trade_id,
            all_bars[i - 1].last_agg_trade_id + 1,
            "Bar {i}: trade ID gap (first={}, prev last={})",
            all_bars[i].first_agg_trade_id,
            all_bars[i - 1].last_agg_trade_id
        );
    }
}

// Issue #96 Task #93: Edge case tests for processor algorithm invariants

#[test]
fn test_same_timestamp_prevents_bar_close() {
    // Issue #36: Bar cannot close on same timestamp as it opened
    // new() defaults to prevent_same_timestamp_close=true
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    // All trades at same timestamp but price breaches threshold
    let trades: Vec<AggTrade> = (0..5)
        .map(|i| {
            let price_str = if i == 0 {
                "50000.0".to_string()
            } else {
                // Price far above threshold to trigger breach
                format!("{}.0", 50000 + (i + 1) * 200)
            };
            AggTrade {
                agg_trade_id: i as i64,
                price: FixedPoint::from_str(&price_str).unwrap(),
                volume: FixedPoint::from_str("1.0").unwrap(),
                first_trade_id: i as i64,
                last_trade_id: i as i64,
                timestamp: 1000000, // ALL same timestamp
                is_buyer_maker: false,
                is_best_match: None,
            }
        })
        .collect();

    let bars = processor.process_agg_trade_records(&trades).unwrap();
    // No bars should close because timestamp gate blocks it
    assert_eq!(
        bars.len(),
        0,
        "Same timestamp should prevent bar close (Issue #36)"
    );
}

#[test]
fn test_single_trade_incomplete_bar() {
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    let trade = AggTrade {
        agg_trade_id: 1,
        price: FixedPoint::from_str("50000.0").unwrap(),
        volume: FixedPoint::from_str("10.0").unwrap(),
        first_trade_id: 1,
        last_trade_id: 1,
        timestamp: 1000000,
        is_buyer_maker: false,
        is_best_match: None,
    };

    // Strict mode: 0 completed bars
    let bars = processor
        .process_agg_trade_records(&[trade.clone()])
        .unwrap();
    assert_eq!(bars.len(), 0, "Single trade cannot complete a bar");

    // With incomplete: should return 1 incomplete bar
    let mut processor2 = OpenDeviationBarProcessor::new(250).unwrap();
    let bars_incl = processor2
        .process_agg_trade_records_with_incomplete(&[trade])
        .unwrap();
    assert_eq!(bars_incl.len(), 1, "Should return 1 incomplete bar");
    assert_eq!(bars_incl[0].open, bars_incl[0].close);
    assert_eq!(bars_incl[0].high, bars_incl[0].low);
}

// === Issue #96: Configuration method coverage tests ===

#[test]
fn test_with_options_gate_disabled_same_timestamp_closes() {
    // Issue #36: with prevent_same_timestamp_close=false, bar should close
    // even when breach trade has same timestamp as open
    let mut processor = OpenDeviationBarProcessor::with_options(250, false).unwrap();
    assert!(!processor.prevent_same_timestamp_close());

    let trades = vec![
        AggTrade {
            agg_trade_id: 1,
            price: FixedPoint::from_str("50000.0").unwrap(),
            volume: FixedPoint::from_str("1.0").unwrap(),
            first_trade_id: 1,
            last_trade_id: 1,
            timestamp: 1000000,
            is_buyer_maker: false,
            is_best_match: None,
        },
        AggTrade {
            agg_trade_id: 2,
            price: FixedPoint::from_str("50200.0").unwrap(), // +0.4% > 0.25%
            volume: FixedPoint::from_str("1.0").unwrap(),
            first_trade_id: 2,
            last_trade_id: 2,
            timestamp: 1000000, // Same timestamp!
            is_buyer_maker: false,
            is_best_match: None,
        },
    ];
    let bars = processor.process_agg_trade_records(&trades).unwrap();
    assert_eq!(
        bars.len(),
        1,
        "Gate disabled: same-timestamp breach should close bar"
    );
}

#[test]
fn test_inter_bar_config_enables_features() {
    use opendeviationbar_core::interbar::LookbackMode;
    let processor = OpenDeviationBarProcessor::new(250).unwrap();
    assert!(
        !processor.inter_bar_enabled(),
        "Default: inter-bar disabled"
    );

    let processor = processor.with_inter_bar_config(InterBarConfig {
        lookback_mode: LookbackMode::FixedCount(100),
        compute_tier2: false,
        compute_tier3: false,
        ..Default::default()
    });
    assert!(
        processor.inter_bar_enabled(),
        "After config: inter-bar enabled"
    );
}

#[test]
fn test_intra_bar_feature_toggle() {
    let processor = OpenDeviationBarProcessor::new(250).unwrap();
    assert!(
        !processor.intra_bar_enabled(),
        "Default: intra-bar disabled"
    );

    let processor = processor.with_intra_bar_features();
    assert!(
        processor.intra_bar_enabled(),
        "After toggle: intra-bar enabled"
    );
}

#[test]
fn test_set_inter_bar_config_after_construction() {
    use opendeviationbar_core::interbar::LookbackMode;
    let mut processor = OpenDeviationBarProcessor::new(500).unwrap();
    assert!(!processor.inter_bar_enabled());

    processor.set_inter_bar_config(InterBarConfig {
        lookback_mode: LookbackMode::FixedCount(200),
        compute_tier2: true,
        compute_tier3: false,
        ..Default::default()
    });
    assert!(
        processor.inter_bar_enabled(),
        "set_inter_bar_config should enable"
    );
}

#[test]
fn test_process_with_options_incomplete_false_vs_true() {
    let trades = scenarios::single_breach_sequence(250);

    // Without incomplete: only completed bars
    let mut p1 = OpenDeviationBarProcessor::new(250).unwrap();
    let bars_strict = p1
        .process_agg_trade_records_with_options(&trades, false)
        .unwrap();

    // With incomplete: completed + 1 partial
    let mut p2 = OpenDeviationBarProcessor::new(250).unwrap();
    let bars_incl = p2
        .process_agg_trade_records_with_options(&trades, true)
        .unwrap();

    assert!(
        bars_incl.len() >= bars_strict.len(),
        "inclusive ({}) must be >= strict ({})",
        bars_incl.len(),
        bars_strict.len()
    );
}

// === Issue #96: Untested public method coverage ===

#[test]
fn test_anomaly_summary_default_no_anomalies() {
    let processor = OpenDeviationBarProcessor::new(250).unwrap();
    let summary = processor.anomaly_summary();
    assert_eq!(summary.gaps_detected, 0);
    assert_eq!(summary.overlaps_detected, 0);
    assert_eq!(summary.timestamp_anomalies, 0);
    assert!(!summary.has_anomalies());
    assert_eq!(summary.total(), 0);
}

#[test]
fn test_anomaly_summary_preserved_through_checkpoint() {
    // Process some trades, create checkpoint, restore, verify anomaly state
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();
    let trades = scenarios::single_breach_sequence(250);
    processor.process_agg_trade_records(&trades).unwrap();

    let checkpoint = processor.create_checkpoint("TEST");
    let restored = OpenDeviationBarProcessor::from_checkpoint(checkpoint).unwrap();
    let summary = restored.anomaly_summary();
    // Default processor has no anomalies; checkpoint preserves that
    assert_eq!(summary.total(), 0);
}

#[test]
fn test_anomaly_summary_from_checkpoint_with_anomalies() {
    // Deserialize a checkpoint that has anomaly data
    let json = r#"{
        "version": 3,
        "symbol": "TESTUSDT",
        "threshold_decimal_bps": 250,
        "prevent_same_timestamp_close": true,
        "defer_open": false,
        "current_bar": null,
        "thresholds": null,
        "last_timestamp_us": 1000000,
        "last_trade_id": 5,
        "price_hash": 0,
        "anomaly_summary": {"gaps_detected": 3, "overlaps_detected": 1, "timestamp_anomalies": 2}
    }"#;
    let checkpoint: opendeviationbar_core::checkpoint::Checkpoint =
        serde_json::from_str(json).unwrap();
    let processor = OpenDeviationBarProcessor::from_checkpoint(checkpoint).unwrap();
    let summary = processor.anomaly_summary();
    assert_eq!(summary.gaps_detected, 3);
    assert_eq!(summary.overlaps_detected, 1);
    assert_eq!(summary.timestamp_anomalies, 2);
    assert!(summary.has_anomalies());
    assert_eq!(summary.total(), 6);
}

#[test]
fn test_with_inter_bar_config_and_cache_shared() {
    use opendeviationbar_core::entropy_cache_global::get_global_entropy_cache;
    use opendeviationbar_core::interbar::LookbackMode;

    let global_cache = get_global_entropy_cache();
    let config = InterBarConfig {
        lookback_mode: LookbackMode::FixedCount(100),
        compute_tier2: true,
        compute_tier3: true,
        ..Default::default()
    };

    // Two processors sharing the same global cache
    let p1 = OpenDeviationBarProcessor::new(250)
        .unwrap()
        .with_inter_bar_config_and_cache(config.clone(), Some(global_cache.clone()));
    let p2 = OpenDeviationBarProcessor::new(500)
        .unwrap()
        .with_inter_bar_config_and_cache(config, Some(global_cache));

    assert!(p1.inter_bar_enabled());
    assert!(p2.inter_bar_enabled());
}

#[test]
fn test_set_inter_bar_config_with_cache_after_checkpoint() {
    use opendeviationbar_core::entropy_cache_global::get_global_entropy_cache;
    use opendeviationbar_core::interbar::LookbackMode;

    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();
    let trades = scenarios::single_breach_sequence(250);
    processor.process_agg_trade_records(&trades).unwrap();

    // Simulate checkpoint round-trip (inter-bar config not preserved)
    let checkpoint = processor.create_checkpoint("TEST");
    let mut restored = OpenDeviationBarProcessor::from_checkpoint(checkpoint).unwrap();
    assert!(
        !restored.inter_bar_enabled(),
        "Checkpoint does not preserve inter-bar config"
    );

    // Re-enable with shared cache
    let global_cache = get_global_entropy_cache();
    restored.set_inter_bar_config_with_cache(
        InterBarConfig {
            lookback_mode: LookbackMode::FixedCount(100),
            compute_tier2: false,
            compute_tier3: false,
            ..Default::default()
        },
        Some(global_cache),
    );
    assert!(
        restored.inter_bar_enabled(),
        "set_inter_bar_config_with_cache should re-enable"
    );
}

#[test]
fn test_threshold_decimal_bps_getter() {
    let p250 = OpenDeviationBarProcessor::new(250).unwrap();
    assert_eq!(p250.threshold_decimal_bps(), 250);

    let p1000 = OpenDeviationBarProcessor::new(1000).unwrap();
    assert_eq!(p1000.threshold_decimal_bps(), 1000);
}

// =========================================================================
// Issue #112: Gap-aware checkpoint recovery tests
// =========================================================================

#[test]
fn test_checkpoint_gap_discards_forming_bar() {
    // Process some trades, checkpoint, then resume with a 2-hour gap
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    // Create trades that don't breach (stay within 0.25%)
    let trades = vec![
        test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1640995200_000_000), // t=0
        test_utils::create_test_agg_trade(2, "50010.0", "1.0", 1640995201_000_000), // t=+1s
        test_utils::create_test_agg_trade(3, "50020.0", "1.0", 1640995202_000_000), // t=+2s
    ];

    let bars = processor.process_agg_trade_records(&trades).unwrap();
    assert_eq!(bars.len(), 0, "No breach = no completed bars");

    // Create checkpoint (should have forming bar)
    let checkpoint = processor.create_checkpoint("BTCUSDT");
    assert!(checkpoint.has_incomplete_bar(), "Should have forming bar");

    // Resume from checkpoint
    let mut restored = OpenDeviationBarProcessor::from_checkpoint(checkpoint).unwrap();

    // Feed trades with 2-hour gap (7,200,000,000 μs > 1-hour default max_gap)
    let gap_trades = vec![
        test_utils::create_test_agg_trade(4, "50030.0", "1.0", 1641002402_000_000), // +2h gap
        test_utils::create_test_agg_trade(5, "50040.0", "1.0", 1641002403_000_000),
    ];

    let bars = restored.process_agg_trade_records(&gap_trades).unwrap();
    // Forming bar should have been discarded, no oversized bar emitted
    assert_eq!(
        bars.len(),
        0,
        "No bars should complete — forming bar was discarded"
    );
    assert_eq!(
        restored.anomaly_summary().gaps_detected,
        1,
        "Gap should be recorded in anomaly summary"
    );
}

#[test]
fn test_checkpoint_small_gap_continues_bar() {
    // Resume with a gap UNDER the threshold — bar should continue
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    let trades = vec![
        test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1640995200_000_000),
        test_utils::create_test_agg_trade(2, "50010.0", "1.0", 1640995201_000_000),
    ];

    let _ = processor.process_agg_trade_records(&trades).unwrap();
    let checkpoint = processor.create_checkpoint("BTCUSDT");
    let mut restored = OpenDeviationBarProcessor::from_checkpoint(checkpoint).unwrap();

    // Feed trades with 30-minute gap (1,800,000,000 μs < 1-hour max_gap)
    let small_gap_trades = vec![
        test_utils::create_test_agg_trade(3, "50020.0", "1.0", 1640997001_000_000), // +30m
        test_utils::create_test_agg_trade(4, "50125.01", "1.0", 1640997002_000_000), // breach
    ];

    let bars = restored
        .process_agg_trade_records(&small_gap_trades)
        .unwrap();
    assert_eq!(bars.len(), 1, "Bar should complete normally with small gap");
    // Bar should span from original open (trade 1) through gap trades
    assert_eq!(bars[0].open_time, 1640995200_000_000);
    assert_eq!(
        restored.anomaly_summary().gaps_detected,
        0,
        "No gap anomaly for small gap"
    );
}

#[test]
fn test_checkpoint_gap_custom_max_gap() {
    // Test with custom max_gap_us set to 30 minutes
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    let trades = vec![test_utils::create_test_agg_trade(
        1,
        "50000.0",
        "1.0",
        1640995200_000_000,
    )];
    let _ = processor.process_agg_trade_records(&trades).unwrap();
    let checkpoint = processor.create_checkpoint("BTCUSDT");

    // Restore with custom 30-minute max gap
    let mut restored = OpenDeviationBarProcessor::from_checkpoint(checkpoint)
        .unwrap()
        .with_max_gap(1_800_000_000); // 30 minutes

    // 45-minute gap should discard with 30-min threshold
    let gap_trades = vec![
        test_utils::create_test_agg_trade(2, "50010.0", "1.0", 1640997900_000_000), // +45min
    ];

    let _ = restored.process_agg_trade_records(&gap_trades).unwrap();
    assert_eq!(
        restored.anomaly_summary().gaps_detected,
        1,
        "45-min gap should be detected with 30-min threshold"
    );
}

#[test]
fn test_is_valid_range_rejects_oversized() {
    use opendeviationbar_core::fixed_point::FixedPoint;

    let threshold_decimal_bps: u32 = 250; // 0.25%
    let threshold_ratio = ((threshold_decimal_bps as i64)
        * opendeviationbar_core::fixed_point::SCALE)
        / (opendeviationbar_core::fixed_point::BASIS_POINTS_SCALE as i64);

    // Bar with 0.50% range — exceeds 2x threshold (2 * 0.25% = 0.50%)
    // open=50000.0, high=50250.01, low=50000.0 → range=250.01/50000 ≈ 0.5%+
    let mut oversized = OpenDeviationBar::default();
    oversized.open = FixedPoint::from_str("50000.0").unwrap();
    oversized.high = FixedPoint::from_str("50250.01").unwrap();
    oversized.low = FixedPoint::from_str("50000.0").unwrap();
    assert!(
        !oversized.is_valid_range(threshold_ratio, 2),
        "Bar exceeding 2x threshold should be invalid"
    );

    // Bar with 0.20% range — within threshold
    let mut valid = OpenDeviationBar::default();
    valid.open = FixedPoint::from_str("50000.0").unwrap();
    valid.high = FixedPoint::from_str("50100.0").unwrap();
    valid.low = FixedPoint::from_str("50000.0").unwrap();
    assert!(
        valid.is_valid_range(threshold_ratio, 2),
        "Bar within threshold should be valid"
    );

    // Bar at exact breach boundary (0.25%) — should be valid (breach triggers AT threshold)
    let mut exact = OpenDeviationBar::default();
    exact.open = FixedPoint::from_str("50000.0").unwrap();
    exact.high = FixedPoint::from_str("50125.0").unwrap();
    exact.low = FixedPoint::from_str("50000.0").unwrap();
    assert!(
        exact.is_valid_range(threshold_ratio, 2),
        "Bar at exact threshold should be valid"
    );
}

#[test]
fn test_checkpoint_no_incomplete_bar_gap_is_noop() {
    // If checkpoint has no forming bar, gap detection is irrelevant
    let mut processor = OpenDeviationBarProcessor::new(250).unwrap();

    // Process trades that complete a bar (produce breach)
    let trades = vec![
        test_utils::create_test_agg_trade(1, "50000.0", "1.0", 1640995200_000_000),
        test_utils::create_test_agg_trade(2, "50200.0", "1.0", 1640995201_000_000), // breach
    ];
    let bars = processor.process_agg_trade_records(&trades).unwrap();
    assert_eq!(bars.len(), 1);

    let checkpoint = processor.create_checkpoint("BTCUSDT");
    assert!(!checkpoint.has_incomplete_bar());

    let mut restored = OpenDeviationBarProcessor::from_checkpoint(checkpoint).unwrap();

    // Large gap doesn't matter — no forming bar to discard
    let gap_trades = vec![
        test_utils::create_test_agg_trade(3, "50010.0", "1.0", 1641081600_000_000), // +24h
    ];
    let _ = restored.process_agg_trade_records(&gap_trades).unwrap();
    assert_eq!(
        restored.anomaly_summary().gaps_detected,
        0,
        "No gap anomaly when no forming bar exists"
    );
}
