# polars-exception: backtesting.py requires Pandas DataFrames with DatetimeIndex
# Issue #46: Modularization M4 - Extract count-bounded orchestration from __init__.py
"""Count-bounded open deviation bar retrieval (get_n_open_deviation_bars).

This module provides the count-bounded API for retrieving exactly N open deviation bars,
useful for ML training and walk-forward optimization. Includes adaptive
gap-filling with exponential backoff for cache misses.
"""

from __future__ import annotations

from datetime import UTC
from pathlib import Path
from typing import TYPE_CHECKING, cast

import pandas as pd

from opendeviationbar.conversion import _concat_pandas_via_polars
from opendeviationbar.types import CacheOptions, ProcessingOptions
from opendeviationbar.validation.cache_staleness import detect_staleness
from opendeviationbar.validation.continuity import (
    ContinuityError,
    ContinuityWarning,
    validate_junction_continuity,
)

from .helpers import (
    _datetime_to_end_ms,
    _empty_ohlcv_dataframe,
    _fetch_exness,
    _process_binance_trades,
    _process_exness_ticks,
    _validate_and_normalize_source_market,
)
from .tick_fetcher import estimate_ticks_per_bar, fetch_ticks_with_backoff

if TYPE_CHECKING:
    from datetime import datetime

    from opendeviationbar.clickhouse import OpenDeviationBarCache
    from opendeviationbar.storage.parquet import TickStorage
    from opendeviationbar.types import BarKey

# Module-level logger (matches __init__.py pattern)
import logging

logger = logging.getLogger("opendeviationbar")


