"""Shard repair: day-recompute via Parquet, Vision, or REST fallback."""

from __future__ import annotations

import logging
import time
from datetime import UTC, datetime, timedelta

from opendeviationbar.crossover import get_crossover_tid
from opendeviationbar.orchestration.helpers import (
    _select_trade_columns,
    _stream_bars_with_processor,
)
from opendeviationbar.storage.parquet import TickStorage

from ._helpers import (
    _get_mode_for_symbol,
    _get_worker_cache,
    _kintsugi_cfg,
    _thread_local,
)
from .discovery import KintsugiResult, Shard

logger = logging.getLogger(__name__)


def repair_shard(
    shard: Shard,
    *,
    include_microstructure: bool = True,
    verbose: bool = False,
) -> KintsugiResult:
    """Repair a single shard via backfill_gap() (Vision-first + REST fallback)."""
    # Validate threshold before attempting repair — reject orphaned invalid data.
    # Use get_min_threshold_for_symbol() directly to avoid telemetry spam.
    from opendeviationbar.threshold import get_min_threshold_for_symbol

    min_thr = get_min_threshold_for_symbol(shard.symbol)
    if shard.threshold_decimal_bps < min_thr:
        return KintsugiResult(
            shard=shard,
            healed=False,
            bars_written=0,
            duration_seconds=0.0,
            had_anchor_tid=False,
            ouroboros_splits=0,
            error=(
                f"threshold {shard.threshold_decimal_bps} dbps "
                f"below minimum for {shard.symbol}"
            ),
        )

    t0 = time.monotonic()
    n_splits = max(len(shard.ouroboros_boundaries), 1)
    had_anchor_tid = shard.anchor_last_tid is not None

    try:
        total_bars = _repair(
            shard,
            include_microstructure=include_microstructure,
            verbose=verbose,
        )
    except (OSError, RuntimeError, ConnectionError, ValueError) as e:
        elapsed = time.monotonic() - t0
        logger.exception(
            "repair failed for %s@%d",
            shard.symbol,
            shard.threshold_decimal_bps,
        )
        _notify_repair_failure(shard, str(e))
        return KintsugiResult(
            shard=shard,
            healed=False,
            bars_written=0,
            duration_seconds=elapsed,
            had_anchor_tid=had_anchor_tid,
            ouroboros_splits=n_splits,
            error=str(e),
        )

    elapsed = time.monotonic() - t0

    # _repair() returns -1 when today-guard defers the shard. This is NOT a
    # "clean" result — the shard wasn't attempted, just deferred to tomorrow.
    # Must NOT be logged to kintsugi_log (would be dismissed for 30 days).
    if total_bars < 0:
        return KintsugiResult(
            shard=shard,
            healed=False,
            bars_written=0,
            duration_seconds=elapsed,
            had_anchor_tid=had_anchor_tid,
            ouroboros_splits=n_splits,
            error="deferred_today_guard",
        )

    healed = total_bars > 0
    source = getattr(_thread_local, "last_source", None)
    is_exchange_gap = getattr(_thread_local, "is_exchange_gap", False)

    # 0 bars is a valid outcome — gap exists in trade IDs but price didn't
    # breach the threshold.  This is clean (not an error).  Logged to
    # kintsugi_log so _get_dismissed_set() can skip the shard on future passes.
    error = None

    state_label = "EXCHANGE_GAP" if is_exchange_gap else ("HEALED" if healed else "NO_BARS")
    logger.info(
        "repair %s for %s@%d: %d bars in %.1fs (day-start-fill, anchored=%s, splits=%d, source=%s)",
        state_label,
        shard.symbol,
        shard.threshold_decimal_bps,
        total_bars,
        elapsed,
        had_anchor_tid,
        n_splits,
        source,
    )

    return KintsugiResult(
        shard=shard,
        healed=healed,
        bars_written=total_bars,
        duration_seconds=elapsed,
        had_anchor_tid=had_anchor_tid,
        ouroboros_splits=n_splits,
        error=error,
        source=source,
        is_exchange_gap=is_exchange_gap,
    )


