"""Shard discovery: Stathera trade-ID audit and genesis gap detection."""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta

from opendeviationbar.symbol_registry import get_kintsugi_priority

logger = logging.getLogger(__name__)

# Number of days by which the oldest bar must post-date effective_start before
# we treat the symbol as needing genesis seeding.  30 days gives newly-enabled
# symbols a grace period before kintsugi starts backfilling.
_GENESIS_THRESHOLD_DAYS = 30


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


@dataclass
class Shard:
    """A Stathera anomaly — consecutive bars with tid_delta != 0 (Issue #158).

    Covers both gaps (tid_delta > 0, missing trades) and overlaps
    (tid_delta < 0, duplicate trade-ID ranges from concurrent writers).

    Identified by anchor_last_tid and resume_first_tid. Repaired by
    day-recompute: delete all bars for the affected Ouroboros day(s) and
    reprocess from scratch via Vision or REST time-based fetch.
    """

    symbol: str
    threshold_decimal_bps: int
    gap_start_ms: int
    gap_end_ms: int
    gap_hours: float
    ouroboros_boundaries: list[int] = field(default_factory=list)
    # Issue #158: Stathera trade-ID fields
    anchor_last_tid: int | None = None  # last_agg_trade_id of bar before anomaly
    resume_first_tid: int | None = None  # first_agg_trade_id of bar after anomaly
    missing_trade_count: int = 0  # abs(tid_delta) — trade count for gap or overlap
    is_overlap: bool = False  # True when tid_delta < 0 (overlap, not gap)
    is_genesis: bool = False  # True when no prior bar exists (Issue #274: genesis seeding)


@dataclass
class KintsugiResult:
    """Result of a single repair operation.

    Repairs ALWAYS use day-start fills (Vision ZIP or REST startTime=midnight_ms).
    Ariadne fromId pagination is NOT used in kintsugi repairs.
    """

    shard: Shard
    healed: bool
    bars_written: int
    duration_seconds: float
    had_anchor_tid: bool  # True when shard came from gap between two known bars (interior gap)
    ouroboros_splits: int  # Number of boundary sub-repairs
    error: str | None = None
    source: str | None = None  # "parquet", "vision", or "rest" — which data source succeeded
    is_exchange_gap: bool = False  # True when 0-bar result from checksum-verified Parquet (permanent TID gap)


# ---------------------------------------------------------------------------
# Shard discovery
# ---------------------------------------------------------------------------


def discover_shards(
    *,
    symbol: str | None = None,
    threshold: int | None = None,
) -> list[Shard]:
    """Discover shards via Stathera trade-ID continuity + genesis gaps (Issue #158, #274).

    Combines Stathera audit (gaps/overlaps between existing bars) with genesis
    detection (newly-enabled symbols with no historical ClickHouse data).
    """
    shards = _discover_shards_stathera(symbol=symbol, threshold=threshold)
    shards.extend(_discover_genesis_shards(symbol=symbol, threshold=threshold))

    # Filter out forex/Exness symbols — kintsugi processes Binance crypto only.
    # Exness symbols have no aggTrade IDs; REST API calls fail with HTTP 400.
    from opendeviationbar.symbol_registry import get_symbol_entries

    entries = get_symbol_entries()
    before = len(shards)
    shards = [s for s in shards if s.symbol in entries and entries[s.symbol].exchange == "binance"]
    filtered = before - len(shards)
    if filtered > 0:
        logger.info("discover_shards: filtered %d non-Binance shards (forex/exness)", filtered)

    return shards


