# FILE-SIZE-OK: bulk store + batch store + mode guard are cohesive
# Issue #46: Modularization M5 - Extract bulk operations from cache.py
# Issue #158: Stathera — _check_overlap_strategy, overlap_strategy
"""Bulk store operations for ClickHouse open deviation bar cache.

Provides mixin methods for storing open deviation bars in bulk (pandas) and batch
(Polars/Arrow) modes. Used by OpenDeviationBarCache via mixin inheritance.
"""

from __future__ import annotations

import hashlib
import logging
from typing import TYPE_CHECKING

import pandas as pd

from .._core import __version__
from ..constants import (
    _PLUGIN_FEATURE_COLUMNS,  # Issue #98: FeatureProvider plugin columns
    EXCHANGE_SESSION_COLUMNS,
    INTER_BAR_FEATURE_COLUMNS,  # Issue #78: Was missing, causing NULL lookback columns
    INTRA_BAR_FEATURE_COLUMNS,  # Issue #78: Also add intra-bar features
    MICROSTRUCTURE_COLUMNS,
    TIMESTAMP_COLUMNS,
    TRADE_ID_RANGE_COLUMNS,  # Issue #72
)
from ..exceptions import CacheWriteError
from ..hooks import HookEvent, emit_hook
from .constants import (
    CLICKHOUSE_TRANSIENT_ERRORS,
    OverlapStrategy,
    validate_overlap_strategy,
)

if TYPE_CHECKING:
    import polars as pl

logger = logging.getLogger(__name__)


def _guard_close_time_us_scale(df: object, column: str = "close_time_us") -> None:
    """Raise if timestamps are not in microsecond scale (#85, #166, USEC-03).

    Rejects second-scale (< 1e15) and values beyond year 2049 (> 2.5e15).
    Valid us range: [1e15, 2.5e15] ≈ [2001-09-09, 2049-03-22].
    """
    cols = df.columns if hasattr(df, "columns") else []
    if column not in cols:
        return
    min_ts = df[column].min()
    max_ts = df[column].max()
    if min_ts < 1_000_000_000_000_000:
        msg = f"{column} in seconds or ms, not us (min={min_ts}). See USEC-03."
        raise ValueError(msg)
    if max_ts > 2_500_000_000_000_000:
        msg = f"{column} beyond year 2049 in us (max={max_ts}). See USEC-03."
        raise ValueError(msg)


_VOLUME_COLUMNS = ("volume", "buy_volume", "sell_volume")


def _guard_volume_non_negative(
    df: object,
    columns: tuple[str, ...] = _VOLUME_COLUMNS,
) -> None:
    """Reject writes with negative volumes (overflow indicator). Issue #88."""
    cols = df.columns if hasattr(df, "columns") else []
    for col in columns:
        if col in cols and df[col].min() < 0:
            msg = (
                f"Issue #88: {col} has negative values "
                f"(min={df[col].min()}). Overflow detected."
            )
            raise ValueError(msg)


def _guard_bar_spread_invariant(
    df: object,
    threshold_decimal_bps: int | None = None,
    multiplier: int = 5,
) -> None:
    """Issue #112: Log wide-open deviation bars for observability.

    The Rust processor uses open-anchored thresholds (breach when price
    deviates from open by > threshold).  Bidirectional movement within a
    bar means (high-low)/open can legitimately exceed the threshold —
    this is expected behavior, not a bug.  This guard logs extreme cases
    for monitoring but never blocks writes.

    Args:
        df: DataFrame with Open, High, Low columns (or open, high, low).
        threshold_decimal_bps: Threshold in dbps. If None, skip check.
        multiplier: Log bars whose range exceeds this multiple of the
            threshold ratio (default 5).
    """
    if threshold_decimal_bps is None:
        return

    # Determine column names (capitalized or lowercase)
    cols = set(df.columns) if hasattr(df, "columns") else set()
    if {"Open", "High", "Low"}.issubset(cols):
        open_col, high_col, low_col = "Open", "High", "Low"
    elif {"open", "high", "low"}.issubset(cols):
        open_col, high_col, low_col = "open", "high", "low"
    else:
        return  # Cannot validate without OHLC columns

    threshold_ratio = threshold_decimal_bps / 100_000  # dbps → ratio
    log_threshold = threshold_ratio * multiplier

    # Check each bar's spread
    highs = df[high_col]
    lows = df[low_col]
    opens = df[open_col]
    ranges = (highs - lows) / opens

    if hasattr(ranges, "max"):  # pandas/polars
        max_observed = ranges.max()
        if max_observed > log_threshold:
            import logging

            idx = ranges.idxmax() if hasattr(ranges, "idxmax") else "unknown"
            logging.getLogger("opendeviationbar.clickhouse.bulk_operations").info(
                "Wide-open deviation bar at index %s: ratio=%.6f (%.1fx threshold). "
                "Expected during flash crashes with open-anchored breach detection. "
                "threshold_decimal_bps=%d",
                idx,
                max_observed,
                max_observed / threshold_ratio,
                threshold_decimal_bps,
            )