def _notify_repair_failure(shard: Shard, error_msg: str) -> None:
    """Send immediate LOUD Telegram alert on repair failure.

    Reports trade-ID anchors and anomaly details (gap or overlap).
    Always LOUD (disable_notification=False) — repair failures require
    operator attention.
    """
    try:
        from opendeviationbar.notify.telegram import _build_context_block, send_telegram

        anomaly_type = "overlap" if shard.is_overlap else "gap"
        lines = [
            "<b>KINTSUGI REPAIR FAILED</b>",
            f"<b>Pair:</b> {shard.symbol}@{shard.threshold_decimal_bps}",
            f"<b>Stathera {anomaly_type}:</b> {shard.missing_trade_count:,} trades",
            f"<b>Anchor:</b> tid {shard.anchor_last_tid} → {shard.resume_first_tid}",
            f"<b>Error:</b> <code>{error_msg[:200]}</code>",
            "\n<b>Next:</b> Will retry on next pass (max 3/24h).",
            "<b>If persistent:</b> Check Binance Vision data availability, "
            "REST rate limits, CH write health",
            _build_context_block("kintsugi"),
        ]
        send_telegram("\n".join(lines), disable_notification=False)
    except Exception:
        logger.debug("Failed to send repair failure Telegram alert", exc_info=True)