def get_n_open_deviation_bars(
    symbol: str,
    n_bars: int,
    threshold_decimal_bps: int | str = 250,
    *,
    # Structured option objects (#338)
    processing: ProcessingOptions | None = None,
    cache: CacheOptions | None = None,
    end_date: str | None = None,
    source: str = "binance",
    market: str | None = None,
    include_microstructure: bool = False,
    prevent_same_timestamp_close: bool = True,
    use_cache: bool = True,
    fetch_if_missing: bool = True,
    max_lookback_days: int = 90,
    warn_if_fewer: bool = True,
    validate_on_return: bool = False,
    continuity_action: str = "warn",
    continuity_tolerance_pct: float | None = None,
    chunk_size: int = 100_000,
    cache_dir: str | None = None,
    # Bar-relative lookback (Issue #81)
    inter_bar_lookback_bars: int | None = None,
    ouroboros_mode: str | None = None,
) -> pd.DataFrame:
    """Get exactly N open deviation bars ending at or before a given date.

    Unlike `get_open_deviation_bars()` which uses date bounds (producing variable bar counts),
    this function returns a deterministic number of bars. This is useful for:
    - ML training (exactly 10,000 samples)
    - Walk-forward optimization (fixed window sizes)
    - Consistent backtest comparisons

    Parameters
    ----------
    symbol : str
        Trading symbol (e.g., "BTCUSDT")
    n_bars : int
        Number of bars to retrieve. Must be > 0.
    threshold_decimal_bps : int or str, default=250
        Threshold in decimal basis points. Can be:
        - Integer: Direct value (250 = 25bps = 0.25%)
        - String preset: "micro", "tight", "standard", "medium", "wide", "macro"
    end_date : str or None, default=None
        End date in YYYY-MM-DD format. If None, uses most recent available data.
    source : str, default="binance"
        Data source: "binance" or "exness"
    market : str, default="spot"
        Market type (Binance only): "spot", "futures-um", or "futures-cm"
    include_microstructure : bool, default=False
        Include microstructure columns (vwap, buy_volume, sell_volume)
    prevent_same_timestamp_close : bool, default=True
        Timestamp gating for flash crash prevention (Issue #36).
        If True (default): A bar cannot close on the same timestamp it opened.
        If False: Legacy v8 behavior for comparative analysis.
    use_cache : bool, default=True
        Use ClickHouse cache for bar retrieval/storage
    fetch_if_missing : bool, default=True
        Fetch and process new data if cache doesn't have enough bars
    max_lookback_days : int, default=90
        Safety limit: maximum days to look back when fetching missing data.
        Prevents runaway fetches on empty caches.
    warn_if_fewer : bool, default=True
        Emit UserWarning if returning fewer bars than requested.
    validate_on_return : bool, default=False
        If True, validate bar continuity before returning.
        Uses continuity_action to determine behavior on failure.
    continuity_action : str, default="warn"
        Action when discontinuity found during validation:
        - "warn": Log warning but return data
        - "raise": Raise ContinuityError
        - "log": Silent logging only
    continuity_tolerance_pct : float or None, default=None
        Maximum relative price gap for continuity validation (Issue #18).
        If None, uses ``CONTINUITY_TOLERANCE_PCT`` from constants
        (default 0.001 = 0.1%, overridable via OPENDEVIATIONBAR_CONTINUITY_TOLERANCE).
    chunk_size : int, default=100_000
        Number of ticks per processing chunk for memory efficiency.
        Larger values = faster processing, more memory.
        Default 100K = ~15MB memory overhead.
    cache_dir : str or None, default=None
        Custom cache directory for tick data (Tier 1).

    Returns
    -------
    pd.DataFrame
        OHLCV DataFrame with exactly n_bars rows (or fewer if not enough data),
        sorted chronologically (oldest first). Columns:
        - Open, High, Low, Close, Volume
        - (if include_microstructure) vwap, buy_volume, sell_volume

    Raises
    ------
    ValueError
        - n_bars <= 0
        - Invalid threshold
        - Invalid date format
    RuntimeError
        - ClickHouse not available when use_cache=True
        - Data fetching failed

    Examples
    --------
    Get last 10,000 bars for ML training:

    >>> from opendeviationbar import get_n_open_deviation_bars
    >>> df = get_n_open_deviation_bars("BTCUSDT", n_bars=10000)
    >>> assert len(df) == 10000

    Get 5,000 bars ending at specific date for walk-forward:

    >>> df = get_n_open_deviation_bars("BTCUSDT", n_bars=5000, end_date="2024-06-01")

    With safety limit (won't fetch more than 30 days of data):

    >>> df = get_n_open_deviation_bars("BTCUSDT", n_bars=1000, max_lookback_days=30)

    Notes
    -----
    Cache behavior:
        - Fast path: If cache has >= n_bars, returns immediately (~50ms)
        - Slow path: If cache has < n_bars and fetch_if_missing=True,
          fetches additional data, computes bars, stores in cache, returns

    Gap-filling algorithm:
        Uses adaptive exponential backoff to estimate how many ticks to fetch.
        Learns compression ratio (ticks/bar) for each (symbol, threshold) pair.

    See Also
    --------
    get_open_deviation_bars : Date-bounded bar retrieval (variable bar count)
    THRESHOLD_PRESETS : Named threshold values
    """
    # -------------------------------------------------------------------------
    # Expand-contract: resolve option objects (#338)
    # -------------------------------------------------------------------------
    if processing is not None:
        include_microstructure = processing.include_microstructure
        prevent_same_timestamp_close = processing.prevent_same_timestamp_close
    if cache is not None:
        use_cache = cache.use_cache
        fetch_if_missing = cache.fetch_if_missing

    import warnings
    from datetime import datetime

    import numpy as np

    # -------------------------------------------------------------------------
    # Validation helper (closure over validate_on_return, continuity_action)
    # -------------------------------------------------------------------------
    def _apply_validation(df: pd.DataFrame) -> pd.DataFrame:
        """Apply continuity validation if enabled, filter columns, then return.

        Issue #75: Column filtering happens here (after cache writes) to ensure
        cache receives full DataFrame with trade IDs while user gets filtered output.
        """
        # Filter output columns based on include_microstructure
        if not include_microstructure and not df.empty:
            ohlcv_cols = ["Open", "High", "Low", "Close", "Volume"]
            available_cols = [c for c in ohlcv_cols if c in df.columns]
            df = df[available_cols]

        if not validate_on_return or df.empty or len(df) <= 1:
            return df

        # Check continuity: Close[i] should equal Open[i+1]
        close_prices = df["Close"].to_numpy()[:-1]
        open_prices = df["Open"].to_numpy()[1:]

        # Calculate relative differences
        with np.errstate(divide="ignore", invalid="ignore"):
            rel_diff = np.abs(open_prices - close_prices) / np.abs(close_prices)

        # Use caller-provided tolerance or fall back to constant (Issue #18)
        from opendeviationbar.constants import CONTINUITY_TOLERANCE_PCT

        tolerance = (
            continuity_tolerance_pct
            if continuity_tolerance_pct is not None
            else CONTINUITY_TOLERANCE_PCT
        )
        discontinuities_mask = rel_diff > tolerance

        if not np.any(discontinuities_mask):
            return df

        # Found discontinuities
        discontinuity_count = int(np.sum(discontinuities_mask))
        msg = f"Found {discontinuity_count} discontinuities in {len(df)} bars"

        if continuity_action == "raise":
            # Build details for ContinuityError
            indices = np.where(discontinuities_mask)[0]
            details = []
            for idx in indices[:10]:  # Limit to first 10
                details.append(
                    {
                        "bar_index": int(idx),
                        "prev_close": float(close_prices[idx]),
                        "next_open": float(open_prices[idx]),
                        "gap_pct": float(rel_diff[idx] * 100),
                    }
                )
            raise ContinuityError(msg, details)
        if continuity_action == "warn":
            warnings.warn(msg, ContinuityWarning, stacklevel=3)
        else:  # "log"
            logging.getLogger("opendeviationbar").warning(msg)

        return df

    # -------------------------------------------------------------------------
    # Validate parameters
    # -------------------------------------------------------------------------
    if n_bars <= 0:
        msg = f"n_bars must be > 0, got {n_bars}"
        raise ValueError(msg)

    # Symbol registry gate (Issue #79)
    from opendeviationbar.symbol_registry import validate_symbol_registered

    validate_symbol_registered(symbol, operation="get_n_open_deviation_bars")

    # Resolve threshold with asset-class validation (Issue #62)
    from opendeviationbar.threshold import resolve_and_validate_threshold

    threshold = resolve_and_validate_threshold(symbol, threshold_decimal_bps)

    # Issue #126: Resolve ouroboros_mode per-symbol from registry if not specified
    if ouroboros_mode is None:
        from opendeviationbar.symbol_registry import get_symbol_entries

        entries = get_symbol_entries()
        entry = entries.get(symbol)
        ouroboros_mode = entry.ouroboros_mode.value if entry else "aion"

    # -------------------------------------------------------------------------
    # Construct BarKey for downstream ClickHouse calls
    # -------------------------------------------------------------------------
    from opendeviationbar.types import BarKey

    bar_key: BarKey = BarKey(symbol, threshold, ouroboros_mode)

    # Normalize source and market
    source, market_normalized = _validate_and_normalize_source_market(source, market, symbol)

    # Parse end_date if provided
    end_ts: int | None = None
    if end_date is not None:
        try:
            end_dt = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=UTC)
            end_ts = _datetime_to_end_ms(end_dt)
        except ValueError as e:
            msg = f"Invalid date format. Use YYYY-MM-DD: {e}"
            raise ValueError(msg) from e

    # -------------------------------------------------------------------------
    # Try cache first (if enabled)
    # -------------------------------------------------------------------------
    if use_cache:
        try:
            from opendeviationbar.clickhouse import OpenDeviationBarCache

            with OpenDeviationBarCache() as ch_cache:
                # Fast path: check if cache has enough bars
                bars_df, available_count = ch_cache.get_n_bars(
                    bar_key,
                    n_bars=n_bars,
                    before_ts=end_ts,
                    include_microstructure=include_microstructure,
                )

                if bars_df is not None and len(bars_df) >= n_bars:
                    # Tier 0 validation: Content-based staleness detection (Issue #39)
                    if include_microstructure:
                        staleness = detect_staleness(
                            bars_df, require_microstructure=True
                        )
                        if staleness.is_stale:
                            logger.warning(
                                "Stale cache data detected for %s: %s. "
                                "Falling through to recompute.",
                                symbol,
                                staleness.reason,
                            )
                            # Fall through to fetch_if_missing path
                        else:
                            # Cache hit - return exactly n_bars
                            return _apply_validation(bars_df.tail(n_bars))
                    else:
                        # Cache hit - return exactly n_bars
                        return _apply_validation(bars_df.tail(n_bars))

                # Slow path: need to fetch more data
                if fetch_if_missing:
                    bars_df = _fill_gap_and_cache(
                        symbol=symbol,
                        threshold=threshold,
                        n_bars=n_bars,
                        end_ts=end_ts,
                        source=source,
                        market=market_normalized,
                        include_microstructure=include_microstructure,
                        max_lookback_days=max_lookback_days,
                        cache=ch_cache,
                        cache_dir=Path(cache_dir) if cache_dir else None,
                        current_bars=bars_df,
                        _current_count=available_count,
                        chunk_size=chunk_size,
                        prevent_same_timestamp_close=prevent_same_timestamp_close,
                        inter_bar_lookback_bars=inter_bar_lookback_bars,
                        ouroboros_mode=ouroboros_mode,
                    )

                    if bars_df is not None and len(bars_df) >= n_bars:
                        return _apply_validation(bars_df.tail(n_bars))

                # Return what we have (or None)
                if bars_df is not None and len(bars_df) > 0:
                    if warn_if_fewer and len(bars_df) < n_bars:
                        warnings.warn(
                            f"Returning {len(bars_df)} bars instead of requested {n_bars}. "
                            f"Insufficient data available within max_lookback_days={max_lookback_days}.",
                            UserWarning,
                            stacklevel=2,
                        )
                    return _apply_validation(bars_df)

                # Empty result
                if warn_if_fewer:
                    warnings.warn(
                        f"Returning 0 bars instead of requested {n_bars}. "
                        "No data available in cache or from source.",
                        UserWarning,
                        stacklevel=2,
                    )
                return _empty_ohlcv_dataframe()

        except Exception as e:
            # ClickHouse not available - fall through to compute-only mode
            if "ClickHouseNotConfigured" in type(e).__name__:
                pass  # Fall through to compute-only mode
            else:
                raise

    # -------------------------------------------------------------------------
    # Compute-only mode (no cache)
    # -------------------------------------------------------------------------
    if not fetch_if_missing:
        if warn_if_fewer:
            warnings.warn(
                f"Returning 0 bars instead of requested {n_bars}. "
                "Cache disabled and fetch_if_missing=False.",
                UserWarning,
                stacklevel=2,
            )
        return _empty_ohlcv_dataframe()

    # Fetch and compute without caching
    bars_df = _fetch_and_compute_bars(
        symbol=symbol,
        threshold=threshold,
        n_bars=n_bars,
        end_ts=end_ts,
        source=source,
        market=market_normalized,
        include_microstructure=include_microstructure,
        max_lookback_days=max_lookback_days,
        cache_dir=Path(cache_dir) if cache_dir else None,
        inter_bar_lookback_bars=inter_bar_lookback_bars,
    )

    if bars_df is not None and len(bars_df) >= n_bars:
        return _apply_validation(bars_df.tail(n_bars))

    if bars_df is not None and len(bars_df) > 0:
        if warn_if_fewer:
            warnings.warn(
                f"Returning {len(bars_df)} bars instead of requested {n_bars}. "
                f"Insufficient data available within max_lookback_days={max_lookback_days}.",
                UserWarning,
                stacklevel=2,
            )
        return _apply_validation(bars_df)

    if warn_if_fewer:
        warnings.warn(
            f"Returning 0 bars instead of requested {n_bars}. "
            "No data available from source.",
            UserWarning,
            stacklevel=2,
        )
    return _empty_ohlcv_dataframe()


