//! Integration tests for order management: stop-loss, take-profit, trailing
//! stops, limit entries, and bracket orders.

use std::collections::HashMap;
use std::sync::Arc;

use arrow::array::{
    ArrayRef, BooleanArray, Float64Array, TimestampNanosecondArray, UInt32Array, UInt8Array,
};
use arrow::record_batch::RecordBatch;
use bt_core::{
    BacktestConfig, BacktestRunner, BarInterval, ExecutionConfig, ExecutionPrice, FeeConfig,
    FillModel, LimitOrderConfig, OrderConfig, PositionSizingMode, SimpleBacktestRunner,
    SlippageConfig, StopLossConfig, StrategyMetadata, StrategyPlan, TakeProfitConfig, TimeInForce,
    TrailingStopConfig,
};
use bt_data::{DataStore, DatasetVersion, WriteOptions};
use bt_expr::{compile_expr, Expr, ScalarValue};
use bt_kernel::{BtError, Result, SymbolId, TimeRange, Timestamp};

// ---------------------------------------------------------------------------
// Mock data store
// ---------------------------------------------------------------------------

struct MockStore {
    bars: RecordBatch,
}

impl MockStore {
    fn new(bars: RecordBatch) -> Self {
        Self { bars }
    }
}

impl DataStore for MockStore {
    fn load_bars(
        &self,
        _symbol: SymbolId,
        _range: TimeRange,
        _version: Option<&str>,
    ) -> Result<RecordBatch> {
        Ok(self.bars.clone())
    }

    fn load_features(
        &self,
        _feature_set: &str,
        _symbol: SymbolId,
        _range: TimeRange,
        _columns: Option<&[&str]>,
    ) -> Result<RecordBatch> {
        Err(BtError::Unsupported("test mock".to_string()))
    }

    fn load_exo(&self, _name: &str, _range: TimeRange) -> Result<RecordBatch> {
        Err(BtError::Unsupported("test mock".to_string()))
    }

    fn list_versions(&self, _dataset: &str) -> Result<Vec<DatasetVersion>> {
        Ok(vec![mock_version()])
    }

    fn active_version(&self, _dataset: &str) -> Result<DatasetVersion> {
        Ok(mock_version())
    }

    fn write_bars(
        &self,
        _symbol: SymbolId,
        _batch: &RecordBatch,
        _opts: WriteOptions,
    ) -> Result<DatasetVersion> {
        Err(BtError::Unsupported("test mock".to_string()))
    }
}

fn mock_version() -> DatasetVersion {
    DatasetVersion {
        id: "v_test".to_string(),
        dataset: "bars_1m".to_string(),
        created_at: Timestamp::from_nanos(1),
        schema_hash: "hash".to_string(),
        row_count: 0,
        time_range: TimeRange::new(
            Timestamp::from_nanos(0),
            Timestamp::from_nanos(6_000_000_000_000),
        )
        .expect("valid range"),
        symbols: vec![SymbolId(1)],
        source_hash: "source".to_string(),
        metadata: serde_json::json!({}),
    }
}

// ---------------------------------------------------------------------------
// OHLC bar builder
// ---------------------------------------------------------------------------