def _repair(
    shard: Shard,
    *,
    include_microstructure: bool = True,
    verbose: bool = False,
) -> int:
    """Repair shard via day-recompute: delete + reprocess entire Ouroboros day(s).

    When a Stathera anomaly (gap or overlap) is detected, the affected
    Ouroboros day(s) are reprocessed from scratch:
      1. Identify all Ouroboros days spanned by the anomaly
      2. Try Vision ZIP first (include_incomplete=True for orphan flush)
      3. Fall back to REST time-based fetch (no Ariadne anchor needed)
      4. Atomic per-chunk DELETE+INSERT to ClickHouse

    This eliminates both missing-trade gaps and overlap artifacts from
    concurrent sidecar + backfill writes.
    """
    if shard.anchor_last_tid is None and not shard.is_genesis:
        msg = (
            f"kintsugi: shard {shard.symbol}@{shard.threshold_decimal_bps} "
            f"has no anchor_last_tid — cannot repair interior gap "
            f"(gap_start_ms={shard.gap_start_ms})"
        )
        logger.error(msg)
        _notify_repair_failure(shard, msg)
        raise RuntimeError(msg)

    from ._helpers import _get_mode_for_symbol

    ouroboros_mode = _get_mode_for_symbol(shard.symbol)

    # Identify the Ouroboros day boundaries spanning the gap.
    # Normalize: gap_start_ms/gap_end_ms may be inverted when lagInFrame()
    # operates in trade-ID order and concurrent writers produce bars with
    # close_time_us out of trade-ID order.
    lo_us = min(shard.gap_start_ms, shard.gap_end_ms)
    hi_us = max(shard.gap_start_ms, shard.gap_end_ms)
    gap_start_date = datetime.fromtimestamp(lo_us / 1_000_000, tz=UTC).date()
    gap_end_date = datetime.fromtimestamp(hi_us / 1_000_000, tz=UTC).date()

    # Guard (#280): Writer ownership boundary.
    # Day-mode: ABSOLUTE today-guard -- sidecar owns today, kintsugi owns yesterday+.
    # Aion-mode: No today-guard -- TID-based ownership. Sidecar's committed_floors
    # dedup in live_engine.rs prevents duplicate bars. ReplacingMergeTree is L2 safety.
    if ouroboros_mode == "day":
        today_utc = datetime.now(tz=UTC).date()
        if gap_end_date >= today_utc:
            # Clamp to yesterday so multi-day gaps still get their historical
            # days repaired. If gap is entirely within today -> defer.
            gap_end_date = today_utc - timedelta(days=1)
            if gap_end_date < gap_start_date:
                logger.info(
                    "kintsugi day-recompute: DEFERRING %s@%d -- gap touches today "
                    "(%s), sidecar owns today (Asclepius). Will retry tomorrow.",
                    shard.symbol,
                    shard.threshold_decimal_bps,
                    today_utc,
                )
                return -1  # sentinel: deferred, not clean -- must NOT be dismissed

    # Dispatch: Aion uses TID-range repair, Day uses day-recompute
    if ouroboros_mode == "aion":
        return _repair_aion(
            shard,
            include_microstructure=include_microstructure,
            verbose=verbose,
        )

    # Cache config once per _repair() call — avoids re-instantiating
    # pydantic model 3x per shard (PYEF-01).
    cfg = _kintsugi_cfg()

    # In day-mode Ouroboros, process up to cfg.batch_days days per repair.
    # Each day is independently processed (processor reset at boundaries)
    # so the day-mode invariant is always preserved. Multi-day batching
    # dramatically speeds up backfill for large gaps (e.g., 288-day gap
    # at BTCUSDT@1000 takes 288 passes at 1 day/pass vs 10 passes at 30).
    # Fair queuing is preserved by max_repairs per pass.
    num_days = (gap_end_date - gap_start_date).days + 1
    batch_days = min(num_days, cfg.batch_days) if ouroboros_mode == "day" else num_days
    if num_days > batch_days:
        gap_end_date = gap_start_date + timedelta(days=batch_days - 1)
        logger.info(
            "kintsugi day-recompute: %s@%d gap spans %d days (%s to %s), "
            "batching %d days (remaining %d on next pass)",
            shard.symbol,
            shard.threshold_decimal_bps,
            num_days,
            gap_start_date,
            gap_end_date,
            batch_days,
            num_days - batch_days,
        )

    # Build list of (date, day_start_us, day_end_us) for each day in the batch.
    days_to_repair: list[tuple[object, int, int]] = []
    current_date = gap_start_date
    while current_date <= gap_end_date:
        day_start_us = int(
            datetime(
                current_date.year, current_date.month, current_date.day, tzinfo=UTC
            ).timestamp()
            * 1_000_000
        )
        day_end_us = int(
            datetime(
                current_date.year,
                current_date.month,
                current_date.day,
                23,
                59,
                59,
                999000,
                tzinfo=UTC,
            ).timestamp()
            * 1_000_000
        )
        days_to_repair.append((current_date, day_start_us, day_end_us))
        current_date += timedelta(days=1)

    total_bars = 0
    if cfg.batch_workers <= 1 or len(days_to_repair) <= 1:
        # Sequential path (single worker or single day).
        for i, (repair_date, day_start_us, day_end_us) in enumerate(days_to_repair):
            logger.info(
                "kintsugi day-recompute: %s@%d — replacing bars for %s "
                "(%d/%d in batch, crash-safe per-chunk replace)",
                shard.symbol,
                shard.threshold_decimal_bps,
                repair_date,
                i + 1,
                len(days_to_repair),
            )
            bars = _repair_under_lock(
                shard=shard,
                day_start_us=day_start_us,
                day_end_us=day_end_us,
                repair_date=repair_date,
                ouroboros_mode=ouroboros_mode,
                include_microstructure=include_microstructure,
                verbose=verbose,
            )
            total_bars += bars
    else:
        # Parallel path: each day is independent (separate trades fetch,
        # fresh processor, independent CH write). ClickHouse mutation
        # check (>5 pending) in _repair_under_lock() provides natural
        # backpressure — days that hit mutation pressure return 0 and
        # are rediscovered on the next pass.
        import concurrent.futures

        workers = min(cfg.batch_workers, len(days_to_repair))
        logger.info(
            "kintsugi day-recompute: %s@%d — parallel batch of %d days with %d workers",
            shard.symbol,
            shard.threshold_decimal_bps,
            len(days_to_repair),
            workers,
        )

        def _repair_day(
            args: tuple[int, tuple[object, int, int]],
        ) -> int:
            i, (repair_date, day_start_us, day_end_us) = args
            logger.info(
                "kintsugi day-recompute: %s@%d — replacing bars for %s "
                "(%d/%d in batch, worker-parallel)",
                shard.symbol,
                shard.threshold_decimal_bps,
                repair_date,
                i + 1,
                len(days_to_repair),
            )
            return _repair_under_lock(
                shard=shard,
                day_start_us=day_start_us,
                day_end_us=day_end_us,
                repair_date=repair_date,
                ouroboros_mode=ouroboros_mode,
                include_microstructure=include_microstructure,
                verbose=verbose,
            )

        with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
            futures = [
                executor.submit(_repair_day, (i, day_info))
                for i, day_info in enumerate(days_to_repair)
            ]
            for future in concurrent.futures.as_completed(futures):
                try:
                    total_bars += future.result()
                except Exception:
                    logger.exception(
                        "kintsugi day-recompute: parallel day repair failed "
                        "for %s@%d — will retry on next pass",
                        shard.symbol,
                        shard.threshold_decimal_bps,
                    )

    return total_bars


