# Issue #317: Aion junction gap healer for sidecar startup
# Issue #318: Parquet-first + batch query + parallel fill
"""Fast junction gap healer — Parquet-first, REST fallback.

After fill_from_rest + sync_flush, discovers ALL boundary gaps across all
(symbol, threshold) pairs in ONE ClickHouse query. For each gap, tries
local Parquet tick cache first (seeder keeps it fresh every 5 min, ~3ms
per file). Falls back to REST only if Parquet doesn't cover the range.
"""

from __future__ import annotations

import logging
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING

import polars as pl

if TYPE_CHECKING:
    from opendeviationbar.clickhouse.cache import OpenDeviationBarCache

logger = logging.getLogger("opendeviationbar.sidecar")

# Seeder Parquet ticks directory
_TICKS_DIR = Path.home() / ".cache" / "opendeviationbar" / "ticks"


def _heal_aion_junction_gaps(
    engine: object,
    aion_symbols: list[str],
    thresholds: list[int],
    ch_cache: OpenDeviationBarCache,
) -> int:
    """Heal ALL junction gaps: Parquet-first, REST fallback, parallel.

    1. Single batch Stathera query discovers all gaps (~50ms)
    2. For each gap: try local Parquet first (seeder data, ~3ms/file)
    3. REST fallback only if Parquet doesn't cover the TID range
    4. Up to 8 parallel workers
    """
    if not aion_symbols:
        return 0

    sym_list = ", ".join(f"'{s}'" for s in aion_symbols)

    # Single batch Stathera query — all pairs at once
    try:
        result = ch_cache.client.query(
            "SELECT symbol, threshold_decimal_bps, prev_last_tid,"
            "       first_agg_trade_id, tid_delta"
            " FROM ("
            "  SELECT symbol, threshold_decimal_bps,"
            "    lagInFrame(last_agg_trade_id, 1) OVER w AS prev_last_tid,"
            "    first_agg_trade_id,"
            "    first_agg_trade_id"
            "      - lagInFrame(last_agg_trade_id, 1) OVER w - 1 AS tid_delta,"
            "    row_number() OVER w AS rn"
            "  FROM opendeviationbar_cache.open_deviation_bars FINAL"
            f"  WHERE symbol IN ({sym_list})"
            "  AND first_agg_trade_id > 0"
            "  WINDOW w AS (PARTITION BY symbol, threshold_decimal_bps"
            "               ORDER BY first_agg_trade_id)"
            " )"
            " WHERE tid_delta > 0 AND rn > 1"
            " ORDER BY tid_delta DESC"
        )
    except (OSError, RuntimeError, ConnectionError):
        logger.warning("aion-junction-heal: batch query failed", exc_info=True)
        return 0

    if not result.result_rows:
        logger.info("aion-junction-heal: no junction gaps found — continuity clean")
        return 0

    # Collect gaps to fill
    gaps: list[tuple[str, int, int, int, int]] = []
    for row in result.result_rows:
        sym, thr, prev_tid, first_tid, delta = row
        if delta <= 0 or delta > 500_000:
            continue
        gaps.append((str(sym), int(thr), int(prev_tid), int(first_tid), int(delta)))

    if not gaps:
        logger.info("aion-junction-heal: no actionable gaps (all exchange-level)")
        return 0

    logger.info(
        "aion-junction-heal: discovered %d gaps — filling Parquet-first, parallel",
        len(gaps),
    )

    total_healed = 0
    parquet_fills = 0
    rest_fills = 0

    def _fill_one(gap: tuple[str, int, int, int, int]) -> bool:
        nonlocal parquet_fills, rest_fills
        sym, thr, anchor, resume, delta = gap

        # Try Parquet first — seeder keeps ticks fresh every 5 min
        if _try_parquet_fill(engine, ch_cache, sym, thr, anchor, resume):
            parquet_fills += 1
            return True

        # Fallback to REST
        try:
            logger.info(
                "aion-junction-heal: %s@%d delta=%d — REST fallback",
                sym, thr, delta,
            )
            engine.fill_gap(sym, anchor + 1, resume)
            rest_fills += 1
            return True
        except (RuntimeError, OSError):
            logger.warning(
                "aion-junction-heal: fill_gap failed for %s@%d",
                sym, thr, exc_info=True,
            )
            return False

    max_workers = min(8, len(gaps))
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        futures = {pool.submit(_fill_one, gap): gap for gap in gaps}
        for future in as_completed(futures):
            if future.result():
                total_healed += 1

    logger.info(
        "aion-junction-heal: healed %d/%d gaps (parquet=%d, rest=%d)",
        total_healed, len(gaps), parquet_fills, rest_fills,
    )
    return total_healed