def _fill_gap_and_cache(
    symbol: str,
    threshold: int,
    n_bars: int,
    end_ts: int | None,
    source: str,
    market: str,
    include_microstructure: bool,
    max_lookback_days: int,
    cache: OpenDeviationBarCache,
    cache_dir: Path | None,
    current_bars: pd.DataFrame | None,
    _current_count: int,
    chunk_size: int = 100_000,
    prevent_same_timestamp_close: bool = True,
    inter_bar_lookback_bars: int | None = None,
    ouroboros_mode: str | None = None,
) -> pd.DataFrame | None:
    """Fill gap in cache by fetching and processing additional data.

    Uses checkpoint-based cross-file continuity for Binance (24/7 crypto markets).
    The key insight: ALL ticks must be processed with a SINGLE processor to
    maintain the bar[i+1].open == bar[i].close invariant.

    For Binance (24/7):
    1. Collect ALL tick data first (no intermediate processing)
    2. Merge all ticks chronologically
    3. Process with SINGLE processor (guarantees continuity)
    4. Store with unified cache key

    For Exness (forex):
    Session-bounded processing is acceptable since weekend gaps are natural.

    Parameters
    ----------
    chunk_size : int, default=100_000
        Number of ticks per processing chunk for memory efficiency when using
        chunked processing with checkpoint continuation.
    """
    from datetime import datetime

    from opendeviationbar.storage.parquet import TickStorage
    from opendeviationbar.types import BarKey

    bar_key = BarKey(symbol, threshold, ouroboros_mode or "aion")

    # Determine how many more bars we need
    bars_needed = n_bars - (len(current_bars) if current_bars is not None else 0)

    if bars_needed <= 0:
        return current_bars

    # Determine end date for fetching
    if end_ts is not None:
        end_dt = datetime.fromtimestamp(end_ts / 1000, tz=UTC)
    else:
        end_dt = datetime.now(tz=UTC)

    # Get oldest bar timestamp to know where to start fetching
    oldest_ts = cache.get_oldest_bar_timestamp(symbol, threshold)

    # Estimate ticks needed using extracted helper
    estimated_ticks_per_bar = estimate_ticks_per_bar(threshold)
    target_ticks = bars_needed * estimated_ticks_per_bar * 2  # 2x buffer

    storage = TickStorage(cache_dir=cache_dir)

    # =========================================================================
    # BINANCE (24/7 CRYPTO): Single-pass processing with checkpoint continuity
    # =========================================================================
    if source == "binance":
        # Phase 1: Fetch ALL tick data using extracted tick fetcher
        fetch_result = fetch_ticks_with_backoff(
            symbol=symbol,
            source=source,
            market=market,
            target_ticks=target_ticks,
            end_dt=end_dt,
            oldest_ts=oldest_ts,
            max_lookback_days=max_lookback_days,
            storage=storage,
        )

        if fetch_result.ticks is None:
            return current_bars

        # Phase 2: Process with SINGLE processor (guarantees continuity)
        new_bars, _ = _process_binance_trades(
            fetch_result.ticks,
            threshold,
            include_microstructure,
            symbol=symbol,
            prevent_same_timestamp_close=prevent_same_timestamp_close,
            inter_bar_lookback_bars=inter_bar_lookback_bars,
        )

        # Phase 3: Store with unified cache key
        # Phase 3+4: Route through store_bars_batch (Arrow path, 2-3 copies)
        if not new_bars.empty:
            import polars as pl

            new_bars_pl = pl.from_pandas(new_bars.reset_index())
            cache.store_bars_batch(bar_key, bars=new_bars_pl)

        # Combine with existing bars
        if current_bars is not None and len(current_bars) > 0:
            # Validate continuity at junction (new_bars older, current_bars newer)
            is_continuous, gap_pct = validate_junction_continuity(
                new_bars, current_bars
            )
            if not is_continuous:
                import warnings

                warnings.warn(
                    f"Discontinuity detected at junction: {symbol} @ {threshold} dbps. "
                    f"Gap: {gap_pct:.4%}. This occurs because open deviation bars from different "
                    f"processing sessions cannot guarantee bar[n].close == bar[n+1].open. "
                    f"Consider invalidating cache and re-fetching all data for continuous "
                    f"bars. See: https://github.com/terrylica/opendeviationbar-py/issues/5",
                    stacklevel=3,
                )

            # MEM-006: Use Polars for memory-efficient concatenation
            combined = _concat_pandas_via_polars([new_bars, current_bars])
            return combined[~combined.index.duplicated(keep="last")]

        return cast(pd.DataFrame, new_bars)

    # =========================================================================
    # EXNESS (FOREX): Session-bounded processing (weekend gaps are natural)
    # =========================================================================
    return _fill_gap_exness(
        symbol=symbol,
        threshold=threshold,
        n_bars=n_bars,
        end_dt=end_dt,
        oldest_ts=oldest_ts,
        include_microstructure=include_microstructure,
        max_lookback_days=max_lookback_days,
        cache=cache,
        storage=storage,
        current_bars=current_bars,
        estimated_ticks_per_bar=estimated_ticks_per_bar,
        ouroboros_mode=ouroboros_mode,
    )