def _repair_aion(
    shard: Shard,
    *,
    include_microstructure: bool = True,
    verbose: bool = False,  # noqa: ARG001
) -> int:
    """Repair an Aion shard by reprocessing trades in the TID range.

    Unlike day-recompute, Aion repair:
    - Processes the exact TID range [anchor_last_tid+1, resume_first_tid-1]
    - Uses a single continuous processor (no day-boundary resets)
    - Does NOT mark the last bar as orphan (bars connect seamlessly)
    - Sources: Parquet tick cache first, REST fromId fallback

    For genesis shards (is_genesis=True), uses the timestamp range to
    identify dates and processes all available trades continuously.
    """
    import polars as pl

    from opendeviationbar.processors.core import OpenDeviationBarProcessor

    _thread_local.is_exchange_gap = False
    storage = TickStorage()

    # Convert gap timestamps to date range for Parquet lookup
    lo_us = min(shard.gap_start_ms, shard.gap_end_ms)
    hi_us = max(shard.gap_start_ms, shard.gap_end_ms)
    start_date = datetime.fromtimestamp(lo_us / 1_000_000, tz=UTC).date()
    end_date = datetime.fromtimestamp(hi_us / 1_000_000, tz=UTC).date()

    # Try Parquet first: load trades for all days in the gap range
    all_trades: list[pl.DataFrame] = []
    parquet_available = True
    current = start_date
    while current <= end_date:
        day_start_ms = int(
            datetime(current.year, current.month, current.day, tzinfo=UTC).timestamp()
            * 1000
        )
        day_end_ms = day_start_ms + 86_400_000 - 1
        if storage.has_ticks(shard.symbol, day_start_ms, day_end_ms - 1):
            ticks = storage.read_ticks(shard.symbol, day_start_ms, day_end_ms)
            if not ticks.is_empty():
                all_trades.append(ticks)
        else:
            parquet_available = False
            break
        current += timedelta(days=1)

    if not parquet_available or not all_trades:
        # Fallback to REST time-based fetch for the entire gap range
        _thread_local.last_source = "rest"
        from opendeviationbar.backfill import _process_gap_fallback_starttime

        return _process_gap_fallback_starttime(
            symbol=shard.symbol,
            threshold_decimal_bps=shard.threshold_decimal_bps,
            start_ms=lo_us // 1000,
            end_ms=hi_us // 1000,
            include_microstructure=include_microstructure,
            overlap_strategy="replace",
            _skip_lock=True,
        )

    _thread_local.last_source = "parquet"

    # Combine all daily Parquet files into one continuous frame
    trades_df = pl.concat(all_trades, rechunk=False)

    # v7.3: Pre-compute TID sequentiality on the raw (unfiltered) trades
    # for exchange gap classification. Column-projected, ~3.5ms/file.
    # Anti-pattern: do NOT download Vision ZIPs to compute SHA-256.
    raw_tids = trades_df["agg_trade_id"]
    _tids_sequential = bool((raw_tids.diff().drop_nulls() <= 1).all())
    del all_trades

    # Filter to TID range (interior gaps only -- genesis has no anchor)
    if shard.anchor_last_tid is not None:
        trades_df = trades_df.filter(pl.col("agg_trade_id") > shard.anchor_last_tid)
    if shard.resume_first_tid is not None and not shard.is_genesis:
        trades_df = trades_df.filter(pl.col("agg_trade_id") < shard.resume_first_tid)

    trades_df = _select_trade_columns(trades_df)

    if trades_df.is_empty():
        # Exchange gap classification (v7.3): Parquet has the bounding trades
        # but 0 trades in the TID range → source data is complete, price
        # simply didn't breach the threshold in this range.
        if parquet_available and _tids_sequential:
            _thread_local.is_exchange_gap = True
            logger.info(
                "kintsugi Aion: EXCHANGE_GAP detected — %s@%d TID range [%s, %s] "
                "has 0 trades (Parquet TIDs sequential, source data complete)",
                shard.symbol,
                shard.threshold_decimal_bps,
                shard.anchor_last_tid,
                shard.resume_first_tid,
            )
        return 0

    # Single continuous processor -- NO day resets
    processor = OpenDeviationBarProcessor(
        threshold_decimal_bps=shard.threshold_decimal_bps,
        symbol=shard.symbol,
    )

    chunk_size = 50_000 if include_microstructure else 100_000

    def _trade_batches(ticks: pl.DataFrame = trades_df) -> object:
        n = len(ticks)
        for start in range(0, n, chunk_size):
            yield ticks.slice(start, chunk_size)

    bar_frames: list[pl.DataFrame] = []
    # include_incomplete=True for genesis (no next bar), False for interior gaps
    for bar_batch in _stream_bars_with_processor(
        _trade_batches(),
        processor,
        include_incomplete=shard.is_genesis,
    ):
        bar_frames.append(bar_batch)

    del trades_df, processor

    if not bar_frames:
        return 0

    bars_df = pl.concat(bar_frames, rechunk=False)
    del bar_frames

    if bars_df.is_empty():
        return 0

    # NO orphan marking for Aion interior gaps -- bars are continuous.
    # Genesis Aion: mark last bar orphan (incomplete at forming edge).
    if shard.is_genesis:
        is_orphan_col = [False] * (len(bars_df) - 1) + [True]
        bars_df = bars_df.with_columns(pl.Series("is_orphan", is_orphan_col))

    from opendeviationbar import __version__

    cache = _get_worker_cache()
    mode = _get_mode_for_symbol(shard.symbol)
    written = cache.store_bars_batch(
        symbol=shard.symbol,
        threshold_decimal_bps=shard.threshold_decimal_bps,
        bars=bars_df,
        version=f"{__version__}+kintsugi-aion",
        overlap_strategy="replace",
        ouroboros_mode=mode,
        _skip_lock=True,
    )

    del bars_df

    logger.info(
        "kintsugi Aion repair: %s@%d -- %d bars from TID %s to %s (source=parquet)",
        shard.symbol,
        shard.threshold_decimal_bps,
        written,
        shard.anchor_last_tid,
        shard.resume_first_tid,
    )
    return written