def _discover_shards_stathera(
    *,
    symbol: str | None = None,
    threshold: int | None = None,
) -> list[Shard]:
    """Discover shards via Stathera trade-ID continuity (Issue #158).

    Connects directly to ClickHouse and runs the Stathera audit query.
    Each anomaly (tid_delta != 0) becomes a Shard with exact trade-ID anchors.

    Parameters
    ----------
    symbol : str | None
        Filter to specific symbol.
    threshold : int | None
        Filter to specific threshold.

    Returns
    -------
    list[Shard]
        Discovered shards with Stathera trade-ID anchors.
    """
    from opendeviationbar.clickhouse import OpenDeviationBarCache

    from ._helpers import _get_mode_for_symbol

    with OpenDeviationBarCache() as cache:
        # Discover (symbol, threshold) pairs
        if symbol and threshold:
            pairs = [(symbol, threshold)]
        else:
            result = cache.client.query(
                """
                SELECT DISTINCT symbol, threshold_decimal_bps
                FROM opendeviationbar_cache.open_deviation_bars
                WHERE first_agg_trade_id > 0
                ORDER BY symbol, threshold_decimal_bps
                """
            )
            pairs = [(row[0], row[1]) for row in result.result_rows]
            if symbol:
                pairs = [(s, t) for s, t in pairs if s == symbol]
            if threshold:
                pairs = [(s, t) for s, t in pairs if t == threshold]

        # Filter out disabled symbols (respect the Symbol Registry gate).
        # Use symbol-only check here — discovery must see ALL thresholds for enabled
        # symbols, including thresholds not in the current focus-mode thresholds list.
        # Threshold restriction belongs in the repair path, not in anomaly discovery.
        from opendeviationbar.symbol_registry import get_registered_symbols

        _enabled = set(get_registered_symbols(enabled_only=True))
        pairs = [(s, t) for s, t in pairs if s in _enabled]

        # Filter out pairs where threshold is NOT in the symbol's registered
        # thresholds list. The registry is the SSoT — any threshold not in
        # get_thresholds_for_symbol() is legacy data that kintsugi should not repair.
        from opendeviationbar.symbol_registry import get_thresholds_for_symbol

        valid_pairs: list[tuple[str, int]] = []
        for sym, thr in pairs:
            allowed = get_thresholds_for_symbol(sym)
            if thr in allowed:
                valid_pairs.append((sym, thr))
            else:
                logger.debug(
                    "discover: skipping %s@%d (not in registry thresholds %s)",
                    sym,
                    thr,
                    allowed,
                )

        shards: list[Shard] = []

        for sym, thr in valid_pairs:
            # Run Stathera audit query per pair
            from opendeviationbar.clickhouse.stathera_query import (
                FULL_TABLE,
                STATHERA_CORE_COLUMNS,
                per_pair_filter,
                per_pair_window,
            )

            sym_mode = _get_mode_for_symbol(sym)

            result = cache.client.query(
                f"""
                SELECT close_time_us, first_agg_trade_id,
                       last_agg_trade_id, prev_close_time_us,
                       prev_last_tid, tid_delta, rn
                FROM (
                    SELECT {STATHERA_CORE_COLUMNS}
                    FROM {FULL_TABLE} FINAL
                    {per_pair_filter()}
                    {per_pair_window()}
                )
                WHERE rn > 1 AND tid_delta != 0
                """,
                parameters={
                    "symbol": sym,
                    "threshold": thr,
                    "ouroboros": sym_mode,
                },
            )

            for row in result.result_rows:
                close_time_us = row[0]
                _first_tid = row[1]
                _last_tid = row[2]
                prev_close_time_us = row[3]
                prev_last_tid = row[4]
                tid_delta = row[5]
                rn = row[6]

                # Skip first row (no previous bar to compare)
                if rn <= 1 or tid_delta is None:
                    continue

                # Skip clean bars (tid_delta == 0)
                if tid_delta == 0:
                    continue

                # Both gaps (tid_delta > 0) and overlaps (tid_delta < 0)
                # trigger day-recompute: DELETE entire day(s) + Ariadne reprocess.
                is_overlap = tid_delta < 0

                # Compute gap hours from timestamps
                gap_us = close_time_us - (prev_close_time_us or 0)
                gap_hours = gap_us / 3_600_000_000.0

                shard = Shard(
                    symbol=sym,
                    threshold_decimal_bps=thr,
                    gap_start_ms=prev_close_time_us or 0,
                    gap_end_ms=close_time_us,
                    gap_hours=gap_hours,
                    anchor_last_tid=prev_last_tid,
                    resume_first_tid=_first_tid,
                    missing_trade_count=abs(tid_delta),
                    is_overlap=is_overlap,
                )
                shards.append(shard)

    _enrich_shards(shards)

    # Emit GAP_DETECTED hooks
    if shards:
        try:
            from opendeviationbar.hooks import HookEvent, emit_hook

            for shard in shards:
                emit_hook(
                    HookEvent.GAP_DETECTED,
                    symbol=shard.symbol,
                    threshold_dbps=shard.threshold_decimal_bps,
                    gap_hours=shard.gap_hours,
                )
        except (ImportError, OSError, RuntimeError, ValueError):
            logger.debug("failed to emit GAP_DETECTED hooks", exc_info=True)

    return shards


