use std::collections::HashSet;

use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};

/// Strategy selection for paper runs. Tagged enum so JSON includes `"type": "..."`.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StrategyConfig {
    /// No-op strategy that generates zero orders (barter's DefaultStrategy).
    /// Also used as the serde default when no `strategy` key is present in run config JSON.
    Default,
    /// API-defined strategy composed of declarative JSON rules evaluated on each candle close.
    RuleBased(RuleBasedParams),
}

impl Default for StrategyConfig {
    fn default() -> Self {
        Self::Default
    }
}

// --- Rule-based strategy DSL ---

/// Parameters for the runtime-interpreted rule-based strategy.
///
/// Strategies are expressed as a list of rules evaluated on each candle close.
/// All rules whose `when` condition is true simultaneously fire their `then` action.
///
/// Example JSON:
/// ```json
/// {
///   "type": "rule_based",
///   "candle_interval": "5m",
///   "rules": [
///     {
///       "when": { "kind": "all_of", "conditions": [
///         { "kind": "ema_crossover", "fast_period": 9, "slow_period": 21, "direction": "above" },
///         { "kind": "position", "state": "flat" }
///       ]},
///       "then": { "side": "buy", "quantity": "0.001" }
///     }
///   ]
/// }
/// ```
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RuleBasedParams {
    /// Candle interval driving strategy evaluation, e.g. `"1m"`, `"5m"`, `"1h"`.
    pub candle_interval: String,
    /// Ordered list of trading rules. All rules whose condition is true on a candle close fire.
    pub rules: Vec<Rule>,
}

/// A single trading rule: place an order when `when` evaluates to true.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Rule {
    /// Optional human-readable annotation for this rule. Preserved through serialization
    /// so that strategy configs round-trip correctly and produce a consistent content hash.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub comment: Option<String>,
    pub when: Condition,
    pub then: Action,
}

/// Declarative position sizing method.  Resolved to a base-asset quantity at candle close
/// using the live price and free balance available at that moment.
///
/// All methods return `None` (order skipped) if required inputs are missing or the price is
/// zero.  Negative results are clamped to zero.
///
/// Use `"method"` to identify the variant (analogous to `"kind"` in [`Expr`]):
///
/// ```json
/// // Buy $500 worth at current price
/// { "method": "fixed_sum", "amount": "500" }
///
/// // Risk 2% of free USDC balance
/// { "method": "percent_of_balance", "percent": "2", "asset": "usdc" }
///
/// // Exactly 0.01 BTC (semantic alias for a fixed decimal)
/// { "method": "fixed_units", "units": "0.01" }
/// ```
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "method", rename_all = "snake_case")]
pub enum SizingMethod {
    /// Buy `amount` worth of quote currency. `qty = amount / current_price`.
    FixedSum { amount: Decimal },
    /// Buy `percent`% of the named asset's free balance worth of base asset.
    /// `qty = balance(asset) * percent / 100 / current_price`.
    /// `asset` is matched case-insensitively (e.g. `"usdc"` or `"USDC"`).
    PercentOfBalance { percent: Decimal, asset: String },
    /// Buy exactly `units` of base asset (explicit alias for a fixed decimal).
    FixedUnits { units: Decimal },
}

/// Per-order quantity: a fixed decimal, a declarative [`SizingMethod`], or a dynamic
/// [`Expr`] evaluated at candle close.
///
/// - **Fixed** (`"0.001"`) — plain decimal string; all legacy configs are backward-compatible.
/// - **Sizing** (`{ "method": "fixed_sum", ... }`) — named method resolved from live price/balance.
/// - **Expr** (`{ "kind": "bin_op", ... }`) — arbitrary expression tree.
///
/// Sizing and expression variants that return `None` (insufficient data) cause the order to be
/// skipped.  Negative results are clamped to zero before use.
///
/// ```json
/// // Fixed
/// "quantity": "0.001"
///
/// // Buy $500 worth — highest-value shorthand
/// "quantity": { "method": "fixed_sum", "amount": "500" }
///
/// // 1% of USDT balance ÷ (2 × ATR14) — ATR-based sizing via expression tree
/// "quantity": {
///   "kind": "bin_op", "op": "div",
///   "left": { "kind": "bin_op", "op": "mul",
///     "left": { "kind": "balance", "asset": "usdt" },
///     "right": { "kind": "literal", "value": "0.01" } },
///   "right": { "kind": "bin_op", "op": "mul",
///     "left": { "kind": "literal", "value": "2" },
///     "right": { "kind": "func", "name": "atr", "period": 14 } } }
/// ```
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum QuantitySpec {
    /// A fixed per-order size in base asset units.
    Fixed(Decimal),
    /// A declarative sizing method resolved at execution time from live price and balance.
    /// Tried before `Expr` because both serialise as JSON objects; disambiguated by tag key
    /// (`"method"` here vs `"kind"` in `Expr`).
    Sizing(Box<SizingMethod>),
    /// A dynamic expression evaluated at candle close.
    Expr(Box<Expr>),
}