def _try_parquet_day_replace(  # noqa: PLR0911
    *,
    shard: Shard,
    day_start_us: int,
    day_end_us: int,
    include_microstructure: bool,
) -> int:
    """Try local Parquet tick cache for a single repair day (zero network cost).

    Parquet cache is the highest-priority source because:
    - Zero network bandwidth (reads from ~/.cache/opendeviationbar/ticks/)
    - No API rate limits, no Vision CDN availability dependency
    - Fastest path — pure local I/O + Rust processing

    Returns 0 if not cached (caller falls through to Vision).
    """
    try:
        storage = TickStorage()
        # Use day_end_us - 1 so has_ticks sees a single-day range, not two days.
        # day_end_us is midnight of the NEXT day (exclusive), but has_ticks counts
        # days via _timestamp_to_day which would include the next day at coverage < 100%.
        # TickStorage API expects ms timestamps — convert us->ms.
        day_start_ms = day_start_us // 1000
        day_end_ms = day_end_us // 1000
        if not storage.has_ticks(shard.symbol, day_start_ms, day_end_ms - 1):
            return 0

        logger.info(
            "kintsugi Parquet: %s@%d — using local tick cache for [%d, %d]",
            shard.symbol,
            shard.threshold_decimal_bps,
            day_start_us,
            day_end_us,
        )

        import polars as pl

        from opendeviationbar.processors.core import OpenDeviationBarProcessor

        day_ticks = storage.read_ticks(shard.symbol, day_start_ms, day_end_ms)
        if day_ticks.is_empty():
            return 0

        # TRUST-01: Parquet completeness gate — verify max TID covers the full day.
        # The crossover TID is the first trade of the NEXT day, so all trades up to
        # crossover_tid - 1 belong to the current day. If Parquet max TID is below
        # that boundary, the file is incomplete (missing trailing trades near midnight).
        next_day = datetime.fromtimestamp(
            day_start_us / 1_000_000, tz=UTC
        ).date() + timedelta(days=1)
        crossover_tid = get_crossover_tid(shard.symbol, next_day)
        if crossover_tid is not None:
            max_tid = day_ticks["agg_trade_id"].max()
            if max_tid < crossover_tid - 1:
                missing = crossover_tid - 1 - max_tid
                logger.warning(
                    "kintsugi Parquet: %s@%d — incomplete for %s: "
                    "max_tid=%d < crossover_tid-1=%d (%d trades missing), "
                    "falling back to Vision/REST",
                    shard.symbol,
                    shard.threshold_decimal_bps,
                    next_day - timedelta(days=1),
                    max_tid,
                    crossover_tid - 1,
                    missing,
                )
                return 0
        else:
            logger.debug(
                "kintsugi Parquet: %s@%d — crossover lookup failed for %s, "
                "proceeding with Parquet anyway",
                shard.symbol,
                shard.threshold_decimal_bps,
                next_day,
            )

        day_ticks = _select_trade_columns(day_ticks)

        # Fresh processor per day — kintsugi repairs are day-mode (each day
        # independently processable from UTC midnight).
        processor = OpenDeviationBarProcessor(
            threshold_decimal_bps=shard.threshold_decimal_bps,
            symbol=shard.symbol,
        )

        def _trade_batches(ticks: pl.DataFrame = day_ticks) -> object:
            chunk_size = 50_000 if include_microstructure else 100_000
            n = len(ticks)
            for start in range(0, n, chunk_size):
                yield ticks.slice(start, chunk_size)

        bar_frames: list[pl.DataFrame] = []
        for bar_batch in _stream_bars_with_processor(
            _trade_batches(),
            processor,
            include_incomplete=True,  # flush orphan bar — critical for low-volatility days
        ):
            bar_frames.append(bar_batch)

        del day_ticks, processor

        if not bar_frames:
            return 0

        bars_df = pl.concat(bar_frames, rechunk=False)
        del bar_frames

        if bars_df.is_empty():
            return 0

        # TRUST-02: Mark the last bar as orphan (incomplete bar flushed at day boundary).
        # include_incomplete=True always flushes the forming bar as the last row.
        is_orphan_col = [False] * (len(bars_df) - 1) + [True]
        bars_df = bars_df.with_columns(pl.Series("is_orphan", is_orphan_col))

        from opendeviationbar import __version__

        source_version = f"{__version__}+kintsugi-parquet"
        cache = _get_worker_cache()
        mode = _get_mode_for_symbol(shard.symbol)
        written = cache.store_bars_batch(
            symbol=shard.symbol,
            threshold_decimal_bps=shard.threshold_decimal_bps,
            bars=bars_df,
            version=source_version,
            overlap_strategy="replace",
            ouroboros_mode=mode,
            _skip_lock=True,
        )

        del bars_df
    except Exception as exc:
        logger.info(
            "kintsugi Parquet: %s@%d failed, falling back to Vision: %s",
            shard.symbol,
            shard.threshold_decimal_bps,
            exc,
        )
        return 0
    else:
        return written