def _guard_threshold_registry(symbol: str, threshold_decimal_bps: int) -> None:
    """Reject writes below symbol's min_threshold from the registry.

    Prevents accumulation of bars at invalid thresholds that waste storage
    and confuse kintsugi gap detection. Issue #256.
    """
    try:
        from opendeviationbar.threshold import get_min_threshold_for_symbol

        min_threshold = get_min_threshold_for_symbol(symbol)
        if threshold_decimal_bps < min_threshold:
            msg = (
                f"Threshold {threshold_decimal_bps} dbps is below "
                f"{symbol}'s registry minimum ({min_threshold} dbps). "
                f"Refusing write. See symbols.toml."
            )
            raise ValueError(msg)
    except ImportError:
        pass  # Registry unavailable — allow write


def _run_write_guards(
    df: object,
    threshold_decimal_bps: int | None = None,
) -> None:
    """Run all pre-write data integrity guards."""
    _guard_close_time_us_scale(df, column="close_time_us")
    _guard_close_time_us_scale(df, column="open_time_us")
    _guard_volume_non_negative(df)
    _guard_bar_spread_invariant(df, threshold_decimal_bps)


def _check_overlap_strategy(
    cache: object,
    symbol: str,
    threshold_decimal_bps: int,
    bars_df: object,
    strategy: str = "warn",
    ouroboros_mode: str | None = None,  # SSoT-OK: resolved from config
) -> bool:
    """Evaluate Stathera overlap strategy before writing bars.

    Issue #158 (Stathera Phase 2): Pre-write overlap check.

    Checks whether new bars overlap existing bars by trade-ID span,
    then applies the requested strategy:

    - "off" (Trust): No check. Single-writer assumption.
    - "warn" (Append): Log warning, proceed. L2 ReplacingMergeTree dedup.
    - "skip" (Defer): Skip write if overlap exists.
    - "replace" (Authoritative): Should not reach here — routed to
      store_bars_replacing() by store_bars_batch/store_bars_bulk.

    Parameters
    ----------
    cache : OpenDeviationBarCache
        Cache instance with query methods.
    symbol : str
        Trading symbol.
    threshold_decimal_bps : int
        Threshold in decimal basis points.
    bars_df : DataFrame
        New bars (must have first_agg_trade_id, last_agg_trade_id columns).
    strategy : str
        One of "off", "warn", "skip".
    ouroboros_mode : str | None
        Ouroboros mode. Resolved from config if None.

    Returns
    -------
    bool
        True if write should proceed, False if it should be skipped.
    """
    proceed = True  # Default: proceed with write

    if strategy == "off":
        return proceed

    # Check if bars_df has trade-ID columns
    cols = set(bars_df.columns) if hasattr(bars_df, "columns") else set()
    if "first_agg_trade_id" not in cols or "last_agg_trade_id" not in cols:
        return proceed  # No trade IDs — can't check

    try:
        import polars as pl

        if isinstance(bars_df, pl.DataFrame):
            new_first = bars_df["first_agg_trade_id"].min()
            new_last = bars_df["last_agg_trade_id"].max()
        else:
            new_first = int(bars_df["first_agg_trade_id"].min())
            new_last = int(bars_df["last_agg_trade_id"].max())
    except (KeyError, ValueError, TypeError):
        return proceed

    if not new_first or not new_last or new_first <= 0:
        return proceed  # No valid trade IDs

    overlapping = cache.get_bars_overlapping_trade_span(
        symbol,
        threshold_decimal_bps,
        new_first,
        new_last,
        ouroboros_mode=ouroboros_mode,
    )

    if not overlapping:
        return proceed  # No overlaps — proceed

    # Overlaps found — handle by strategy
    if strategy == "warn":
        # INFO not WARNING — these overlaps are expected during kintsugi
        # day-recompute (sidecar can INSERT between DELETE and new INSERT).
        # ReplacingMergeTree L2 dedup collapses them on background merge.
        logger.info(
            "Stathera overlap: %d existing bar(s) overlap new bars "
            "(tid span %d-%d) for %s@%d. Writing anyway (strategy=warn).",
            len(overlapping),
            new_first,
            new_last,
            symbol,
            threshold_decimal_bps,
        )
    elif strategy == "skip":
        logger.info(
            "Stathera overlap: skipping write for %s@%d — "
            "%d existing bar(s) overlap tid span %d-%d (strategy=skip).",
            symbol,
            threshold_decimal_bps,
            len(overlapping),
            new_first,
            new_last,
        )
        proceed = False

    elif strategy == "replace":
        # Routed to store_bars_replacing() by store_bars_batch/store_bars_bulk
        # before this function is called. Reaching here is a logic error.
        logger.error(
            "BUG: _check_overlap_strategy called with strategy='replace' — "
            "should have been routed to store_bars_replacing() earlier."
        )

    return proceed


