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

from __future__ import annotations

import logging
import time
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta

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,
    _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)

    # Aion repair (#317): TID-range reprocessing. Writer ownership is TID-based —
    # sidecar's committed_floors dedup prevents duplicate bars.
    return _repair_aion(
        shard,
        include_microstructure=include_microstructure,
        verbose=verbose,
    )


def _repair_aion(  # noqa: PLR0915
    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

    _trades_snapshot = trades_df if isinstance(trades_df, pl.DataFrame) else trades_df.collect()

    def _trade_batches() -> Iterator[pl.DataFrame]:
        n = len(_trades_snapshot)
        for start in range(0, n, chunk_size):
            yield _trades_snapshot.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