def _try_vision_day_replace(
    *,
    shard: Shard,
    repair_date: object,  # datetime.date
    include_microstructure: bool,
) -> int:
    """Try Vision archives for a single repair day (rate-limit-free, deterministic).

    Uses the same Redis negative cache as backfill.py to avoid repeated probes
    for known-unavailable dates. Vision data is preferred because:
    - No API rate limits (ZIP download from data.binance.vision)
    - Deterministic (same ZIP = same trades every time)
    - Faster for full-day fetches (single HTTP GET vs hundreds of REST pages)

    Streams bars from Vision, writes with overlap_strategy="replace" for crash
    safety. Returns 0 on any failure (caller falls back to REST Ariadne).
    """
    from opendeviationbar.backfill import (
        _is_vision_cached_unavailable,
        _mark_vision_unavailable,
    )

    # Always try Vision — let HTTP 404 be the signal, not a static T-7 cutoff.
    # Binance typically publishes daily archives with a 1-2 day delay, but the
    # old T-7 cutoff was too conservative and forced all recent dates to REST
    # (rate-limited, ~12 min/repair vs ~5 sec for Vision). The negative cache
    # (Redis, 1 hour TTL) prevents repeated 404 hits for truly unavailable dates.
    date_str = str(repair_date)  # YYYY-MM-DD

    if _is_vision_cached_unavailable(shard.symbol, date_str):
        logger.debug(
            "kintsugi Vision: %s@%d — %s cached-unavailable (Redis negative cache)",
            shard.symbol,
            shard.threshold_decimal_bps,
            date_str,
        )
        return 0

    logger.info(
        "kintsugi Vision: trying %s@%d for %s (rate-limit-free)",
        shard.symbol,
        shard.threshold_decimal_bps,
        date_str,
    )

    import polars as pl

    from opendeviationbar.orchestration.helpers import (
        _stream_open_deviation_bars_binance,
    )

    # Stage 1: Download Vision ZIP and stream bars (network/data errors → negative cache).
    # ClickHouse errors (code 344, 209, etc.) must NOT mark Vision unavailable — the data
    # exists on Vision; only the write path failed. Mixing the two caused stale negative
    # cache entries that sent all repairs to REST even when Vision was perfectly available.
    try:
        bar_frames: list[pl.DataFrame] = []
        for bar_batch in _stream_open_deviation_bars_binance(
            shard.symbol,
            date_str,
            date_str,
            shard.threshold_decimal_bps,
            "spot",  # crypto symbols are always spot
            include_microstructure=include_microstructure,
            include_incomplete=True,  # flush orphan — critical for low-volatility days
        ):
            bar_frames.append(bar_batch)
    except Exception as exc:
        # Vision download failed (404, network error, ZIP parse error, etc.).
        # Mark negative cache so we don't hammer Vision for a known-bad date.
        _mark_vision_unavailable(shard.symbol, date_str)
        exc_str = str(exc)
        is_404 = (
            "404" in exc_str or "No data" in exc_str or "not found" in exc_str.lower()
        )
        if is_404:
            logger.info(
                "kintsugi Vision: %s@%d — %s not published (cached 1h): %s",
                shard.symbol,
                shard.threshold_decimal_bps,
                date_str,
                exc,
            )
        else:
            logger.info(
                "kintsugi Vision: %s@%d — %s download failed (cached 1h, falling back to REST): %s",
                shard.symbol,
                shard.threshold_decimal_bps,
                date_str,
                exc,
            )
        return 0

    bars_df = pl.concat(bar_frames, rechunk=False) if bar_frames else pl.DataFrame()
    del bar_frames

    if bars_df.is_empty():
        logger.info(
            "kintsugi Vision: %s@%d — no bars produced for %s",
            shard.symbol,
            shard.threshold_decimal_bps,
            date_str,
        )
        return 0

    # TRUST-02: Mark the last bar as orphan (incomplete bar flushed at day boundary).
    # include_incomplete=True always flushes the forming bar as the last row.
    is_orphan_col = [False] * (len(bars_df) - 1) + [True]
    bars_df = bars_df.with_columns(pl.Series("is_orphan", is_orphan_col))

    # Stage 2: Write bars to ClickHouse. Errors here do NOT mark Vision unavailable —
    # Vision data was successfully downloaded; only the write path failed.
    try:
        from opendeviationbar import __version__

        source_version = f"{__version__}+kintsugi-vision"
        cache = _get_worker_cache()
        mode = _get_mode_for_symbol(shard.symbol)
        written = cache.store_bars_batch(
            symbol=shard.symbol,
            threshold_decimal_bps=shard.threshold_decimal_bps,
            bars=bars_df,
            version=source_version,
            overlap_strategy="replace",
            ouroboros_mode=mode,
            _skip_lock=True,
        )
    except Exception as exc:
        logger.info(
            "kintsugi Vision: %s@%d — %s ClickHouse write failed (NOT cached, falling back to REST): %s",
            shard.symbol,
            shard.threshold_decimal_bps,
            date_str,
            exc,
        )
        return 0
    finally:
        del bars_df

    return written