# ---------------------------------------------------------------------------
# Genesis shard discovery (Issue #274)
# ---------------------------------------------------------------------------


def _discover_genesis_shards(
    *,
    symbol: str | None = None,
    threshold: int | None = None,
) -> list[Shard]:
    """Discover genesis gaps — newly-enabled symbols with no historical data (Issue #274).

    For each enabled (symbol, threshold) pair whose oldest ClickHouse bar is more
    than _GENESIS_THRESHOLD_DAYS after the symbol's effective_start, creates ONE
    genesis Shard covering the day immediately before the current oldest bar.

    After each repair the oldest bar recedes by one day and the next kintsugi pass
    discovers the next genesis shard automatically, working backward until the full
    historical range is seeded.  Once seeding reaches effective_start the condition
    no longer fires and standard Stathera discovery handles any intra-day gaps.

    Genesis shards carry is_genesis=True and no anchor_last_tid; _repair() bypasses
    the anchor guard for them.  verify_repair() already returns True when
    anchor_last_tid is None (no-anchor skip).
    """
    from opendeviationbar.clickhouse import OpenDeviationBarCache
    from opendeviationbar.symbol_registry import (
        get_registered_symbols,
        get_symbol_entries,
        get_thresholds_for_symbol,
    )
    from opendeviationbar.threshold import get_min_threshold_for_symbol

    today_utc = datetime.now(tz=UTC).date()
    enabled_symbols: set[str] = set(get_registered_symbols(enabled_only=True))
    if symbol:
        enabled_symbols = {s for s in enabled_symbols if s == symbol}
    if not enabled_symbols:
        return []

    sym_entries = dict(get_symbol_entries())
    shards: list[Shard] = []

    sym_list = "'" + "','".join(sorted(enabled_symbols)) + "'"
    with OpenDeviationBarCache() as cache:
        result = cache.client.query(
            f"""
            SELECT symbol, threshold_decimal_bps,
                   min(toDate(intDiv(open_time_us, 1000000), 'UTC')) AS oldest_bar_date,
                   argMin(first_agg_trade_id, open_time_us)       AS oldest_bar_first_tid
            FROM opendeviationbar_cache.open_deviation_bars
            WHERE close_time_us > 0 AND symbol IN ({sym_list})
            GROUP BY symbol, threshold_decimal_bps
            """
        )
        oldest_by_pair: dict[tuple[str, int], tuple[object, int]] = {
            (row[0], row[1]): (row[2], row[3]) for row in result.result_rows
        }

    for sym in sorted(enabled_symbols):
        entry = sym_entries.get(sym)
        if entry is None:
            continue
        effective_start = getattr(entry, "effective_start", None) or getattr(
            entry, "listing_date", None
        )
        if effective_start is None:
            continue

        min_thr = get_min_threshold_for_symbol(sym)
        for thr in get_thresholds_for_symbol(sym):
            if thr < min_thr:
                continue
            if threshold is not None and thr != threshold:
                continue

            pair_data = oldest_by_pair.get((sym, thr))
            if pair_data is None:
                # No bars at all; start from yesterday
                oldest_bar_date = today_utc
                oldest_bar_first_tid: int | None = None
            else:
                oldest_bar_date, oldest_bar_first_tid = pair_data

            gap_days = (oldest_bar_date - effective_start).days
            if gap_days < _GENESIS_THRESHOLD_DAYS:
                continue  # Already seeded or within grace period

            # Repair the day immediately before the current oldest bar
            target_day = oldest_bar_date - timedelta(days=1)
            if target_day < effective_start or target_day >= today_utc:
                continue  # Clamped — nothing to do

            day_start_us = int(
                datetime.combine(target_day, datetime.min.time(), UTC).timestamp() * 1_000_000
            )
            day_end_us = day_start_us + 86_400_000_000 - 1

            shards.append(
                Shard(
                    symbol=sym,
                    threshold_decimal_bps=thr,
                    gap_start_ms=day_start_us,
                    gap_end_ms=day_end_us,
                    gap_hours=24.0,
                    anchor_last_tid=None,
                    resume_first_tid=oldest_bar_first_tid,
                    missing_trade_count=0,
                    is_genesis=True,
                )
            )
            logger.debug(
                "kintsugi genesis: %s@%d — seeding %s (gap=%dd since effective_start=%s)",
                sym,
                thr,
                target_day,
                gap_days,
                effective_start,
            )

    if shards:
        logger.info(
            "kintsugi genesis discovery: %d genesis shards across %d symbols",
            len(shards),
            len({s.symbol for s in shards}),
        )

    return shards