/// The order to place when a rule fires.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Action {
    pub side: ActionSide,
    /// Per-order size in base asset units. Either a fixed decimal string or a dynamic expression.
    /// For sell orders, actual size is capped to the open position quantity.
    pub quantity: QuantitySpec,
    /// When `true` and an opposite position is open, the order quantity is set to
    /// `existing_position_qty + configured_quantity`, closing the position and immediately
    /// opening a new one in the opposite direction. When `false` (default), an opposite-side
    /// order only closes the existing position regardless of the configured quantity.
    #[serde(default)]
    pub reverse: bool,
}

/// Order direction for a rule action.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ActionSide {
    Buy,
    Sell,
}

/// A composable condition tree evaluated against instrument state at candle close.
///
/// Leaf variants check market indicators or current position.
/// Combinator variants compose multiple conditions.
///
/// All variants are tagged with `"kind"` for unambiguous JSON parsing.
///
/// ```json
/// { "kind": "ema_crossover", "fast_period": 9, "slow_period": 21, "direction": "above" }
/// { "kind": "position", "state": "flat" }
/// { "kind": "all_of", "conditions": [...] }
/// { "kind": "not", "condition": { "kind": "position", "state": "long" } }
/// ```
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Condition {
    /// True when fast EMA crosses above (or below) slow EMA on this candle.
    EmaCrossover {
        fast_period: usize,
        slow_period: usize,
        direction: CrossoverDirection,
        /// Optional timeframe for the EMA states. When absent, uses the primary candle interval.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        timeframe: Option<String>,
    },
    /// True when price is above (or below) a single EMA.
    EmaTrend {
        period: usize,
        direction: TrendDirection,
        /// Optional timeframe for the EMA state. When absent, uses the primary candle interval.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        timeframe: Option<String>,
    },
    /// True when RSI crosses a threshold.
    Rsi {
        #[serde(default = "default_rsi_period_usize")]
        period: usize,
        threshold: Decimal,
        comparison: Comparison,
        /// Optional timeframe for the price series. When absent, uses the primary candle interval.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        timeframe: Option<String>,
    },
    /// True when price breaks above the upper or below the lower Bollinger Band.
    Bollinger {
        #[serde(default = "default_bb_period_usize")]
        period: usize,
        #[serde(default = "default_num_std_dev")]
        num_std_dev: Decimal,
        band: BollingerBand,
        /// Optional timeframe for the price series. When absent, uses the primary candle interval.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        timeframe: Option<String>,
    },
    /// True when price is above or below a fixed level.
    PriceLevel {
        price: Decimal,
        direction: TrendDirection,
        /// Optional timeframe for the price lookup. When absent, uses the primary candle interval.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        timeframe: Option<String>,
    },
    /// True when the current position matches the required state.
    Position {
        state: PositionState,
    },
    /// True when all sub-conditions are true.
    AllOf {
        conditions: Vec<Condition>,
    },
    /// True when any sub-condition is true.
    AnyOf {
        conditions: Vec<Condition>,
    },
    /// True when the sub-condition is false.
    Not {
        condition: Box<Condition>,
    },
    /// True when `left op right` holds (generic numeric comparison).
    /// Both sides are [`Expr`] trees evaluated against candle history.
    Compare {
        left: Expr,
        op: CmpOp,
        right: Expr,
    },
    /// True when `left` crossed above `right` on the current bar vs. the previous bar.
    CrossOver {
        left: Expr,
        right: Expr,
    },
    /// True when `left` crossed below `right` on the current bar vs. the previous bar.
    CrossUnder {
        left: Expr,
        right: Expr,
    },
    /// True when `condition` fired at least / exactly / at most `count` times in the trailing
    /// `period` bars (offsets 1..=period, i.e. excluding the current bar).
    ///
    /// Primary use case: detecting that a discrete crossing event (e.g. `cross_under(close,
    /// sma20)`) occurred at least once recently, without the engine needing to store that event.
    ///
    /// Returns false during warm-up (fewer than `period` candles in history).
    ///
    /// **Note:** only `Compare`, `CrossOver`, `CrossUnder`, `AllOf`, `AnyOf`, `Not`, and nested
    /// `EventCount` sub-conditions are offset-aware. `Position`, `EmaCrossover`, `EmaTrend`,
    /// `Rsi`, and `Bollinger` return false at any non-zero offset.
    ///
    /// ```json
    /// { "kind": "event_count", "condition": { "kind": "cross_under", ... }, "period": 20, "op": ">=", "count": 1 }
    /// ```
    EventCount {
        condition: Box<Condition>,
        period: usize,
        op: CmpOp,
        count: usize,
    },
}