/// Create bars with explicit OHLC values per bar.
///
/// Each tuple: (open, high, low, close).
/// Timestamps start at 0, spaced 1 second apart.
/// Volume = 100.0, trade_count = 10 for every bar.
fn sample_bars_ohlc(ohlc: &[(f64, f64, f64, f64)]) -> RecordBatch {
    let n = ohlc.len();
    let schema = bt_data::schema::canonical_bars_1m_schema();

    let timestamps: Vec<i64> = (0..n).map(|i| i as i64 * 60_000_000_000).collect();
    let symbol_ids = vec![1_u32; n];
    let opens: Vec<f64> = ohlc.iter().map(|b| b.0).collect();
    let highs: Vec<f64> = ohlc.iter().map(|b| b.1).collect();
    let lows: Vec<f64> = ohlc.iter().map(|b| b.2).collect();
    let closes: Vec<f64> = ohlc.iter().map(|b| b.3).collect();
    // vwap = average of OHLC
    let vwaps: Vec<f64> = ohlc.iter().map(|b| (b.0 + b.1 + b.2 + b.3) / 4.0).collect();
    let volumes = vec![100.0_f64; n];
    let buy_vol: Vec<Option<f64>> = vec![Some(50.0); n];
    let sell_vol: Vec<Option<f64>> = vec![Some(50.0); n];
    let trade_counts = vec![10_u32; n];
    let bids: Vec<Option<f64>> = closes.iter().map(|c| Some(c - 0.1)).collect();
    let asks: Vec<Option<f64>> = closes.iter().map(|c| Some(c + 0.1)).collect();
    let spreads: Vec<Option<f64>> = vec![Some(0.2); n];
    let is_gaps = vec![false; n];
    let gap_fills = vec![0_u8; n];

    let arrays: Vec<ArrayRef> = vec![
        Arc::new(TimestampNanosecondArray::from(timestamps).with_timezone("UTC")),
        Arc::new(UInt32Array::from(symbol_ids)),
        Arc::new(Float64Array::from(opens)),
        Arc::new(Float64Array::from(highs)),
        Arc::new(Float64Array::from(lows)),
        Arc::new(Float64Array::from(closes)),
        Arc::new(Float64Array::from(vwaps)),
        Arc::new(Float64Array::from(volumes)),
        Arc::new(Float64Array::from(buy_vol)),
        Arc::new(Float64Array::from(sell_vol)),
        Arc::new(UInt32Array::from(trade_counts)),
        Arc::new(Float64Array::from(bids)),
        Arc::new(Float64Array::from(asks)),
        Arc::new(Float64Array::from(spreads)),
        Arc::new(BooleanArray::from(is_gaps)),
        Arc::new(UInt8Array::from(gap_fills)),
    ];

    RecordBatch::try_new(schema, arrays).expect("valid OHLC batch")
}

// ---------------------------------------------------------------------------
// Strategy helpers
// ---------------------------------------------------------------------------

/// Strategy that goes long 1 unit on bar 0 (signal=1.0), then flat on every bar.
/// With signal_delay=1, entry fills on bar 1 and the position is held from bar 1 onward.
fn strategy_always_long() -> StrategyPlan {
    let signal = compile_expr(Expr::Literal(ScalarValue::Float64(1.0))).expect("compile signal");
    let sizing = compile_expr(Expr::Column("signal".to_string())).expect("compile sizing");
    let mut signals = HashMap::new();
    signals.insert("signal".to_string(), signal);
    StrategyPlan {
        name: "always_long".to_string(),
        signals,
        position_sizing: sizing,
        required_columns: vec!["close".to_string()],
        required_features: Vec::new(),
        parameters: HashMap::new(),
        metadata: StrategyMetadata::default(),
        raw_signals: None,
        raw_position_sizing: None,
        has_dynamic_periods: false,
    }
}

/// Strategy that goes short 1 unit.
fn strategy_always_short() -> StrategyPlan {
    let signal = compile_expr(Expr::Literal(ScalarValue::Float64(-1.0))).expect("compile signal");
    let sizing = compile_expr(Expr::Column("signal".to_string())).expect("compile sizing");
    let mut signals = HashMap::new();
    signals.insert("signal".to_string(), signal);
    StrategyPlan {
        name: "always_short".to_string(),
        signals,
        position_sizing: sizing,
        required_columns: vec!["close".to_string()],
        required_features: Vec::new(),
        parameters: HashMap::new(),
        metadata: StrategyMetadata::default(),
        raw_signals: None,
        raw_position_sizing: None,
        has_dynamic_periods: false,
    }
}