def _shard_sort_key(shard: Shard) -> tuple[int, int, int]:
    """Sort key: integrity first, then registry priority, then recency.

    Stathera shards (data integrity) always precede genesis shards (historical
    expansion) within the same priority bucket.  Among shards of the same type
    and priority, most-recent gaps are repaired first.
    """
    return (-get_kintsugi_priority(shard.symbol), int(shard.is_genesis), -shard.gap_end_ms)


def _aletheia_purge() -> int:
    """Aletheia: purge cascade-heal race artifacts from kintsugi_log.

    When multiple concurrent workers process shards for the same (symbol,
    threshold) pair, one worker heals the gap while adjacent workers arrive
    to find bars already present and log 'clean, 0-bar'. These entries are
    race artifacts — not genuine sub-threshold data — and would dismiss
    valid shards for 30 days.

    Detection: clean+0-bar entries logged in the last 2 hours for a
    (symbol, threshold) pair that also has healed entries in the same window.
    A genuine sub-threshold gap cannot coincide with active healing.

    Returns the number of artifact entries purged.
    """
    try:
        from opendeviationbar.clickhouse import OpenDeviationBarCache

        from .telemetry import _emit_telemetry

        with OpenDeviationBarCache() as cache:
            # Identify artifact entries: clean+0-bar entries where the same
            # (symbol, threshold) pair also has healed entries within 2 hours.
            count_result = cache.client.query(
                "SELECT count() FROM opendeviationbar_cache.kintsugi_log "
                "WHERE result = 'clean' AND bars_written = 0 "
                "AND timestamp > now() - INTERVAL 2 HOUR "
                "AND (symbol, threshold_decimal_bps) IN ("
                "  SELECT symbol, threshold_decimal_bps "
                "  FROM opendeviationbar_cache.kintsugi_log "
                "  WHERE result = 'healed' "
                "  AND timestamp > now() - INTERVAL 2 HOUR"
                ")"
            )
            n = int(count_result.result_rows[0][0]) if count_result.result_rows else 0
            if n == 0:
                return 0

            # kintsugi_log has no PARTITION BY clause (single implicit partition).
            # ALTER TABLE DELETE is correct here -- table is tiny (90-day TTL).
            # DELETE IN PARTITION cannot be used without an explicit partition key.
            cache.client.command(
                "ALTER TABLE opendeviationbar_cache.kintsugi_log DELETE "
                "WHERE result = 'clean' AND bars_written = 0 "
                "AND timestamp > now() - INTERVAL 2 HOUR "
                "AND (symbol, threshold_decimal_bps) IN ("
                "  SELECT symbol, threshold_decimal_bps "
                "  FROM opendeviationbar_cache.kintsugi_log "
                "  WHERE result = 'healed' "
                "  AND timestamp > now() - INTERVAL 2 HOUR"
                ") "
                "SETTINGS mutations_sync = 1"
            )
            logger.info("aletheia: purged %d cascade-heal race artifacts from kintsugi_log", n)
            _emit_telemetry("aletheia_purge", purged=n)
            return n
    except (OSError, RuntimeError, ConnectionError):
        logger.debug("_aletheia_purge failed", exc_info=True)
        return 0