def _build_insert_settings(
    skip_dedup: bool,
    cache_key_accessor: object = None,
) -> dict[str, object]:
    """Build INSERT dedup settings from cache_key (Issue #90).

    Returns settings dict with insert_deduplication_token if dedup is enabled
    and a valid cache_key exists. Returns empty dict otherwise.
    """
    if skip_dedup or cache_key_accessor is None:
        return {}
    token = cache_key_accessor()
    if token:
        return {
            "insert_deduplicate": 1,
            "insert_deduplication_token": token,
        }
    return {}


class BulkStoreMixin:
    """Mixin providing bulk store operations for OpenDeviationBarCache.

    Requires `self.client` from ClickHouseClientMixin.
    """

    def _guard_ouroboros_mode_consistency(
        self,
        symbol: str,
        threshold_decimal_bps: int,
        ouroboros_mode: str,
    ) -> None:
        """Guard against mixing ouroboros modes for the same (symbol, threshold) pair.

        Issue #126: ouroboros_mode is now in ORDER BY (Phase 5A migration),
        so cross-mode rows won't merge. This guard adds application-level
        safety to prevent accidental mode mixing during migration.

        Behavior controlled by OPENDEVIATIONBAR_OUROBOROS_GUARD:
        - "strict": raise CacheWriteError on mismatch or connection failure
        - "warn": log warning, allow write
        - "off": skip check entirely (used during migration)
        """
        from opendeviationbar.config import Settings

        guard = Settings.get().population.ouroboros_guard
        if guard == "off":
            logger.debug("Mode consistency check skipped (guard=off)")
            return

        try:
            result = self.client.query(
                """
                SELECT DISTINCT ouroboros_mode
                FROM opendeviationbar_cache.open_deviation_bars FINAL
                WHERE symbol = {symbol:String}
                  AND threshold_decimal_bps = {threshold:UInt32}
                LIMIT 2
                """,
                parameters={"symbol": symbol, "threshold": threshold_decimal_bps},
            )
            if result.result_rows:
                existing_mode = result.result_rows[0][0]
                if existing_mode and existing_mode != ouroboros_mode:
                    msg = (
                        f"Cannot write {ouroboros_mode} bars for "
                        f"{symbol}@{threshold_decimal_bps}: "
                        f"existing data uses {existing_mode}. "
                        f"Use force_refresh=True to clear existing data first."
                    )
                    if guard == "warn":
                        logger.warning(msg)
                        return
                    raise CacheWriteError(  # noqa: TRY301
                        msg,
                        symbol=symbol,
                        operation="mode_guard",
                    )
        except CacheWriteError:
            raise
        except CLICKHOUSE_TRANSIENT_ERRORS as e:
            if guard == "strict":
                msg = f"Cannot verify mode consistency (ClickHouse unreachable): {e}"
                raise CacheWriteError(
                    msg,
                    symbol=symbol,
                    operation="mode_guard",
                ) from e
            # guard == "warn" (only remaining option since "off" returned early)
            logger.warning("Mode consistency check failed: %s", e)

    def store_bars_bulk(  # noqa: PLR0915
        self,
        symbol: str,
        threshold_decimal_bps: int,
        bars: pd.DataFrame,
        version: str | None = None,
        ouroboros_mode: str | None = None,
        *,
        skip_dedup: bool = False,
        overlap_strategy: OverlapStrategy = "warn",
        _skip_lock: bool = False,
    ) -> int:
        """Store bars without requiring CacheKey (for bar-count API).

        This method is for storing bars computed during gap-filling
        where we don't have exact date bounds.

        Parameters
        ----------
        symbol : str
            Trading symbol (e.g., "BTCUSDT")
        threshold_decimal_bps : int
            Threshold in decimal basis points
        bars : pd.DataFrame
            DataFrame with OHLCV columns (from opendeviationbar processing)
        version : str | None
            opendeviationbar-core version for cache invalidation. If None (default),
            uses current package version for schema evolution tracking.
        ouroboros_mode : str
            Ouroboros reset mode (default: "aion").
        skip_dedup : bool
            If True, skip INSERT deduplication token (for force_refresh).
            Default False enables idempotent INSERTs (Issue #90).
        overlap_strategy : OverlapStrategy
            Issue #158 (Stathera Phase 2): Pre-write overlap strategy.
            "off" = no check, "warn" = log + write, "skip" = skip write,
            "replace" = delete overlapping then insert.
        _skip_lock : bool
            Internal: skip Redis lock (used by store_bars_replacing to avoid
            deadlock when caller already holds the lock). Default False.

        Returns
        -------
        int
            Number of rows inserted

        Raises
        ------
        CacheWriteError
            If the insert operation fails or mode mismatch detected.
        ValueError
            If threshold is below the symbol's registry minimum.
        """
        if bars.empty:
            return 0

        _guard_threshold_registry(symbol, threshold_decimal_bps)

        overlap_strategy = validate_overlap_strategy(overlap_strategy)

        # Issue #158 Phase 8: Route "replace" to store_bars_replacing().
        # pandas not supported — store_bars_replacing requires Polars.
        if overlap_strategy == "replace":
            msg = "overlap_strategy='replace' requires Polars DataFrame. Use store_bars_batch() or store_bars_replacing() directly."
            raise ValueError(msg)

        # Issue #158 Phase 7: Acquire distributed write lock (L1)
        if not _skip_lock:
            from .write_lock import cache_write_lock

            with cache_write_lock(symbol, threshold_decimal_bps):
                return self.store_bars_bulk(
                    symbol,
                    threshold_decimal_bps,
                    bars,
                    version=version,
                    ouroboros_mode=ouroboros_mode,
                    skip_dedup=skip_dedup,
                    overlap_strategy=overlap_strategy,
                    _skip_lock=True,
                )

        # Issue #158: Stathera pre-write overlap check
        if overlap_strategy != "off":
            should_proceed = _check_overlap_strategy(
                self,
                symbol,
                threshold_decimal_bps,
                bars,
                strategy=overlap_strategy,
                ouroboros_mode=ouroboros_mode,
            )
            if not should_proceed:
                return 0

        # Issue #126: Resolve ouroboros_mode from config if not specified
        if ouroboros_mode is None:
            from opendeviationbar.ouroboros import get_operational_ouroboros_mode

            ouroboros_mode = get_operational_ouroboros_mode()
            # SCHEMA-01: Warn when caller omits ouroboros_mode for a symbol
            # whose registry entry specifies a different mode
            try:
                from opendeviationbar.symbol_registry import get_symbol_entries

                _entries = get_symbol_entries()
                _entry = _entries.get(symbol)
                if _entry is not None:
                    _expected = _entry.ouroboros_mode.value
                    if _expected != ouroboros_mode:
                        logger.warning(
                            "SCHEMA-01: ouroboros_mode mismatch for %s: "
                            "caller passed None (resolved to '%s'), "
                            "registry says '%s'. Fix the caller to pass explicit mode.",
                            symbol,
                            ouroboros_mode,
                            _expected,
                        )
            except (ImportError, OSError):
                pass  # Registry unavailable — allow write
        logger.info(
            "cache_write: ouroboros_mode=%s symbol=%s threshold=%d bars=%d",
            ouroboros_mode,
            symbol,
            threshold_decimal_bps,
            len(bars),
        )

        # Issue #97: Guard against mixed ouroboros modes
        self._guard_ouroboros_mode_consistency(
            symbol,
            threshold_decimal_bps,
            ouroboros_mode,
        )

        # Issue #48: Emit CACHE_WRITE_START hook
        emit_hook(
            HookEvent.CACHE_WRITE_START,
            symbol=symbol,
            bars_count=len(bars),
            threshold_decimal_bps=threshold_decimal_bps,
        )
        logger.debug(
            "Bulk writing %d bars to cache for %s @ %d dbps",
            len(bars),
            symbol,
            threshold_decimal_bps,
        )

        # R1 (Issue #98): reset_index() already creates a new DataFrame,
        # so the prior .copy() was a redundant full-DataFrame copy.
        if isinstance(bars.index, pd.DatetimeIndex):
            df = bars.reset_index()  # creates new DF (no prior .copy() needed)
            idx_col = bars.index.name or "index"
            df["close_time_us"] = df[idx_col].dt.as_unit("us").astype("int64")
            df = df.drop(columns=[idx_col], errors="ignore")
        elif "close_time_us" not in bars.columns:
            msg = "DataFrame must have DatetimeIndex or 'close_time_us' column"
            raise ValueError(msg)
        else:
            df = bars  # No copy needed — store_bars_bulk is the terminal consumer (PYEF-03)

        _run_write_guards(df, threshold_decimal_bps)

        # Normalize column names (lowercase)
        df.columns = df.columns.str.lower()

        # Add cache metadata (Ouroboros: Plan sparkling-coalescing-dijkstra.md)
        df["symbol"] = symbol
        df["threshold_decimal_bps"] = threshold_decimal_bps
        df["ouroboros_mode"] = ouroboros_mode
        df["opendeviationbar_version"] = version if version is not None else __version__

        # For bulk storage without CacheKey, use timestamp range as source bounds
        if "close_time_us" in df.columns and len(df) > 0:
            start_ts = (
                df["open_time_us"].min()
                if "open_time_us" in df.columns
                else df["close_time_us"].min()
            )
            end_ts = df["close_time_us"].max()
            df["source_start_ts"] = start_ts
            df["source_end_ts"] = end_ts
            # Generate cache_key from symbol, threshold, ouroboros, and timestamp range
            key_str = (
                f"{symbol}_{threshold_decimal_bps}_{start_ts}_{end_ts}_{ouroboros_mode}"
            )
            df["cache_key"] = hashlib.md5(key_str.encode()).hexdigest()
        else:
            df["source_start_ts"] = 0
            df["source_end_ts"] = 0
            df["cache_key"] = ""

        # Select columns for insertion
        columns = [
            "symbol",
            "threshold_decimal_bps",
            "ouroboros_mode",
            "close_time_us",
            "open",
            "high",
            "low",
            "close",
            "volume",
            "cache_key",
            "opendeviationbar_version",
            "source_start_ts",
            "source_end_ts",
        ]

        # Issue #96 Task #37: Cache available column set for O(1) membership testing
        # Instead of repeated df.columns lookups (O(n)), use frozenset (O(1))
        available_cols = frozenset(df.columns)

        # Add optional columns if present (from constants.py SSoT)
        # close_time_us already in base list; open_time_us via TIMESTAMP_COLUMNS
        all_optional = (
            *MICROSTRUCTURE_COLUMNS,
            *TRADE_ID_RANGE_COLUMNS,
            *TIMESTAMP_COLUMNS,
        )
        for col in all_optional:
            if col in available_cols and col not in columns:
                columns.append(col)

        # Add exchange session columns; cast bool_ to int (Issue #8, #50)
        for col in EXCHANGE_SESSION_COLUMNS:
            if col in available_cols:
                df[col] = df[col].astype(int)
                columns.append(col)

        # Add inter-bar and intra-bar feature columns if present (Issue #78)
        # These are Nullable in ClickHouse; clickhouse-connect's insert_df
        # requires NaN (not Python None) for Nullable numeric columns.
        # R2 (Issue #98): Skip pd.to_numeric when dtype is already float64.
        for col in (*INTER_BAR_FEATURE_COLUMNS, *INTRA_BAR_FEATURE_COLUMNS):
            if col in available_cols:
                if not pd.api.types.is_float_dtype(df[col]):
                    df[col] = pd.to_numeric(df[col], errors="coerce")
                columns.append(col)

        # Add plugin feature columns if present (Issue #98)
        for col in _PLUGIN_FEATURE_COLUMNS:
            if col in available_cols:
                if not pd.api.types.is_float_dtype(df[col]):
                    df[col] = pd.to_numeric(df[col], errors="coerce")
                columns.append(col)

        # (#280) Physical invariant gate: reject bars that are physically impossible.
        # Catches corruption from sidecar restarts, concurrent writers, or bugs.
        # Must run BEFORE any other filter — these bars must never enter CH.
        has_tid = (
            "first_agg_trade_id" in df.columns and "last_agg_trade_id" in df.columns
        )
        has_ts = "open_time_us" in df.columns and "close_time_us" in df.columns
        if has_tid or has_ts:
            mask_bad = pd.Series(False, index=df.index)
            if has_tid:
                mask_bad |= (
                    (df["first_agg_trade_id"] > 0)
                    & (df["last_agg_trade_id"] > 0)
                    & (df["first_agg_trade_id"] > df["last_agg_trade_id"])
                )
            if has_ts:
                mask_bad |= (
                    (df["open_time_us"] > 0)
                    & (df["close_time_us"] > 0)
                    & (df["open_time_us"] > df["close_time_us"])
                )
            n_bad = int(mask_bad.sum())
            if n_bad > 0:
                logger.error(
                    "Physical invariant gate: REJECTED %d impossible bars "
                    "(inverted trade IDs or timestamps) — data corruption prevented",
                    n_bad,
                )
                df = df[~mask_bad]
                if len(df) == 0:
                    return 0

        # Issue #90: INSERT dedup token for idempotent writes.
        insert_settings = _build_insert_settings(
            skip_dedup,
            (lambda: df["cache_key"].iloc[0])
            if df.get("cache_key") is not None and len(df) > 0
            else None,
        )

        try:
            written = self.client.insert_df(
                "opendeviationbar_cache.open_deviation_bars",
                df[columns],
                settings=insert_settings,
            ).written_rows
            logger.info(
                "Bulk cached %d bars for %s @ %d dbps",
                written,
                symbol,
                threshold_decimal_bps,
            )
            emit_hook(
                HookEvent.CACHE_WRITE_COMPLETE,
                symbol=symbol,
                bars_written=written,
                threshold_decimal_bps=threshold_decimal_bps,
            )
            return written
        except CLICKHOUSE_TRANSIENT_ERRORS as e:
            logger.exception(
                "Bulk cache write failed for %s @ %d dbps",
                symbol,
                threshold_decimal_bps,
            )
            emit_hook(
                HookEvent.CACHE_WRITE_FAILED,
                symbol=symbol,
                error=str(e),
                threshold_decimal_bps=threshold_decimal_bps,
            )
            msg = f"Failed to bulk write bars for {symbol}: {e}"
            raise CacheWriteError(
                msg,
                symbol=symbol,
                operation="bulk_write",
            ) from e

    def store_bars_batch(  # noqa: PLR0915
        self,
        symbol: str,
        threshold_decimal_bps: int,
        bars: pl.DataFrame,
        version: str | None = None,
        ouroboros_mode: str | None = None,
        *,
        skip_dedup: bool = False,
        overlap_strategy: OverlapStrategy = "warn",
        _skip_lock: bool = False,
    ) -> int:
        """Store a batch of bars using Arrow for efficient streaming writes.

        This method is optimized for incremental streaming cache writes
        (Phase 4.3). It uses Arrow for zero-copy data transfer to ClickHouse.

        Parameters
        ----------
        symbol : str
            Trading symbol (e.g., "BTCUSDT")
        threshold_decimal_bps : int
            Threshold in decimal basis points
        bars : pl.DataFrame
            Polars DataFrame with OHLCV columns (from streaming processing)
        version : str | None
            opendeviationbar-core version for cache invalidation. If None (default),
            uses current package version for schema evolution tracking.
        ouroboros_mode : str
            Ouroboros reset mode (default: "aion").
        skip_dedup : bool
            If True, skip INSERT deduplication token (for force_refresh).
            Default False enables idempotent INSERTs (Issue #90).
        overlap_strategy : OverlapStrategy
            Issue #158 (Stathera Phase 2): Pre-write overlap strategy.
            "off" = no check, "warn" = log + write, "skip" = skip write,
            "replace" = delete overlapping then insert.
        _skip_lock : bool
            Internal: skip Redis lock (used by store_bars_replacing to avoid
            deadlock when caller already holds the lock). Default False.

        Returns
        -------
        int
            Number of rows inserted

        Examples
        --------
        >>> from opendeviationbar.clickhouse import OpenDeviationBarCache
        >>> with OpenDeviationBarCache() as cache:
        ...     # Stream bars and write incrementally
        ...     for batch in stream_open_deviation_bars(
        ...         "BTCUSDT", "2024-01-01", "2024-01-07",
        ...     ):
        ...         written = cache.store_bars_batch(
        ...             "BTCUSDT", 250, batch, version="7.1.3"
        ...         )
        ...         print(f"Wrote {written} bars")
        """
        import polars as pl

        if bars.is_empty():
            return 0

        _guard_threshold_registry(symbol, threshold_decimal_bps)

        overlap_strategy = validate_overlap_strategy(overlap_strategy)

        # Issue #158 Phase 8: Route "replace" to store_bars_replacing().
        # Pass _skip_lock through — caller may already hold the lock
        # (e.g. kintsugi _repair_under_lock).
        if overlap_strategy == "replace":
            return self.store_bars_replacing(
                symbol,
                threshold_decimal_bps,
                bars,
                ouroboros_mode=ouroboros_mode,
                version=version,
                _skip_lock=_skip_lock,
            )

        # Issue #158 Phase 7: Acquire distributed write lock (L1)
        if not _skip_lock:
            from .write_lock import cache_write_lock

            with cache_write_lock(symbol, threshold_decimal_bps):
                return self.store_bars_batch(
                    symbol,
                    threshold_decimal_bps,
                    bars,
                    version=version,
                    ouroboros_mode=ouroboros_mode,
                    skip_dedup=skip_dedup,
                    overlap_strategy=overlap_strategy,
                    _skip_lock=True,
                )

        # Issue #158: Stathera pre-write overlap check
        if overlap_strategy != "off":
            should_proceed = _check_overlap_strategy(
                self,
                symbol,
                threshold_decimal_bps,
                bars,
                strategy=overlap_strategy,
                ouroboros_mode=ouroboros_mode,
            )
            if not should_proceed:
                return 0

        # Issue #126: Resolve ouroboros_mode from config if not specified
        if ouroboros_mode is None:
            from opendeviationbar.ouroboros import get_operational_ouroboros_mode

            ouroboros_mode = get_operational_ouroboros_mode()
            # SCHEMA-01: Warn when caller omits ouroboros_mode for a symbol
            # whose registry entry specifies a different mode
            try:
                from opendeviationbar.symbol_registry import get_symbol_entries

                _entries = get_symbol_entries()
                _entry = _entries.get(symbol)
                if _entry is not None:
                    _expected = _entry.ouroboros_mode.value
                    if _expected != ouroboros_mode:
                        logger.warning(
                            "SCHEMA-01: ouroboros_mode mismatch for %s: "
                            "caller passed None (resolved to '%s'), "
                            "registry says '%s'. Fix the caller to pass explicit mode.",
                            symbol,
                            ouroboros_mode,
                            _expected,
                        )
            except (ImportError, OSError):
                pass  # Registry unavailable — allow write
        logger.info(
            "cache_write: ouroboros_mode=%s symbol=%s threshold=%d bars=%d",
            ouroboros_mode,
            symbol,
            threshold_decimal_bps,
            len(bars),
        )

        # Issue #97: Guard against mixed ouroboros modes
        self._guard_ouroboros_mode_consistency(
            symbol,
            threshold_decimal_bps,
            ouroboros_mode,
        )

        # Normalize column names (lowercase)
        # Issue #96 Task #34: Fast-path skip rename when all columns lowercase
        if any(c != c.lower() for c in bars.columns):
            df = bars.rename({c: c.lower() for c in bars.columns if c != c.lower()})
        else:
            df = bars

        # Handle timestamp conversion from datetime to microseconds
        if "timestamp" in df.columns and df["timestamp"].dtype == pl.Datetime:
            df = df.with_columns(
                (pl.col("timestamp").dt.epoch(time_unit="us"))
                .cast(pl.Int64)
                .alias("close_time_us")
            ).drop("timestamp")
        elif "close_time_us" not in df.columns:
            msg = "DataFrame must have 'close_time_us' column"
            raise ValueError(msg)

        _run_write_guards(df, threshold_decimal_bps)

        # Add cache metadata — single with_columns call for all metadata (MEM-07)
        # Schema evolution: use __version__ if version not specified
        effective_version = version if version is not None else __version__

        # Compute source bounds and cache_key
        if "close_time_us" in df.columns and len(df) > 0:
            start_ts = (
                df["open_time_us"].min()
                if "open_time_us" in df.columns
                else df["close_time_us"].min()
            )
            end_ts = df["close_time_us"].max()
            key_str = (
                f"{symbol}_{threshold_decimal_bps}_{start_ts}_{end_ts}_{ouroboros_mode}"
            )
            cache_key = hashlib.md5(key_str.encode()).hexdigest()
        else:
            start_ts = 0
            end_ts = 0
            cache_key = ""

        # Define columns for insertion
        columns = [
            "symbol",
            "threshold_decimal_bps",
            "ouroboros_mode",
            "close_time_us",
            "open",
            "high",
            "low",
            "close",
            "volume",
            "cache_key",
            "opendeviationbar_version",
            "source_start_ts",
            "source_end_ts",
        ]

        # Add optional columns if present (from constants.py SSoT)
        # close_time_us already in base list; open_time_us via TIMESTAMP_COLUMNS
        all_optional = (
            *MICROSTRUCTURE_COLUMNS,
            *TRADE_ID_RANGE_COLUMNS,
            *TIMESTAMP_COLUMNS,
        )
        for col in all_optional:
            if col in df.columns and col not in columns:
                columns.append(col)

        # Collect all column expressions for a single with_columns call (MEM-07)
        new_cols = [
            pl.lit(symbol).alias("symbol"),
            pl.lit(threshold_decimal_bps).alias("threshold_decimal_bps"),
            pl.lit(ouroboros_mode).alias("ouroboros_mode"),
            pl.lit(effective_version).alias("opendeviationbar_version"),
            pl.lit(start_ts).alias("source_start_ts"),
            pl.lit(end_ts).alias("source_end_ts"),
            pl.lit(cache_key).alias("cache_key"),
        ]

        # Add exchange session casts to the same batch (Issue #8, #50)
        for col in EXCHANGE_SESSION_COLUMNS:
            if col in df.columns:
                new_cols.append(pl.col(col).cast(pl.UInt8))
                columns.append(col)

        df = df.with_columns(new_cols)

        # Add inter-bar and intra-bar feature columns if present (Issue #78)
        for col in (*INTER_BAR_FEATURE_COLUMNS, *INTRA_BAR_FEATURE_COLUMNS):
            if col in df.columns:
                columns.append(col)

        # Add plugin feature columns if present (Issue #98)
        for col in _PLUGIN_FEATURE_COLUMNS:
            if col in df.columns:
                columns.append(col)

        # Filter to existing columns
        columns = [c for c in columns if c in df.columns]

        # Issue #90: INSERT dedup token for idempotent writes.
        insert_settings = _build_insert_settings(
            skip_dedup,
            (lambda: df["cache_key"][0])
            if "cache_key" in df.columns and len(df) > 0
            else None,
        )

        # (#280) Physical invariant gate: reject bars that are physically impossible.
        # Catches corruption from sidecar restarts, concurrent writers, or bugs.
        has_tid = (
            "first_agg_trade_id" in df.columns and "last_agg_trade_id" in df.columns
        )
        has_ts = "open_time_us" in df.columns and "close_time_us" in df.columns
        if has_tid or has_ts:
            bad_mask_parts = []
            if has_tid:
                bad_mask_parts.append(
                    (pl.col("first_agg_trade_id") > 0)
                    & (pl.col("last_agg_trade_id") > 0)
                    & (pl.col("first_agg_trade_id") > pl.col("last_agg_trade_id"))
                )
            if has_ts:
                bad_mask_parts.append(
                    (pl.col("open_time_us") > 0)
                    & (pl.col("close_time_us") > 0)
                    & (pl.col("open_time_us") > pl.col("close_time_us"))
                )
            bad_mask = bad_mask_parts[0]
            for part in bad_mask_parts[1:]:
                bad_mask = bad_mask | part
            n_bad = df.filter(bad_mask).height
            if n_bad > 0:
                logger.error(
                    "Physical invariant gate: REJECTED %d impossible bars "
                    "(inverted trade IDs or timestamps) — data corruption prevented",
                    n_bad,
                )
                df = df.filter(~bad_mask)
                if df.height == 0:
                    return 0

        # Use Arrow for efficient insert (zero-copy)
        arrow_table = df.select(columns).to_arrow()
        summary = self.client.insert_arrow(
            "opendeviationbar_cache.open_deviation_bars",
            arrow_table,
            settings=insert_settings,
        )

        return summary.written_rows
