"""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 ._helpers import _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)

    # 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 = "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,
    )


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 opendeviationbar.ouroboros import get_operational_ouroboros_mode

    ouroboros_mode = get_operational_ouroboros_mode()

    # 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_ms out of trade-ID order.
    lo_ms = min(shard.gap_start_ms, shard.gap_end_ms)
    hi_ms = max(shard.gap_start_ms, shard.gap_end_ms)
    gap_start_date = datetime.fromtimestamp(lo_ms / 1000, tz=UTC).date()
    gap_end_date = datetime.fromtimestamp(hi_ms / 1000, tz=UTC).date()

    # Guard (#280): ABSOLUTE today-guard — sidecar owns today, kintsugi owns
    # yesterday and older. No exceptions. Intra-day gaps are handled by the
    # sidecar's Asclepius self-heal loop. This eliminates the recurring
    # concurrent-writer race that caused overlapping bars across 4 fix cycles.
    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

    # 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_ms, day_end_ms) 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_ms = int(
            datetime(
                current_date.year, current_date.month, current_date.day, tzinfo=UTC
            ).timestamp()
            * 1000
        )
        day_end_ms = int(
            datetime(
                current_date.year,
                current_date.month,
                current_date.day,
                23,
                59,
                59,
                999000,
                tzinfo=UTC,
            ).timestamp()
            * 1000
        )
        days_to_repair.append((current_date, day_start_ms, day_end_ms))
        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_ms, day_end_ms) 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_ms=day_start_ms,
                day_end_ms=day_end_ms,
                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_ms, day_end_ms) = 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_ms=day_start_ms,
                day_end_ms=day_end_ms,
                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 _try_parquet_day_replace(
    *,
    shard: Shard,
    day_start_ms: int,
    day_end_ms: 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:
        from opendeviationbar.storage.parquet import TickStorage

        storage = TickStorage()
        # Use day_end_ms - 1 so has_ticks sees a single-day range, not two days.
        # day_end_ms 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%.
        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_ms,
            day_end_ms,
        )

        import polars as pl

        from opendeviationbar.orchestration.helpers import (
            _select_trade_columns,
            _stream_bars_with_processor,
        )
        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

        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

        from opendeviationbar import __version__

        source_version = f"{__version__}+kintsugi-parquet"
        cache = _get_worker_cache()
        written = cache.store_bars_batch(
            symbol=shard.symbol,
            threshold_decimal_bps=shard.threshold_decimal_bps,
            bars=bars_df,
            version=source_version,
            overlap_strategy="replace",
            _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

    # 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()
        written = cache.store_bars_batch(
            symbol=shard.symbol,
            threshold_decimal_bps=shard.threshold_decimal_bps,
            bars=bars_df,
            version=source_version,
            overlap_strategy="replace",
            _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_ms: int,
    day_end_ms: 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_ms=day_start_ms,
        day_end_ms=day_end_ms,
        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_ms,
        end_ms=day_end_ms,
        include_microstructure=include_microstructure,
        overlap_strategy="warn",  # plain INSERT; ReplacingMergeTree dedup handles safety
        _skip_lock=True,  # prevent deadlock: outer lock already held
    )