def _get_dismissed_set() -> set[tuple[str, int, int]]:
    """Batch query: shards dismissed from repair queue.

    Returns set of (symbol, threshold_decimal_bps, gap_start_ms) tuples.
    Two categories:
    1. **Clean (30-day window):** 0-bar results from unverified Parquet — sub-threshold
       gaps that may become healable if new data appears.
    2. **Exchange gap (permanent):** 0-bar results from checksum-verified Parquet —
       Binance never issued those trade IDs, so retrying is futile.

    Single query replaces N per-shard queries (Issue #179 P1-1).

    Calls _aletheia_purge() first to remove cascade-heal race artifacts
    before building the dismissed set.
    """
    _aletheia_purge()
    try:
        from opendeviationbar.clickhouse import OpenDeviationBarCache

        with OpenDeviationBarCache() as cache:
            # 1. Clean shards with 30-day window (sub-threshold, may become healable)
            result = cache.client.query(
                "SELECT DISTINCT symbol, threshold_decimal_bps, gap_start_ms "
                "FROM opendeviationbar_cache.kintsugi_log "
                "WHERE result = 'clean' AND bars_written = 0 "
                "AND timestamp > now() - INTERVAL 30 DAY"
            )
            dismissed = {(row[0], row[1], row[2]) for row in result.result_rows}

            # 2. Exchange gap shards — permanent dismiss (no time window).
            # These are TID ranges where checksum-verified Parquet confirms
            # Binance never issued those trade IDs. Retrying is futile.
            exgap_result = cache.client.query(
                "SELECT DISTINCT symbol, threshold_decimal_bps, gap_start_ms "
                "FROM opendeviationbar_cache.kintsugi_log "
                "WHERE result = 'exchange_gap'"
            )
            dismissed.update(
                (row[0], row[1], row[2]) for row in exgap_result.result_rows
            )

            return dismissed
    except (OSError, RuntimeError, ConnectionError):
        logger.debug("_get_dismissed_set query failed", exc_info=True)
        return set()


def _get_rate_limited_set() -> set[tuple[str, int, int]]:
    """Batch query: shards with >= 3 FAILED attempts in last 24h.

    Returns set of (symbol, threshold_decimal_bps, gap_start_ms) tuples.
    Only counts 'failed' results — 'healed', 'clean', and 'exchange_gap'
    are successful outcomes that should never block re-discovery of new
    boundary gaps from sidecar restarts.
    """
    try:
        from opendeviationbar.clickhouse import OpenDeviationBarCache

        with OpenDeviationBarCache() as cache:
            result = cache.client.query(
                "SELECT symbol, threshold_decimal_bps, gap_start_ms "
                "FROM opendeviationbar_cache.kintsugi_log "
                "WHERE result = 'failed' "
                "AND timestamp > now() - INTERVAL 24 HOUR "
                "GROUP BY symbol, threshold_decimal_bps, gap_start_ms "
                "HAVING count() >= 3"
            )
            return {(row[0], row[1], row[2]) for row in result.result_rows}
    except (OSError, RuntimeError, ConnectionError):
        logger.debug("_get_rate_limited_set query failed", exc_info=True)
        return set()