/// EMA crossover direction.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CrossoverDirection {
    /// Fast EMA crossed above slow EMA on this candle.
    Above,
    /// Fast EMA crossed below slow EMA on this candle.
    Below,
}

/// Price / EMA trend direction.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TrendDirection {
    Above,
    Below,
}

/// Threshold comparison direction for RSI.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Comparison {
    Above,
    Below,
}

/// Which Bollinger Band the price should breach.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BollingerBand {
    AboveUpper,
    BelowLower,
}

/// Required position state for a position condition.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PositionState {
    Flat,
    Long,
    Short,
}

fn default_rsi_period_usize() -> usize { 14 }
fn default_bb_period_usize() -> usize { 20 }
fn default_num_std_dev() -> Decimal { Decimal::TWO }

// --- Expression DSL ---

/// An OHLCV candle field.
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CandleField {
    Open,
    High,
    Low,
    Close,
    Volume,
}

/// Comparison operator for [`Condition::Compare`].
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
pub enum CmpOp {
    #[serde(rename = ">")]  Gt,
    #[serde(rename = "<")]  Lt,
    #[serde(rename = ">=")] Gte,
    #[serde(rename = "<=")] Lte,
    #[serde(rename = "==")] Eq,
}

/// Arithmetic operator for [`Expr::BinOp`].
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ArithOp {
    Add,
    Sub,
    Mul,
    Div,
}

/// Unary math operator for [`Expr::UnaryOp`].
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum UnaryOpKind {
    /// Absolute value.
    Abs,
    /// Square root (returns None for negative input).
    Sqrt,
    /// Arithmetic negation.
    Neg,
    /// Natural logarithm (returns None for non-positive input).
    Log,
}

/// Rolling-window function for [`Expr::Func`] and [`Expr::ApplyFunc`].
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FuncName {
    Highest,
    Lowest,
    Sma,
    Ema,
    Rsi,
    StdDev,
    /// Average True Range (Wilder's smoothing). Uses High, Low, and previous Close internally.
    /// The `field` parameter on [`Expr::Func`] is ignored for ATR.
    /// Requires `period + 1` candles minimum.
    Atr,
    /// Supertrend line (stateful iterative band-flip, ATR-based).
    /// `period` = ATR period, [`Expr::Func::multiplier`] = ATR multiplier (default 3.0).
    /// Returns the current supertrend line value; use [`Condition::CrossOver`] / [`Condition::CrossUnder`]
    /// against close price to generate entry/exit signals.
    Supertrend,
    /// Weighted Moving Average. Weight of each bar = its position in the window (oldest = 1, newest = period).
    Wma,
    /// Rolling sum over `period` bars.
    Sum,
    /// Average Directional Index (Wilder's smoothing). Uses High, Low, Close internally.
    /// The `field` parameter is ignored for ADX.
    /// Requires approximately `2 * period + 1` candles minimum.
    Adx,
    /// Upper Bollinger Band: SMA(field, period) + multiplier × StdDev(field, period).
    /// [`Expr::Func::multiplier`] = number of standard deviations (default 2.0).
    BollingerUpper,
    /// Lower Bollinger Band: SMA(field, period) − multiplier × StdDev(field, period).
    /// [`Expr::Func::multiplier`] = number of standard deviations (default 2.0).
    BollingerLower,
}