/// Strategy that emits signal = 1.0 on first bar, then 0.0 (flat).
/// This relies on the bar index — we use a simple "if close < threshold" trick.
/// For our tests: threshold = 101.0 means signal=1 when close<101, 0 otherwise.
fn strategy_entry_then_flat(threshold: f64) -> StrategyPlan {
    // signal = if close < threshold then 1.0 else 0.0
    let signal = compile_expr(Expr::IfElse(
        Box::new(Expr::Lt(
            Box::new(Expr::Column("close".to_string())),
            Box::new(Expr::Literal(ScalarValue::Float64(threshold))),
        )),
        Box::new(Expr::Literal(ScalarValue::Float64(1.0))),
        Box::new(Expr::Literal(ScalarValue::Float64(0.0))),
    ))
    .expect("compile conditional signal");
    let sizing = compile_expr(Expr::Column("signal".to_string())).expect("compile sizing");
    let mut signals = HashMap::new();
    signals.insert("signal".to_string(), signal);
    StrategyPlan {
        name: "entry_then_flat".to_string(),
        signals,
        position_sizing: sizing,
        required_columns: vec!["close".to_string()],
        required_features: Vec::new(),
        parameters: HashMap::new(),
        metadata: StrategyMetadata::default(),
        raw_signals: None,
        raw_position_sizing: None,
        has_dynamic_periods: false,
    }
}

// ---------------------------------------------------------------------------
// Config helpers
// ---------------------------------------------------------------------------

fn base_config(orders: OrderConfig) -> BacktestConfig {
    BacktestConfig {
        universe: vec![SymbolId(1)],
        time_range: TimeRange::new(
            Timestamp::from_nanos(0),
            Timestamp::from_nanos(6_000_000_000_000),
        )
        .expect("valid range"),
        bar_interval: BarInterval::Minutes(1),
        initial_capital: 10_000.0,
        currency: "USD".to_string(),
        risk_free_rate: 0.025,
        execution: ExecutionConfig {
            signal_delay: 1,
            execution_price: ExecutionPrice::AtClose,
            max_position_pct: 1.0,
            allow_short: true,
            allow_fractional: true,
            skip_gap_bars: false,
            position_sizing_mode: PositionSizingMode::Units,
            fill_model: FillModel::default(),
            orders,
            pyramiding: false,
        },
        fees: FeeConfig {
            taker_fee_bps: 0.0,
            ..FeeConfig::default()
        },
        slippage: SlippageConfig::FixedBps { bps: 0.0 },
        data_version: None,
        rng_seed: Some(42),
        trading_days_per_year: 365.25,
        output_resolution: Some(BarInterval::Minutes(1)),
        resample_to: None,
        feature_sets: vec![],
        symbol_names: HashMap::new(),
        warmup_bars: 0,
    }
}

/// Helper: extract exit_reason column from trades RecordBatch as Vec<u8>.
fn get_exit_reasons(trades: &RecordBatch) -> Vec<u8> {
    let col = trades
        .column_by_name("exit_reason")
        .expect("exit_reason column must exist");
    let arr = col.as_any().downcast_ref::<UInt8Array>().expect("UInt8");
    (0..arr.len()).map(|i| arr.value(i)).collect()
}