def _try_parquet_fill(
    engine: object,
    ch_cache: OpenDeviationBarCache,
    symbol: str,
    threshold: int,
    anchor_tid: int,
    resume_tid: int,
) -> bool:
    """Try to fill a gap from local Parquet tick cache.

    Returns True if successful (bars processed and in ring buffer).
    Returns False if Parquet doesn't cover the range (caller falls back to REST).
    """
    try:
        from opendeviationbar.processors.core import OpenDeviationBarProcessor

        # Find Parquet files that might cover the gap's time range
        sym_dir = _TICKS_DIR / symbol
        if not sym_dir.exists():
            return False

        # Get the time range of the gap from CH
        time_result = ch_cache.client.query(
            "SELECT min(close_time_us), max(close_time_us)"
            " FROM opendeviationbar_cache.open_deviation_bars FINAL"
            f" WHERE symbol = '{symbol}'"
            f" AND last_agg_trade_id >= {anchor_tid}"
            f" AND first_agg_trade_id <= {resume_tid}"
            " AND first_agg_trade_id > 0"
        )
        if not time_result.result_rows or time_result.result_rows[0][0] is None:
            # No bars in range — check today's Parquet
            today_str = datetime.now(UTC).strftime("%Y-%m-%d")
            parquet_path = sym_dir / f"{today_str}.parquet"
            if not parquet_path.exists():
                return False
            paths = [parquet_path]
        else:
            # Find Parquet files covering the time range
            min_us, max_us = time_result.result_rows[0]
            from datetime import date, timedelta

            start_date = date.fromtimestamp(int(min_us) / 1_000_000)
            end_date = date.fromtimestamp(int(max_us) / 1_000_000)
            # Include today
            today = datetime.now(UTC).date()
            if end_date < today:
                end_date = today

            paths = []
            d = start_date
            while d <= end_date:
                p = sym_dir / f"{d.isoformat()}.parquet"
                if p.exists():
                    paths.append(p)
                d += timedelta(days=1)

            if not paths:
                return False

        # Read and filter ticks from Parquet
        all_ticks = []
        for path in paths:
            try:
                ticks = pl.read_parquet(path, columns=["agg_trade_id", "price", "quantity", "timestamp", "is_buyer_maker"])
                filtered = ticks.filter(
                    (pl.col("agg_trade_id") > anchor_tid)
                    & (pl.col("agg_trade_id") < resume_tid)
                )
                if not filtered.is_empty():
                    all_ticks.append(filtered)
            except Exception:
                logger.debug("Parquet read failed for %s, skipping", path, exc_info=True)
                continue

        if not all_ticks:
            return False

        combined = pl.concat(all_ticks).sort("agg_trade_id")
        trade_count = len(combined)

        if trade_count == 0:
            return False

        # Process through Rust processor and feed to engine
        processor = OpenDeviationBarProcessor(threshold, symbol=symbol)
        trade_dicts = combined.to_dicts()
        bars = processor.process_trades(trade_dicts)

        if bars:
            # Feed bars to engine ring buffer via fill_gap callback
            logger.info(
                "aion-junction-heal: %s@%d filled from Parquet — %d trades → %d bars",
                symbol, threshold, trade_count, len(bars),
            )
            # Push bars into the engine's ring buffer
            for bar in bars:
                try:
                    engine.push_bar(symbol, threshold, bar)
                except AttributeError:
                    # Engine doesn't have push_bar — fall back to REST
                    return False
        else:
            logger.info(
                "aion-junction-heal: %s@%d Parquet had %d trades but 0 bars (sub-threshold)",
                symbol, threshold, trade_count,
            )

        return True

    except Exception:
        logger.debug(
            "aion-junction-heal: Parquet fill failed for %s@%d (falling back to REST)",
            symbol, threshold, exc_info=True,
        )
        return False