fn default_close() -> CandleField { CandleField::Close }
fn default_multiplier() -> Option<Decimal> { None }
fn is_zero(v: &usize) -> bool { *v == 0 }

/// A numeric expression that evaluates to `Option<Decimal>`.
///
/// Used in [`Condition::Compare`], [`Condition::CrossOver`], and
/// [`Condition::CrossUnder`] to compose arbitrary numeric comparisons.
///
/// ```json
/// { "kind": "literal", "value": "100.5" }
/// { "kind": "field", "field": "high" }
/// { "kind": "field", "field": "low", "offset": 1 }
/// { "kind": "func", "name": "lowest", "field": "low", "period": 20, "offset": 1 }
/// { "kind": "func", "name": "atr", "period": 14 }
/// { "kind": "func", "name": "supertrend", "period": 10, "multiplier": "3.0" }
/// { "kind": "apply_func", "name": "ema", "input": { "kind": "func", "name": "ema", "field": "close", "period": 20 }, "period": 20 }
/// { "kind": "unary_op", "op": "abs", "operand": { "kind": "bin_op", "op": "sub", ... } }
/// ```
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Expr {
    /// A constant numeric value.
    Literal { value: Decimal },
    /// An OHLCV candle field, optionally from `offset` bars ago (0 = current bar).
    Field {
        field: CandleField,
        #[serde(default, skip_serializing_if = "is_zero")]
        offset: usize,
        /// Optional timeframe to read from. When absent, uses the primary candle interval.
        /// Example: `"timeframe": "1h"` reads the hourly candle history.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        timeframe: Option<String>,
    },
    /// A rolling-window function applied to a candle field.
    /// `offset` shifts the window back: 0 = window ending on the current bar.
    /// `multiplier` is used by [`FuncName::Supertrend`] (default 3.0 when absent).
    Func {
        name: FuncName,
        #[serde(default = "default_close")]
        field: CandleField,
        period: usize,
        #[serde(default, skip_serializing_if = "is_zero")]
        offset: usize,
        /// Optional multiplier, used by `Supertrend` (ATR multiplier, default 3.0).
        #[serde(default = "default_multiplier", skip_serializing_if = "Option::is_none")]
        multiplier: Option<Decimal>,
        /// Optional timeframe to compute the function on. When absent, uses the primary candle interval.
        /// Example: `"timeframe": "1d"` computes SMA(200) on the daily candle history.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        timeframe: Option<String>,
    },
    /// Arithmetic combination of two sub-expressions.
    BinOp {
        op: ArithOp,
        left: Box<Expr>,
        right: Box<Expr>,
    },
    /// Like [`Expr::Func`] but takes an arbitrary sub-expression as input instead of a raw
    /// candle field. For each bar in the rolling window the inner `input` is evaluated at
    /// that bar's offset, and the resulting series is fed to the rolling function.
    ///
    /// This enables function composition such as EMA-of-EMA (for Gaussian Channel),
    /// WMA-of-arithmetic (for Hull Moving Average), or VWAP via
    /// `Sum(close × volume) / Sum(volume)`.
    ///
    /// Not all [`FuncName`] variants are valid here; ATR, Supertrend, ADX, and RSI require
    /// multi-field per-bar logic that is incompatible with a pre-evaluated scalar series and
    /// will return `None`.
    ApplyFunc {
        name: FuncName,
        input: Box<Expr>,
        period: usize,
        #[serde(default, skip_serializing_if = "is_zero")]
        offset: usize,
        /// Optional timeframe for the output series. When absent, uses the primary candle interval.
        /// Note: the `input` sub-expression's timeframe is resolved independently.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        timeframe: Option<String>,
    },
    /// Unary math operation on a sub-expression.
    UnaryOp {
        op: UnaryOpKind,
        operand: Box<Expr>,
    },
    /// Scans back up to `period` bars and returns how many bars ago `condition` was last true.
    ///
    /// Returns `None` (propagates to `false` in conditions) if `condition` never fired within
    /// the look-back window. Use inside [`Condition::Compare`] to express recency constraints:
    ///
    /// ```json
    /// { "kind": "compare",
    ///   "left": { "kind": "bars_since", "condition": { "kind": "cross_under", ... }, "period": 20 },
    ///   "op": ">", "right": { "kind": "literal", "value": "2" } }
    /// ```
    ///
    /// ("The last touch was more than 2 bars ago.")
    ///
    /// **Note:** the same offset-awareness constraints apply as for [`Condition::EventCount`].
    BarsSince {
        condition: Box<Condition>,
        period: usize,
    },
    /// Volume-weighted average entry price of the current position.
    /// Returns `None` when the position is flat (no open trade).
    EntryPrice,
    /// Absolute quantity of the current open position in base asset units.
    /// Returns `None` when flat.
    PositionQuantity,
    /// Estimated unrealised PnL of the current open position (in quote asset).
    /// Returns `None` when flat.
    UnrealisedPnl,
    /// Number of complete candle bars elapsed since the position was opened.
    /// Computed as integer division `(current_time - time_enter).num_seconds() / interval_secs`,
    /// which truncates toward zero (floor for positive durations). The result is always an exact
    /// integer Decimal (0, 1, 2, …), so `>= N` comparisons are never affected by rounding.
    /// Returns `None` when flat or when `primary_interval_secs` is zero.
    BarsSinceEntry,
    /// Free balance of the named asset (matched case-insensitively against the internal asset name).
    /// Returns `None` when balance data is unavailable or the asset is not found.
    ///
    /// ```json
    /// { "kind": "balance", "asset": "usdt" }
    /// ```
    Balance { asset: String },
}