/// Helper: extract fill_price column from trades RecordBatch as Vec<f64>.
fn get_fill_prices(trades: &RecordBatch) -> Vec<f64> {
    let col = trades
        .column_by_name("fill_price")
        .expect("fill_price column");
    let arr = col
        .as_any()
        .downcast_ref::<Float64Array>()
        .expect("Float64");
    (0..arr.len()).map(|i| arr.value(i)).collect()
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

/// Long position, bar low hits the stop-loss → exit at SL price.
///
/// Bars:
///   bar0: O=100 H=101 L=99 C=100  (signal emitted here, delay=1)
///   bar1: O=100 H=101 L=99 C=100  (entry fills at close=100)
///   bar2: O=100 H=102 L=97 C=99   (low=97 <= SL=98 → SL triggered)
///   bar3: O=99  H=100 L=98 C=99
///
/// SL = 2% → trigger at 100 * 0.98 = 98.0
/// Since low=97 < 98, SL fires. Fill at stop price = 98.0.
#[test]
fn stop_loss_long_triggers_on_low() {
    let bars = sample_bars_ohlc(&[
        (100.0, 101.0, 99.0, 100.0),
        (100.0, 101.0, 99.0, 100.0),
        (100.0, 102.0, 97.0, 99.0),
        (99.0, 100.0, 98.0, 99.0),
    ]);
    let store = MockStore::new(bars);
    let config = base_config(OrderConfig {
        stop_loss: Some(StopLossConfig { stop_pct: 2.0 }),
        ..Default::default()
    });
    let runner = SimpleBacktestRunner::new();
    let result = runner
        .run(&strategy_always_long(), &config, &store)
        .expect("run");

    let trades = &result.trades;
    assert!(
        trades.num_rows() >= 2,
        "expect entry + SL exit, got {}",
        trades.num_rows()
    );

    let reasons = get_exit_reasons(trades);
    let fills = get_fill_prices(trades);

    // First trade = entry (signal-driven), reason = Signal (0)
    assert_eq!(reasons[0], 0, "entry should be Signal");
    // Second trade = SL exit, reason = StopLoss (1)
    assert_eq!(reasons[1], 1, "exit should be StopLoss");
    // SL fill price should be 98.0
    assert!(
        (fills[1] - 98.0).abs() < 0.01,
        "SL fill price should be ~98.0, got {}",
        fills[1]
    );
}

/// Short position, bar high hits the stop-loss → exit at SL price.
///
/// Bars:
///   bar0: O=100 H=101 L=99 C=100  (signal: -1, delay=1)
///   bar1: O=100 H=101 L=99 C=100  (entry short at close=100)
///   bar2: O=100 H=103 L=99 C=102  (high=103 >= SL=102 → SL triggered)
///   bar3: O=102 H=103 L=101 C=102
///
/// SL = 2% → short entry=100, stop at 100*1.02 = 102.0.
/// Bar2 high=103 >= 102 → SL fires at 102.0.
#[test]
fn stop_loss_short_triggers_on_high() {
    let bars = sample_bars_ohlc(&[
        (100.0, 101.0, 99.0, 100.0),
        (100.0, 101.0, 99.0, 100.0),
        (100.0, 103.0, 99.0, 102.0),
        (102.0, 103.0, 101.0, 102.0),
    ]);
    let store = MockStore::new(bars);
    let config = base_config(OrderConfig {
        stop_loss: Some(StopLossConfig { stop_pct: 2.0 }),
        ..Default::default()
    });
    let runner = SimpleBacktestRunner::new();
    let result = runner
        .run(&strategy_always_short(), &config, &store)
        .expect("run");

    let reasons = get_exit_reasons(&result.trades);
    let fills = get_fill_prices(&result.trades);

    // Find the SL exit
    let sl_idx = reasons
        .iter()
        .position(|&r| r == 1)
        .expect("should have SL exit");
    assert!(
        (fills[sl_idx] - 102.0).abs() < 0.01,
        "short SL fill should be ~102.0, got {}",
        fills[sl_idx]
    );
}

/// Long position, bar high hits take-profit → exit at TP price.
///
/// Bars:
///   bar0: O=100 H=101 L=99 C=100  (signal, delay=1)
///   bar1: O=100 H=101 L=99 C=100  (entry at close=100)
///   bar2: O=101 H=106 L=100 C=105 (high=106 >= TP=105 → TP triggered)
///   bar3: O=105 H=106 L=104 C=105
///
/// TP = 5% → trigger at 100 * 1.05 = 105.0.
#[test]
fn take_profit_long_triggers_on_high() {
    let bars = sample_bars_ohlc(&[
        (100.0, 101.0, 99.0, 100.0),
        (100.0, 101.0, 99.0, 100.0),
        (101.0, 106.0, 100.0, 105.0),
        (105.0, 106.0, 104.0, 105.0),
    ]);
    let store = MockStore::new(bars);
    let config = base_config(OrderConfig {
        take_profit: Some(TakeProfitConfig { profit_pct: 5.0 }),
        ..Default::default()
    });
    let runner = SimpleBacktestRunner::new();
    let result = runner
        .run(&strategy_always_long(), &config, &store)
        .expect("run");

    let reasons = get_exit_reasons(&result.trades);
    let fills = get_fill_prices(&result.trades);

    let tp_idx = reasons
        .iter()
        .position(|&r| r == 2)
        .expect("should have TP exit");
    assert!(
        (fills[tp_idx] - 105.0).abs() < 0.01,
        "TP fill should be ~105.0, got {}",
        fills[tp_idx]
    );
}

/// Short position, bar low hits take-profit → exit at TP price.
///
/// Bars:
///   bar0: O=100 H=101 L=99 C=100  (signal -1, delay=1)
///   bar1: O=100 H=101 L=99 C=100  (entry short at 100)
///   bar2: O=99  H=100 L=94 C=95   (low=94 <= TP=95 → TP triggered)
///   bar3: O=95  H=96  L=94 C=95
///
/// TP = 5% → short entry=100, tp at 100*0.95 = 95.0.
#[test]
fn take_profit_short_triggers_on_low() {
    let bars = sample_bars_ohlc(&[
        (100.0, 101.0, 99.0, 100.0),
        (100.0, 101.0, 99.0, 100.0),
        (99.0, 100.0, 94.0, 95.0),
        (95.0, 96.0, 94.0, 95.0),
    ]);
    let store = MockStore::new(bars);
    let config = base_config(OrderConfig {
        take_profit: Some(TakeProfitConfig { profit_pct: 5.0 }),
        ..Default::default()
    });
    let runner = SimpleBacktestRunner::new();
    let result = runner
        .run(&strategy_always_short(), &config, &store)
        .expect("run");

    let reasons = get_exit_reasons(&result.trades);
    let fills = get_fill_prices(&result.trades);

    let tp_idx = reasons
        .iter()
        .position(|&r| r == 2)
        .expect("should have TP exit");
    assert!(
        (fills[tp_idx] - 95.0).abs() < 0.01,
        "short TP fill should be ~95.0, got {}",
        fills[tp_idx]
    );
}

/// Both SL and TP are breached in the same bar → SL wins (conservative).
///
/// Bars:
///   bar0: O=100 H=101 L=99 C=100
///   bar1: O=100 H=101 L=99 C=100  (entry at 100)
///   bar2: O=100 H=106 L=97 C=100  (high=106 >= TP=105 AND low=97 <= SL=98)
///   bar3: O=100 H=101 L=99 C=100
///
/// SL=2% → 98.0, TP=5% → 105.0.  Both triggered → SL prioritaire.
#[test]
fn sl_wins_over_tp_same_bar() {
    let bars = sample_bars_ohlc(&[
        (100.0, 101.0, 99.0, 100.0),
        (100.0, 101.0, 99.0, 100.0),
        (100.0, 106.0, 97.0, 100.0),
        (100.0, 101.0, 99.0, 100.0),
    ]);
    let store = MockStore::new(bars);
    let config = base_config(OrderConfig {
        stop_loss: Some(StopLossConfig { stop_pct: 2.0 }),
        take_profit: Some(TakeProfitConfig { profit_pct: 5.0 }),
        ..Default::default()
    });
    let runner = SimpleBacktestRunner::new();
    let result = runner
        .run(&strategy_always_long(), &config, &store)
        .expect("run");

    let reasons = get_exit_reasons(&result.trades);

    // The exit should be SL (1), not TP (2), because SL prioritaire.
    let exit_reason = reasons.iter().find(|&&r| r == 1 || r == 2);
    assert_eq!(
        exit_reason,
        Some(&1_u8),
        "SL should win over TP on same bar, got reasons: {:?}",
        reasons
    );
}

/// Trailing stop: price rises, trail ratchets up, then price falls → triggered.
///
/// Bars:
///   bar0: O=100 H=101 L=99  C=100  (signal, delay=1)
///   bar1: O=100 H=101 L=99  C=100  (entry at 100, trailing=97.0)
///   bar2: O=101 H=110 L=100 C=109  (hwm→110, trail→110*0.97=106.7)
///   bar3: O=109 H=110 L=106 C=107  (low=106 < trail=106.7 → triggered)
///
/// Trail = 3%, initial trail = 100*0.97 = 97.0.
/// After bar2: hwm = 110, trail = 110*0.97 = 106.7.
/// Bar3: low = 106 <= 106.7 → triggered at 106.7.
#[test]
fn trailing_stop_ratchets_and_triggers() {
    let bars = sample_bars_ohlc(&[
        (100.0, 101.0, 99.0, 100.0),
        (100.0, 101.0, 99.0, 100.0),
        (101.0, 110.0, 100.0, 109.0),
        (109.0, 110.0, 106.0, 107.0),
    ]);
    let store = MockStore::new(bars);
    let config = base_config(OrderConfig {
        trailing_stop: Some(TrailingStopConfig {
            trail_pct: 3.0,
            use_high: true,
        }),
        ..Default::default()
    });
    let runner = SimpleBacktestRunner::new();
    let result = runner
        .run(&strategy_always_long(), &config, &store)
        .expect("run");

    let reasons = get_exit_reasons(&result.trades);

    let trail_exit = reasons.iter().position(|&r| r == 3);
    assert!(
        trail_exit.is_some(),
        "trailing stop should trigger, reasons: {:?}",
        reasons
    );

    // Verify fill price is the trailing stop level
    let fills = get_fill_prices(&result.trades);
    let trail_idx = trail_exit.unwrap();
    let expected_trail = 110.0 * 0.97; // 106.7
    assert!(
        (fills[trail_idx] - expected_trail).abs() < 0.1,
        "trailing stop fill should be ~{}, got {}",
        expected_trail,
        fills[trail_idx]
    );
}

/// Limit entry: buy order fills when bar low <= limit price.
///
/// With signal_delay=1, signal on bar 0 creates the order on bar 1.
/// Limit price = bar1_close * (1 - offset_bps/10000).
///
/// Bars:
///   bar0: O=100 H=101 L=99 C=100  (signal=1, delayed to bar 1)
///   bar1: O=100 H=101 L=99 C=100  (order: limit=100*0.995=99.5, low=99<=99.5 → fills)
///   bar2: O=101 H=102 L=100 C=101
///   bar3: O=101 H=102 L=100 C=101
///
/// offset_bps = 50 → limit = 100 * 0.995 = 99.5. Low=99 triggers it.
#[test]
fn limit_entry_fills_below_close() {
    let bars = sample_bars_ohlc(&[
        (100.0, 101.0, 99.0, 100.0),
        (100.0, 101.0, 99.0, 100.0),
        (101.0, 102.0, 100.0, 101.0),
        (101.0, 102.0, 100.0, 101.0),
    ]);
    let store = MockStore::new(bars);
    let config = base_config(OrderConfig {
        limit_entry: Some(LimitOrderConfig {
            offset_bps: 50.0,
            time_in_force: TimeInForce::GTC,
        }),
        ..Default::default()
    });
    let runner = SimpleBacktestRunner::new();
    let result = runner
        .run(&strategy_always_long(), &config, &store)
        .expect("run");

    let trades = &result.trades;
    assert!(trades.num_rows() >= 1, "should have at least 1 trade");

    let fills = get_fill_prices(trades);
    // Limit price = bar1 close * (1 - 50/10000) = 100 * 0.995 = 99.5
    assert!(
        (fills[0] - 99.5).abs() < 0.01,
        "limit fill should be ~99.5, got {}",
        fills[0]
    );
}

/// GTB(2) limit entry: price never reaches limit → expires after 2 bars, no fill.
///
/// Uses a one-shot strategy (signal=1 only when close < 100.5) so the pending
/// order is created once and NOT overwritten on subsequent bars.
///
/// Bars:
///   bar0: O=100 H=101 L=99  C=100  (close<100.5 → signal=1, delayed to bar 1)
///   bar1: O=101 H=102 L=100 C=101  (close>=100.5 → signal=0; order created,
///         limit=101*0.98=98.98, low=100>98.98 → no fill, bars_alive=1)
///   bar2: O=101 H=103 L=100 C=102  (low=100>98.98 → no fill, bars_alive=2 → GTB expires)
///   bar3: O=102 H=104 L=101 C=103
///
/// offset_bps = 200 → limit = 101 * 0.98 = 98.98. GTB(2) → cancel on bar 2.
#[test]
fn limit_entry_gtb_expires() {
    let bars = sample_bars_ohlc(&[
        (100.0, 101.0, 99.0, 100.0),
        (101.0, 102.0, 100.0, 101.0),
        (101.0, 103.0, 100.0, 102.0),
        (102.0, 104.0, 101.0, 103.0),
    ]);
    let store = MockStore::new(bars);
    let config = base_config(OrderConfig {
        limit_entry: Some(LimitOrderConfig {
            offset_bps: 200.0,
            time_in_force: TimeInForce::GTB(2),
        }),
        ..Default::default()
    });
    let runner = SimpleBacktestRunner::new();
    let result = runner
        .run(&strategy_entry_then_flat(100.5), &config, &store)
        .expect("run");

    // No fills should happen — limit never triggered and GTB expired.
    assert_eq!(
        result.trades.num_rows(),
        0,
        "no trades expected for expired limit, got {}",
        result.trades.num_rows()
    );
}

/// IOC limit entry: not triggered on arrival bar → immediately cancelled.
///
/// Uses a one-shot strategy so the pending order isn't recreated.
///
/// Bars:
///   bar0: O=100 H=101 L=99  C=100  (close<100.5 → signal=1, delayed to bar 1)
///   bar1: O=101 H=102 L=100 C=101  (close>=100.5 → signal=0; order created,
///         limit=101*0.98=98.98, low=100>98.98 → no fill → IOC cancels)
///   bar2: O=101 H=102 L=97  C=99   (low=97<98.98 but order already cancelled)
///   bar3: O=99  H=100 L=98  C=99
#[test]
fn limit_entry_ioc_cancel() {
    let bars = sample_bars_ohlc(&[
        (100.0, 101.0, 99.0, 100.0),
        (101.0, 102.0, 100.0, 101.0),
        (101.0, 102.0, 97.0, 99.0),
        (99.0, 100.0, 98.0, 99.0),
    ]);
    let store = MockStore::new(bars);
    let config = base_config(OrderConfig {
        limit_entry: Some(LimitOrderConfig {
            offset_bps: 200.0,
            time_in_force: TimeInForce::IOC,
        }),
        ..Default::default()
    });
    let runner = SimpleBacktestRunner::new();
    let result = runner
        .run(&strategy_entry_then_flat(100.5), &config, &store)
        .expect("run");

    // IOC cancelled, no fill even though bar2 would have triggered.
    assert_eq!(
        result.trades.num_rows(),
        0,
        "IOC should cancel immediately, got {} trades",
        result.trades.num_rows()
    );
}

/// With OrderConfig::default() (no orders), the result should match legacy behavior.
/// This tests backward compatibility — the exit_reason column should be all Signal (0).
#[test]
fn backward_compat_no_orders() {
    let bars = sample_bars_ohlc(&[
        (100.0, 101.0, 99.0, 100.0),
        (100.0, 101.0, 99.0, 100.0),
        (101.0, 102.0, 100.0, 101.0),
        (101.0, 102.0, 100.0, 101.0),
    ]);
    let store = MockStore::new(bars);
    let config = base_config(OrderConfig::default());
    let runner = SimpleBacktestRunner::new();
    let result = runner
        .run(&strategy_always_long(), &config, &store)
        .expect("run");

    // Should have at least the entry trade
    assert!(result.trades.num_rows() >= 1, "should have trades");

    // All exit reasons should be Signal (0)
    let reasons = get_exit_reasons(&result.trades);
    for (i, &r) in reasons.iter().enumerate() {
        assert_eq!(
            r, 0,
            "trade {} exit_reason should be Signal(0), got {}",
            i, r
        );
    }
}

/// Verify that the trades RecordBatch includes the exit_reason column.
#[test]
fn exit_reason_column_in_trades() {
    let bars = sample_bars_ohlc(&[
        (100.0, 101.0, 99.0, 100.0),
        (100.0, 101.0, 99.0, 100.0),
        (101.0, 102.0, 100.0, 101.0),
        (101.0, 102.0, 100.0, 101.0),
    ]);
    let store = MockStore::new(bars);
    let config = base_config(OrderConfig::default());
    let runner = SimpleBacktestRunner::new();
    let result = runner
        .run(&strategy_always_long(), &config, &store)
        .expect("run");

    let schema = result.trades.schema();
    assert!(
        schema.column_with_name("exit_reason").is_some(),
        "trades schema must include exit_reason column"
    );
}

/// Market order exit (signal-driven) should have exit_reason = Signal (0).
#[test]
fn market_order_exit_reason_is_signal() {
    // Strategy goes long then flat: entry + exit, both signal-driven.
    // Use threshold=101 so that when close >= 101 signal becomes 0 → exit.
    let bars = sample_bars_ohlc(&[
        (100.0, 101.0, 99.0, 100.0),  // close=100 < 101 → signal=1
        (100.0, 101.0, 99.0, 100.0), // entry fills here, close=100 < 101 → signal still 1 (no change)
        (101.0, 102.0, 100.0, 101.0), // close=101 >= 101 → signal=0 → exit order created
        (101.0, 102.0, 100.0, 101.0), // exit fills here
    ]);
    let store = MockStore::new(bars);
    let config = base_config(OrderConfig::default());
    let runner = SimpleBacktestRunner::new();
    let result = runner
        .run(&strategy_entry_then_flat(101.0), &config, &store)
        .expect("run");

    let reasons = get_exit_reasons(&result.trades);
    // All trades (entry + exit) should be Signal (0)
    for (i, &r) in reasons.iter().enumerate() {
        assert_eq!(r, 0, "trade {} should be Signal exit, got {}", i, r);
    }
}

/// Bracket (SL + TP) where TP hits first: verify TP exit.
///
/// Bars:
///   bar0: O=100 H=101 L=99  C=100  (signal=1)
///   bar1: O=100 H=101 L=99  C=100  (entry at 100)
///   bar2: O=101 H=106 L=100 C=105  (high=106 >= TP=105 → TP, low=100 > SL=98)
///   bar3: O=105 H=106 L=104 C=105
///
/// Bracket: SL=2% → 98, TP=5% → 105. Only TP triggered on bar2.
#[test]
fn bracket_tp_hits_first() {
    let bars = sample_bars_ohlc(&[
        (100.0, 101.0, 99.0, 100.0),
        (100.0, 101.0, 99.0, 100.0),
        (101.0, 106.0, 100.0, 105.0),
        (105.0, 106.0, 104.0, 105.0),
    ]);
    let store = MockStore::new(bars);
    let config = base_config(OrderConfig {
        stop_loss: Some(StopLossConfig { stop_pct: 2.0 }),
        take_profit: Some(TakeProfitConfig { profit_pct: 5.0 }),
        ..Default::default()
    });
    let runner = SimpleBacktestRunner::new();
    let result = runner
        .run(&strategy_always_long(), &config, &store)
        .expect("run");

    let reasons = get_exit_reasons(&result.trades);
    let fills = get_fill_prices(&result.trades);

    let tp_idx = reasons.iter().position(|&r| r == 2);
    assert!(
        tp_idx.is_some(),
        "should have TP exit, reasons: {:?}",
        reasons
    );
    assert!(
        (fills[tp_idx.unwrap()] - 105.0).abs() < 0.01,
        "TP fill should be ~105.0"
    );
}