def _exness_fetch_process_loop(
    symbol: str,
    threshold: int,
    n_bars: int,
    end_dt: datetime,
    oldest_ts: int | None,
    include_microstructure: bool,
    max_lookback_days: int,
    storage: TickStorage,
    estimated_ticks_per_bar: int,
    *,
    initial_bars: list[pd.DataFrame] | None = None,
    cache_callback: object | None = None,
) -> pd.DataFrame | None:
    """Shared Exness fetch-process loop for forex data.

    Forex markets have natural weekend gaps, so session-bounded processing
    is acceptable (unlike 24/7 crypto markets).

    Parameters
    ----------
    initial_bars : list[pd.DataFrame] | None
        Pre-existing bars to include in the result.
    cache_callback : callable | None
        If provided, called as cache_callback(symbol, threshold, new_bars) after
        each batch. Used by _fill_gap_exness to store bars in ClickHouse.
    """
    from datetime import datetime, timedelta

    multiplier = 2.0
    max_attempts = 5

    all_bars: list[pd.DataFrame] = list(initial_bars) if initial_bars else []
    bars_collected = sum(len(df) for df in all_bars)

    cache_symbol = f"exness_spot_{symbol}".upper()

    for _attempt in range(max_attempts):
        bars_still_needed = n_bars - bars_collected
        ticks_to_fetch = int(bars_still_needed * estimated_ticks_per_bar * multiplier)
        days_to_fetch = max(1, ticks_to_fetch // 1_000_000)
        days_to_fetch = min(days_to_fetch, max_lookback_days)

        if oldest_ts is not None:
            fetch_end_dt = datetime.fromtimestamp(oldest_ts / 1000, tz=UTC)
        else:
            fetch_end_dt = end_dt

        fetch_start_dt = fetch_end_dt - timedelta(days=days_to_fetch)

        if (end_dt - fetch_start_dt).days > max_lookback_days:
            break

        start_date = fetch_start_dt.strftime("%Y-%m-%d")
        end_date_str = fetch_end_dt.strftime("%Y-%m-%d")
        start_ts_fetch = int(fetch_start_dt.timestamp() * 1000)
        end_ts_fetch = int(fetch_end_dt.timestamp() * 1000)

        if storage.has_ticks(cache_symbol, start_ts_fetch, end_ts_fetch):
            tick_data = storage.read_ticks(cache_symbol, start_ts_fetch, end_ts_fetch)
        else:
            tick_data = _fetch_exness(symbol, start_date, end_date_str, "strict")
            if not tick_data.is_empty():
                storage.write_ticks(cache_symbol, tick_data)

        if tick_data.is_empty():
            break

        new_bars = _process_exness_ticks(
            tick_data, symbol, threshold, "strict", False, include_microstructure
        )

        if not new_bars.empty:
            if cache_callback is not None:
                cache_callback(symbol, threshold, new_bars)
            all_bars.insert(0, new_bars)
            bars_collected += len(new_bars)
            oldest_ts = int(new_bars.index.min().timestamp() * 1000)

            if bars_collected >= n_bars:
                break

        multiplier *= 2

    if not all_bars:
        return None

    # MEM-006: Use Polars for memory-efficient concatenation
    combined = _concat_pandas_via_polars(all_bars)
    return combined[~combined.index.duplicated(keep="last")]


def _fill_gap_exness(
    symbol: str,
    threshold: int,
    n_bars: int,
    end_dt: datetime,
    oldest_ts: int | None,
    include_microstructure: bool,
    max_lookback_days: int,
    cache: OpenDeviationBarCache,
    storage: TickStorage,
    current_bars: pd.DataFrame | None,
    estimated_ticks_per_bar: int,
    ouroboros_mode: str | None = None,
) -> pd.DataFrame | None:
    """Fill gap for Exness forex data with session-bounded processing."""
    initial_bars: list[pd.DataFrame] = []
    if current_bars is not None and len(current_bars) > 0:
        initial_bars.append(current_bars)

    import polars as pl

    from opendeviationbar.types import BarKey

    return _exness_fetch_process_loop(
        symbol=symbol,
        threshold=threshold,
        n_bars=n_bars,
        end_dt=end_dt,
        oldest_ts=oldest_ts,
        include_microstructure=include_microstructure,
        max_lookback_days=max_lookback_days,
        storage=storage,
        estimated_ticks_per_bar=estimated_ticks_per_bar,
        initial_bars=initial_bars,
        cache_callback=lambda sym, thr, bars: cache.store_bars_batch(
            BarKey(sym, thr, ouroboros_mode or "aion"), bars=pl.from_pandas(bars.reset_index())
        ),
    )


def _fetch_and_compute_bars(
    symbol: str,
    threshold: int,
    n_bars: int,
    end_ts: int | None,
    source: str,
    market: str,
    include_microstructure: bool,
    max_lookback_days: int,
    cache_dir: Path | None,
    inter_bar_lookback_bars: int | None = None,
) -> pd.DataFrame | None:
    """Fetch and compute bars without caching (compute-only mode).

    Uses single-pass processing for Binance (24/7 crypto) to guarantee continuity.
    """
    from datetime import datetime

    from opendeviationbar.storage.parquet import TickStorage

    # Determine end date
    if end_ts is not None:
        end_dt = datetime.fromtimestamp(end_ts / 1000, tz=UTC)
    else:
        end_dt = datetime.now(tz=UTC)

    # Estimate ticks needed using extracted helper
    estimated_ticks_per_bar = estimate_ticks_per_bar(threshold)
    target_ticks = n_bars * estimated_ticks_per_bar * 2

    storage = TickStorage(cache_dir=cache_dir)

    # =========================================================================
    # BINANCE (24/7 CRYPTO): Single-pass processing for continuity
    # =========================================================================
    if source == "binance":
        # Use extracted tick fetcher
        fetch_result = fetch_ticks_with_backoff(
            symbol=symbol,
            source=source,
            market=market,
            target_ticks=target_ticks,
            end_dt=end_dt,
            oldest_ts=None,
            max_lookback_days=max_lookback_days,
            storage=storage,
        )

        if fetch_result.ticks is None:
            return None

        bars_df, _ = _process_binance_trades(
            fetch_result.ticks,
            threshold,
            include_microstructure,
            symbol=symbol,
            inter_bar_lookback_bars=inter_bar_lookback_bars,
        )
        return cast(pd.DataFrame, bars_df) if not bars_df.empty else None

    # =========================================================================
    # EXNESS (FOREX): Session-bounded processing
    # =========================================================================
    return _compute_exness_bars(
        symbol=symbol,
        threshold=threshold,
        n_bars=n_bars,
        end_dt=end_dt,
        include_microstructure=include_microstructure,
        max_lookback_days=max_lookback_days,
        storage=storage,
        estimated_ticks_per_bar=estimated_ticks_per_bar,
    )


def _compute_exness_bars(
    symbol: str,
    threshold: int,
    n_bars: int,
    end_dt: datetime,
    include_microstructure: bool,
    max_lookback_days: int,
    storage: TickStorage,
    estimated_ticks_per_bar: int,
) -> pd.DataFrame | None:
    """Compute Exness forex bars without caching (compute-only mode)."""
    return _exness_fetch_process_loop(
        symbol=symbol,
        threshold=threshold,
        n_bars=n_bars,
        end_dt=end_dt,
        oldest_ts=None,
        include_microstructure=include_microstructure,
        max_lookback_days=max_lookback_days,
        storage=storage,
        estimated_ticks_per_bar=estimated_ticks_per_bar,
    )