// --- Utility functions ---

/// Parse a candle interval string into seconds.
/// Supports: `"1m"`, `"5m"`, `"15m"`, `"1h"`, `"4h"`, `"1d"`.
pub fn parse_interval_secs(interval: &str) -> Option<u64> {
    match interval {
        "1m"  => Some(60),
        "5m"  => Some(300),
        "15m" => Some(900),
        "1h"  => Some(3_600),
        "4h"  => Some(14_400),
        "1d"  => Some(86_400),
        _     => None,
    }
}

/// Walk the full rule tree and collect all additional timeframe strings referenced by
/// expressions or legacy conditions. The primary `candle_interval` is NOT included.
///
/// Used by the executor to initialise per-timeframe data structures and by the API to
/// validate candle data coverage before accepting a backtest request.
pub fn collect_timeframes(params: &RuleBasedParams) -> HashSet<String> {
    let mut set = HashSet::new();
    for rule in &params.rules {
        collect_from_condition(&rule.when, &mut set);
    }
    // Remove the primary interval if it appears — callers only care about *additional* ones.
    set.remove(&params.candle_interval);
    set
}

fn collect_from_condition(cond: &Condition, out: &mut HashSet<String>) {
    match cond {
        Condition::EmaCrossover { timeframe, .. }
        | Condition::EmaTrend { timeframe, .. }
        | Condition::Rsi { timeframe, .. }
        | Condition::Bollinger { timeframe, .. }
        | Condition::PriceLevel { timeframe, .. } => {
            if let Some(tf) = timeframe { out.insert(tf.clone()); }
        }
        Condition::Compare { left, right, .. } => {
            collect_from_expr(left, out);
            collect_from_expr(right, out);
        }
        Condition::CrossOver { left, right } | Condition::CrossUnder { left, right } => {
            collect_from_expr(left, out);
            collect_from_expr(right, out);
        }
        Condition::EventCount { condition, .. } => collect_from_condition(condition, out),
        Condition::AllOf { conditions } | Condition::AnyOf { conditions } => {
            for c in conditions { collect_from_condition(c, out); }
        }
        Condition::Not { condition } => collect_from_condition(condition, out),
        Condition::Position { .. } => {}
    }
}

fn collect_from_expr(expr: &Expr, out: &mut HashSet<String>) {
    match expr {
        Expr::Field { timeframe, .. } | Expr::Func { timeframe, .. } => {
            if let Some(tf) = timeframe { out.insert(tf.clone()); }
        }
        Expr::ApplyFunc { timeframe, input, .. } => {
            if let Some(tf) = timeframe { out.insert(tf.clone()); }
            collect_from_expr(input, out);
        }
        Expr::BinOp { left, right, .. } => {
            collect_from_expr(left, out);
            collect_from_expr(right, out);
        }
        Expr::UnaryOp { operand, .. } => collect_from_expr(operand, out),
        Expr::BarsSince { condition, .. } => collect_from_condition(condition, out),
        // Position-state and balance expressions carry no timeframe reference.
        Expr::Literal { .. }
        | Expr::EntryPrice
        | Expr::PositionQuantity
        | Expr::UnrealisedPnl
        | Expr::BarsSinceEntry
        | Expr::Balance { .. } => {}
    }
}