def _shard_is_parquet_cached(shard: Shard) -> bool:
    """Return True if all Ouroboros day(s) in the shard are in the local tick cache.

    When True, the repair worker reads from ``~/.cache/opendeviationbar/ticks/``
    instead of downloading Vision ZIPs.  No network bandwidth is consumed, and
    there is no cascade-heal race (each worker reads its own date file independently),
    so a higher concurrency limit applies.
    """
    try:
        from opendeviationbar.storage.parquet import TickStorage

        storage = TickStorage()
        # A shard covers [gap_start_ms, gap_end_ms]. Check the whole range.
        return storage.has_ticks(
            shard.symbol,
            shard.gap_start_ms,
            shard.gap_end_ms,
        )
    except Exception:
        return False


def _shard_has_vision_available(shard: Shard) -> bool:
    """Return True if Vision archives are likely available for this shard's date range.

    Checks the Redis negative cache (populated by backfill.py on Vision 404).
    If no 404 is cached, Vision is presumed available -- the actual HTTP check
    happens at repair time. This is a fast heuristic for concurrency planning,
    not a guarantee.
    """
    try:
        from opendeviationbar.backfill import _is_vision_cached_unavailable

        # Check each Ouroboros day in the shard range
        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()

        current = gap_start_date
        while current <= gap_end_date:
            if _is_vision_cached_unavailable(shard.symbol, current.strftime("%Y-%m-%d")):
                return False  # At least one day has a cached Vision 404
            current += timedelta(days=1)
        return True  # noqa: TRY300 — No cached 404s, Vision presumed available
    except Exception:
        return True  # Optimistic: assume Vision available if check fails


def _select_repair_candidates(
    shards: list[Shard],
    max_repairs: int,
) -> list[Shard]:
    """Select shards eligible for repair this pass.

    Applies three filters in order:
    1. **Dismissed**: Skip shards confirmed as 0-bar (gap too short) in last 30d.
    2. **Budget**: At most ``max_repairs`` shards per pass.
    3. **Rate limit**: Skip shards with ≥3 non-transient attempts in 24h.

    Uses batch ClickHouse queries (2 queries total) instead of per-shard
    queries (was ~2xN queries for N shards). Issue #179 P1-1.
    """
    dismissed = _get_dismissed_set()
    rate_limited = _get_rate_limited_set()

    candidates: list[Shard] = []
    budget = max_repairs
    n_dismissed = 0
    n_rate_limited = 0

    for shard in shards:
        key = (shard.symbol, shard.threshold_decimal_bps, shard.gap_start_ms)

        if key in dismissed:
            n_dismissed += 1
            continue

        if budget <= 0:
            break

        if key in rate_limited:
            n_rate_limited += 1
            continue

        budget -= 1
        candidates.append(shard)

    if n_dismissed > 0:
        logger.info("skipped %d dismissed shards (0-bar in last 30d)", n_dismissed)
    if n_rate_limited > 0:
        logger.info("skipped %d rate-limited shards", n_rate_limited)

    return candidates


def _enrich_shards(shards: list[Shard]) -> None:
    """Add Ouroboros boundaries to shards."""
    from opendeviationbar.ouroboros import get_ouroboros_boundaries

    from ._helpers import _get_mode_for_symbol

    if not shards:
        return

    try:
        for shard in shards:
            # Ouroboros boundaries within the gap
            if shard.gap_start_ms > 0 and shard.gap_end_ms > 0:
                start_date = datetime.fromtimestamp(
                    shard.gap_start_ms / 1_000_000,
                    tz=UTC,
                ).date()
                end_date = datetime.fromtimestamp(
                    shard.gap_end_ms / 1_000_000,
                    tz=UTC,
                ).date()
                shard_mode = _get_mode_for_symbol(shard.symbol)

                boundaries = get_ouroboros_boundaries(
                    start_date,
                    end_date,
                    shard_mode,
                )
                shard.ouroboros_boundaries = [b.timestamp_ms for b in boundaries]
    except (OSError, RuntimeError, ConnectionError) as e:
        logger.warning("failed to enrich shards: %s", e)