def _repair_under_lock(
    *,
    shard: Shard,
    day_start_us: int,
    day_end_us: int,
    repair_date: object,  # datetime.date — single day being repaired
    ouroboros_mode: str,  # noqa: ARG001
    include_microstructure: bool,
    verbose: bool,  # noqa: ARG001
) -> int:
    """Execute crash-safe repair for a single day.

    DATA SOURCE PRIORITY: Parquet → Vision → REST.
    1. Local Parquet tick cache (~/.cache/opendeviationbar/ticks/): zero network, fastest.
    2. Binance Vision ZIP archives (rate-limit-free, deterministic, single HTTP GET).
    3. REST time-based fetch (startTime=UTC midnight): last resort, hundreds of pages.
       No Ariadne fromId pagination — always fetch the full day from UTC 00:00:00.000

    CRASH SAFETY: Uses overlap_strategy="replace" which does atomic per-chunk
    DELETE+INSERT via store_bars_replacing(). If killed mid-repair:
    - Completed chunks: correctly replaced (no data loss)
    - Incomplete chunks: old bars still exist (picked up next pass)
    No bars are ever deleted without immediate replacement.
    """
    # Step 1: Try local Parquet tick cache (zero network, highest priority).
    parquet_bars = _try_parquet_day_replace(
        shard=shard,
        day_start_us=day_start_us,
        day_end_us=day_end_us,
        include_microstructure=include_microstructure,
    )
    if parquet_bars > 0:
        logger.info(
            "kintsugi day-recompute: Parquet wrote %d bars for %s@%d on %s",
            parquet_bars,
            shard.symbol,
            shard.threshold_decimal_bps,
            repair_date,
        )
        _thread_local.last_source = "parquet"
        return parquet_bars

    # Step 2: Try Vision archives (rate-limit-free, deterministic).
    vision_bars = _try_vision_day_replace(
        shard=shard,
        repair_date=repair_date,
        include_microstructure=include_microstructure,
    )
    if vision_bars > 0:
        logger.info(
            "kintsugi day-recompute: Vision wrote %d bars for %s@%d on %s",
            vision_bars,
            shard.symbol,
            shard.threshold_decimal_bps,
            repair_date,
        )
        _thread_local.last_source = "vision"
        return vision_bars

    # Step 3: Vision unavailable — fall back to REST time-based fetch.
    # In day-mode Ouroboros, each day is independently processable. We fetch
    # ALL trades for the day by time range (startTime/endTime), process through
    # a fresh processor, and write bars. No Ariadne anchor needed — this makes
    # parallel workers truly independent (no queries against adjacent days' bars).
    #
    # REST fallback — Vision was unavailable (404 or negative-cached).
    # For very recent dates (today-1, today-2) this is expected. For older
    # dates it indicates a Vision gap.
    _today_utc = datetime.now(tz=UTC).date()
    _days_ago = (_today_utc - repair_date).days
    _vision_expected_delay_days = 2  # Binance Vision typical publish latency
    if _days_ago > _vision_expected_delay_days:
        logger.warning(
            "kintsugi day-recompute: REST fallback for %s@%d on %s "
            "(%d days ago — Vision should have this, check negative cache)",
            shard.symbol,
            shard.threshold_decimal_bps,
            repair_date,
            _days_ago,
        )
    else:
        logger.info(
            "kintsugi day-recompute: REST time-based fetch for %s@%d on %s "
            "(%d day(s) ago — Vision likely not published yet)",
            shard.symbol,
            shard.threshold_decimal_bps,
            repair_date,
            _days_ago,
        )

    from opendeviationbar.backfill import _process_gap_fallback_starttime

    _thread_local.last_source = "rest"
    return _process_gap_fallback_starttime(
        symbol=shard.symbol,
        threshold_decimal_bps=shard.threshold_decimal_bps,
        start_ms=day_start_us // 1000,
        end_ms=day_end_us // 1000,
        include_microstructure=include_microstructure,
        overlap_strategy="warn",  # plain INSERT; ReplacingMergeTree dedup handles safety
        _skip_lock=True,  # prevent deadlock: outer lock already held
    )