/// Compute the minimum warmup duration (in seconds) required before a strategy
/// can produce valid indicator values for all referenced timeframes.
///
/// Walks the rule tree to find, for each timeframe, the maximum `period` value
/// used by any function expression. Multiplies each by its interval duration to
/// get the candle history required. Returns the maximum across all timeframes.
///
/// Used by the backtest runner to pre-load candles from before `starts_at` so
/// that indicators are fully warmed up at the start of the backtest window.
///
/// Example: `SMA(200)` on `"4h"` → 200 × 14400 s = 2,880,000 s (~33 days).
pub fn compute_warmup_secs(config: &StrategyConfig) -> u64 {
    let params = match config {
        StrategyConfig::RuleBased(p) => p,
        StrategyConfig::Default => return 0,
    };

    // Collect max period per timeframe string (None = primary interval).
    let mut max_period: std::collections::HashMap<Option<String>, usize> =
        std::collections::HashMap::new();

    for rule in &params.rules {
        warmup_from_condition(&rule.when, &mut max_period);
    }

    // Convert each (timeframe, max_period) pair to seconds and take the maximum.
    max_period
        .into_iter()
        .filter_map(|(tf_opt, period)| {
            let tf_str = tf_opt.as_deref().unwrap_or(&params.candle_interval);
            let interval_secs = parse_interval_secs(tf_str)?;
            Some(interval_secs * period as u64)
        })
        .max()
        .unwrap_or(0)
}

fn warmup_from_condition(
    cond: &Condition,
    out: &mut std::collections::HashMap<Option<String>, usize>,
) {
    match cond {
        Condition::EmaCrossover { fast_period, slow_period, timeframe, .. } => {
            let key = timeframe.clone();
            let entry = out.entry(key).or_insert(0);
            *entry = (*entry).max(*fast_period).max(*slow_period);
        }
        Condition::EmaTrend { period, timeframe, .. } => {
            let entry = out.entry(timeframe.clone()).or_insert(0);
            *entry = (*entry).max(*period);
        }
        Condition::Rsi { period, timeframe, .. } => {
            let entry = out.entry(timeframe.clone()).or_insert(0);
            *entry = (*entry).max(*period);
        }
        Condition::Bollinger { period, timeframe, .. } => {
            let entry = out.entry(timeframe.clone()).or_insert(0);
            *entry = (*entry).max(*period);
        }
        Condition::PriceLevel { timeframe, .. } => {
            out.entry(timeframe.clone()).or_insert(0);
        }
        Condition::Compare { left, right, .. } => {
            warmup_from_expr(left, out);
            warmup_from_expr(right, out);
        }
        Condition::CrossOver { left, right } | Condition::CrossUnder { left, right } => {
            warmup_from_expr(left, out);
            warmup_from_expr(right, out);
        }
        Condition::EventCount { condition, period, .. } => {
            warmup_from_condition(condition, out);
            // EventCount scans back `period` bars.
            out.entry(None).and_modify(|v| *v = (*v).max(*period)).or_insert(*period);
        }
        Condition::AllOf { conditions } | Condition::AnyOf { conditions } => {
            for c in conditions { warmup_from_condition(c, out); }
        }
        Condition::Not { condition } => warmup_from_condition(condition, out),
        Condition::Position { .. } => {}
    }
}

fn warmup_from_expr(
    expr: &Expr,
    out: &mut std::collections::HashMap<Option<String>, usize>,
) {
    match expr {
        Expr::Field { timeframe, .. } => {
            out.entry(timeframe.clone()).or_insert(0);
        }
        Expr::Func { name, period, timeframe, .. } => {
            // ADX needs approximately 2 × period candles to stabilise.
            let effective = if matches!(name, FuncName::Adx) { period * 2 } else { *period };
            let entry = out.entry(timeframe.clone()).or_insert(0);
            *entry = (*entry).max(effective);
        }
        Expr::ApplyFunc { period, input, timeframe, .. } => {
            let entry = out.entry(timeframe.clone()).or_insert(0);
            *entry = (*entry).max(*period);
            warmup_from_expr(input, out);
        }
        Expr::BinOp { left, right, .. } => {
            warmup_from_expr(left, out);
            warmup_from_expr(right, out);
        }
        Expr::UnaryOp { operand, .. } => warmup_from_expr(operand, out),
        Expr::BarsSince { condition, period } => {
            warmup_from_condition(condition, out);
            out.entry(None).and_modify(|v| *v = (*v).max(*period)).or_insert(*period);
        }
        // Position-state and balance expressions require no warmup candle history.
        Expr::Literal { .. }
        | Expr::EntryPrice
        | Expr::PositionQuantity
        | Expr::UnrealisedPnl
        | Expr::BarsSinceEntry
        | Expr::Balance { .. } => {}
    }
}

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

    #[test]
    fn deserialize_default_variant() {
        let json = r#"{"type": "default"}"#;
        let config: StrategyConfig = serde_json::from_str(json).unwrap();
        assert!(matches!(config, StrategyConfig::Default));
    }

    #[test]
    fn default_is_default_variant() {
        let config = StrategyConfig::default();
        assert!(matches!(config, StrategyConfig::Default));
    }

    #[test]
    fn deserialize_func_with_multiplier() {
        let json = r#"{"kind": "func", "name": "supertrend", "period": 10, "multiplier": "3.0"}"#;
        let expr: Expr = serde_json::from_str(json).unwrap();
        match expr {
            Expr::Func { name: FuncName::Supertrend, period: 10, multiplier: Some(m), .. } => {
                assert_eq!(m, Decimal::new(30, 1));
            }
            _ => panic!("expected Func::Supertrend with multiplier"),
        }
    }

    #[test]
    fn deserialize_func_without_multiplier() {
        let json = r#"{"kind": "func", "name": "atr", "period": 14}"#;
        let expr: Expr = serde_json::from_str(json).unwrap();
        match expr {
            Expr::Func { name: FuncName::Atr, period: 14, multiplier: None, .. } => {}
            _ => panic!("expected Func::Atr without multiplier"),
        }
    }

    #[test]
    fn deserialize_apply_func() {
        let json = r#"{"kind": "apply_func", "name": "ema", "input": {"kind": "func", "name": "ema", "field": "close", "period": 20}, "period": 20}"#;
        let expr: Expr = serde_json::from_str(json).unwrap();
        match expr {
            Expr::ApplyFunc { name: FuncName::Ema, period: 20, .. } => {}
            _ => panic!("expected ApplyFunc::Ema"),
        }
    }

    #[test]
    fn deserialize_unary_op() {
        let json = r#"{"kind": "unary_op", "op": "abs", "operand": {"kind": "literal", "value": "-5"}}"#;
        let expr: Expr = serde_json::from_str(json).unwrap();
        match expr {
            Expr::UnaryOp { op: UnaryOpKind::Abs, .. } => {}
            _ => panic!("expected UnaryOp::Abs"),
        }
    }

    #[test]
    fn round_trip_apply_func() {
        let orig = Expr::ApplyFunc {
            name: FuncName::Wma,
            input: Box::new(Expr::Func {
                name: FuncName::Ema,
                field: CandleField::Close,
                period: 20,
                offset: 0,
                multiplier: None,
                timeframe: None,
            }),
            period: 4,
            offset: 0,
            timeframe: None,
        };
        let json = serde_json::to_string(&orig).unwrap();
        let back: Expr = serde_json::from_str(&json).unwrap();
        match back {
            Expr::ApplyFunc { name: FuncName::Wma, period: 4, offset: 0, .. } => {}
            _ => panic!("round-trip failed"),
        }
    }

    #[test]
    fn parse_interval_secs_known_values() {
        assert_eq!(super::parse_interval_secs("1m"),  Some(60));
        assert_eq!(super::parse_interval_secs("5m"),  Some(300));
        assert_eq!(super::parse_interval_secs("15m"), Some(900));
        assert_eq!(super::parse_interval_secs("1h"),  Some(3_600));
        assert_eq!(super::parse_interval_secs("4h"),  Some(14_400));
        assert_eq!(super::parse_interval_secs("1d"),  Some(86_400));
        assert_eq!(super::parse_interval_secs("2h"),  None);
    }

    #[test]
    fn collect_timeframes_extracts_additional_timeframes() {
        use serde_json::json;
        let params: RuleBasedParams = serde_json::from_value(json!({
            "candle_interval": "5m",
            "rules": [{
                "when": {
                    "kind": "all_of",
                    "conditions": [
                        { "kind": "position", "state": "flat" },
                        {
                            "kind": "compare",
                            "left": { "kind": "field", "field": "close", "timeframe": "1d" },
                            "op": ">",
                            "right": { "kind": "func", "name": "sma", "field": "close", "period": 200, "timeframe": "1d" }
                        },
                        {
                            "kind": "compare",
                            "left": { "kind": "func", "name": "sma", "field": "close", "period": 20, "timeframe": "1h" },
                            "op": ">",
                            "right": { "kind": "func", "name": "sma", "field": "close", "period": 20, "offset": 5, "timeframe": "1h" }
                        },
                        {
                            "kind": "cross_over",
                            "left": { "kind": "field", "field": "close" },
                            "right": { "kind": "func", "name": "sma", "field": "close", "period": 20 }
                        }
                    ]
                },
                "then": { "side": "buy", "quantity": "0.001" }
            }]
        })).unwrap();

        let tfs = super::collect_timeframes(&params);
        assert!(tfs.contains("1d"), "expected 1d: {tfs:?}");
        assert!(tfs.contains("1h"), "expected 1h: {tfs:?}");
        assert!(!tfs.contains("5m"), "primary should be excluded: {tfs:?}");
        assert_eq!(tfs.len(), 2);
    }

    #[test]
    fn timeframe_field_round_trips_correctly() {
        let expr: Expr = serde_json::from_str(
            r#"{"kind":"func","name":"sma","field":"close","period":200,"timeframe":"1d"}"#,
        ).unwrap();
        match &expr {
            Expr::Func { timeframe: Some(tf), .. } => assert_eq!(tf, "1d"),
            _ => panic!("expected Func with timeframe"),
        }
        // Round-trip: serialize back and verify timeframe is preserved.
        let out = serde_json::to_string(&expr).unwrap();
        assert!(out.contains("\"timeframe\":\"1d\""), "timeframe must appear: {out}");
    }

    #[test]
    fn timeframe_none_does_not_appear_in_output() {
        let expr: Expr = serde_json::from_str(
            r#"{"kind":"func","name":"sma","field":"close","period":20}"#,
        ).unwrap();
        let out = serde_json::to_string(&expr).unwrap();
        assert!(!out.contains("timeframe"), "absent timeframe must not appear: {out}");
    }

    /// Verifies that optional/defaulted fields are omitted during serialization so that
    /// a config stored in the DB (without those fields) produces the same JSON — and the
    /// same content hash — after a round-trip through serde.
    #[test]
    fn omitted_defaults_do_not_appear_in_serialized_output() {
        // Func: offset=0 and multiplier=None must be omitted.
        let expr: Expr = serde_json::from_str(
            r#"{"kind":"func","name":"atr","field":"close","period":14}"#,
        ).unwrap();
        let out = serde_json::to_string(&expr).unwrap();
        assert!(!out.contains("\"offset\""), "offset:0 must not appear: {out}");
        assert!(!out.contains("\"multiplier\""), "multiplier:null must not appear: {out}");

        // Field: offset=0 must be omitted.
        let expr2: Expr = serde_json::from_str(
            r#"{"kind":"field","field":"close"}"#,
        ).unwrap();
        let out2 = serde_json::to_string(&expr2).unwrap();
        assert!(!out2.contains("\"offset\""), "offset:0 must not appear: {out2}");

        // ApplyFunc: offset=0 must be omitted.
        let expr3: Expr = serde_json::from_str(
            r#"{"kind":"apply_func","name":"ema","input":{"kind":"field","field":"close"},"period":20}"#,
        ).unwrap();
        let out3 = serde_json::to_string(&expr3).unwrap();
        assert!(!out3.contains("\"offset\""), "offset:0 must not appear: {out3}");
    }

    /// Verifies that serializing with an explicit offset=1 or multiplier DOES include the fields.
    #[test]
    fn explicit_non_default_values_are_serialized() {
        let expr: Expr = serde_json::from_str(
            r#"{"kind":"func","name":"supertrend","field":"close","period":10,"offset":1,"multiplier":"3.0"}"#,
        ).unwrap();
        let out = serde_json::to_string(&expr).unwrap();
        assert!(out.contains("\"offset\":1"), "offset:1 must appear: {out}");
        assert!(out.contains("\"multiplier\""), "multiplier must appear: {out}");
    }
}
