#!/usr/bin/env python3
# FILE-SIZE-OK: standalone script, splitting would reduce usability
# Issue #102: Gap Regression Prevention — WAVE-1 gap detector deliverable
"""Stathera: Trade-ID alignment audit for ClickHouse open_deviation_bars table.

Uses Binance aggTrade IDs as the immutable sequence of record. Every bar is an
interval [first_agg_trade_id, last_agg_trade_id]. The invariant:
    bar[i].last_agg_trade_id + 1 == bar[i+1].first_agg_trade_id

Detects gaps (missing trades) and overlaps (bars sharing trade ranges) using
trade-ID continuity.

Usage:
    python detect_gaps.py                        # Stathera audit (default)
    python detect_gaps.py --symbol BTCUSDT       # Check specific symbol
    python detect_gaps.py --json                 # JSON output for programmatic use

Exit codes:
    0 - CLEAN: no gaps or overlaps detected
    1 - ISSUES: gaps, overlaps, or stale data detected
    2 - ERROR: connection or query failure

Environment variables:
    OPENDEVIATIONBAR_CH_HOSTS     SSH alias for ClickHouse host (default: bigblack)
    OPENDEVIATIONBAR_MODE         Connection mode: remote, local, auto (default: remote)
    CLICKHOUSE_HOST       Direct host override (default: localhost)
    CLICKHOUSE_PORT       Port override (default: 8123)

Connection:
    In 'remote' mode (default), establishes an SSH tunnel to OPENDEVIATIONBAR_CH_HOSTS
    and queries ClickHouse through the tunnel. In 'local' mode, connects to
    localhost directly. The script manages tunnel lifecycle automatically.
"""

from __future__ import annotations

import argparse
import contextlib
import json
import os
import socket
import subprocess
import sys
import time
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import clickhouse_connect

from opendeviationbar.symbol_registry import get_thresholds_for_symbol

# =============================================================================
# Constants (inlined to avoid opendeviationbar package dependency)
# =============================================================================

# TIER1_SYMBOLS from python/opendeviationbar/constants.py
# These are the base names (without USDT suffix) — we append USDT for queries
TIER1_BASES: tuple[str, ...] = (
    "AAVE",
    "ADA",
    "AVAX",
    "BCH",
    "BNB",
    "BTC",
    "DOGE",
    "ETH",
    "FIL",
    "LINK",
    "LTC",
    "NEAR",
    "SOL",
    "SUI",
    "UNI",
    "WIF",
    "WLD",
    "XRP",
)

# Additional symbols that may be in ClickHouse but not in TIER1
# (DOT, ATOM, ETC, XLM, SHIB, TRX, ZEC, HBAR, PAXG, PEPE, TON)
EXTRA_BASES: tuple[str, ...] = (
    "ATOM",
    "DOT",
    "ETC",
    "HBAR",
    "PAXG",
    "PEPE",
    "SHIB",
    "TON",
    "TRX",
    "XLM",
    "ZEC",
)

ALL_BASES: tuple[str, ...] = (*TIER1_BASES, *EXTRA_BASES)


# =============================================================================
# Issue #126: Resolve ouroboros mode from Settings when not specified via CLI
def _resolve_ouroboros_mode(symbol: str | None = None) -> str:
    """Resolve ouroboros mode, preferring per-symbol registry when symbol is given.

    Resolution order:
    1. Per-symbol registry lookup (symbols.toml ouroboros_mode field)
    2. Global env var OPENDEVIATIONBAR_OUROBOROS_MODE
    3. Fallback: "aion"
    """
    if symbol is not None:
        try:
            from opendeviationbar.symbol_registry import get_symbol_entries

            entries = get_symbol_entries()
            entry = entries.get(symbol)
            if entry is not None:
                return entry.ouroboros_mode.value
        except (ImportError, ValueError, OSError):
            pass
    try:
        from opendeviationbar.ouroboros import get_operational_ouroboros_mode

        return get_operational_ouroboros_mode()
    except (ImportError, ValueError, OSError):
        return "aion"


# SSH Tunnel (minimal standalone implementation)
# =============================================================================


def _find_free_port() -> int:
    """Find a free local port."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("localhost", 0))
        return s.getsockname()[1]


def _is_port_open(host: str, port: int, timeout: float = 1.0) -> bool:
    """Check if TCP port is open."""
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(timeout)
            return s.connect_ex((host, port)) == 0
    except OSError:
        return False


class SSHTunnel:
    """Minimal SSH tunnel manager for ClickHouse access."""

    def __init__(self, ssh_alias: str, remote_port: int = 8123) -> None:
        self.ssh_alias = ssh_alias
        self.remote_port = remote_port
        self.local_port: int | None = None
        self._process: subprocess.Popen[bytes] | None = None

    def start(self, timeout: float = 10.0) -> int:
        """Start tunnel, return local port."""
        self.local_port = _find_free_port()
        self._process = subprocess.Popen(
            [
                "ssh",
                "-N",
                "-o",
                "ExitOnForwardFailure=yes",
                "-o",
                "ServerAliveInterval=30",
                "-o",
                "ServerAliveCountMax=3",
                "-L",
                f"{self.local_port}:localhost:{self.remote_port}",
                self.ssh_alias,
            ],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.PIPE,
        )
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            if _is_port_open("localhost", self.local_port):
                return self.local_port
            exit_code = self._process.poll()
            if exit_code is not None:
                if exit_code == 0:
                    time.sleep(0.3)
                    if _is_port_open("localhost", self.local_port):
                        self._process = None
                        return self.local_port
                stderr = ""
                if self._process.stderr:
                    stderr = self._process.stderr.read().decode()
                self._process = None
                msg = f"SSH tunnel to {self.ssh_alias} failed (exit={exit_code}): {stderr}"
                raise RuntimeError(msg)
            time.sleep(0.2)
        self.stop()
        msg = f"SSH tunnel to {self.ssh_alias} timed out after {timeout}s"
        raise RuntimeError(msg)

    def stop(self) -> None:
        """Stop the tunnel."""
        if self._process is not None:
            try:
                self._process.terminate()
                self._process.wait(timeout=5)
            except (subprocess.TimeoutExpired, OSError):
                try:
                    self._process.kill()
                    self._process.wait(timeout=2)
                except (subprocess.TimeoutExpired, OSError):
                    pass
            finally:
                for stream in (
                    self._process.stdin,
                    self._process.stdout,
                    self._process.stderr,
                ):
                    if stream is not None:
                        with contextlib.suppress(OSError):
                            stream.close()
                self._process = None


# =============================================================================
@dataclass
class CoverageSummary:
    """Coverage summary for a (symbol, threshold) pair."""

    symbol: str
    threshold_dbps: int
    total_bars: int
    earliest_us: int
    latest_us: int
    days_covered: float
    bars_per_day: float
    gap_count: int
    total_gap_hours: float
    max_gap_hours: float
    hours_since_last_bar: float = 0.0
    is_stale: bool = False

    @property
    def earliest_dt(self) -> str:
        return datetime.fromtimestamp(self.earliest_us / 1_000_000, tz=UTC).strftime(
            "%Y-%m-%d"
        )

    @property
    def latest_dt(self) -> str:
        return datetime.fromtimestamp(self.latest_us / 1_000_000, tz=UTC).strftime(
            "%Y-%m-%d"
        )


@dataclass
class StatheraAnomaly:
    """A Stathera invariant violation (gap or overlap in trade-ID space)."""

    symbol: str
    threshold_dbps: int
    anomaly_type: str  # "gap" or "overlap"
    close_time_us: int
    prev_close_time_us: int
    first_agg_trade_id: int
    prev_last_agg_trade_id: int
    tid_delta: int  # >0 = gap (missing trades), <0 = overlap
    opendeviationbar_version: str = ""

    @property
    def close_dt(self) -> str:
        return datetime.fromtimestamp(
            self.close_time_us / 1_000_000,
            tz=UTC,
        ).strftime("%Y-%m-%d %H:%M")

    @property
    def prev_close_dt(self) -> str:
        return datetime.fromtimestamp(
            self.prev_close_time_us / 1_000_000,
            tz=UTC,
        ).strftime("%Y-%m-%d %H:%M")


@dataclass
class StatheraSummary:
    """Stathera audit summary for a (symbol, threshold) pair."""

    symbol: str
    threshold_dbps: int
    total_bars: int
    clean_transitions: int
    gap_count: int
    total_missing_trades: int
    overlap_count: int
    total_overlap_trades: int
    legacy_bars: int  # bars with first_agg_trade_id = 0


@dataclass
class BackfillQueueStatus:
    """Status of the backfill_requests queue."""

    by_status: dict[str, int] = field(default_factory=dict)
    oldest_pending: str | None = None
    max_running_hours: float = 0.0
    is_healthy: bool = True


@dataclass
class DetectionResult:
    """Full detection result."""

    stathera_anomalies: list[StatheraAnomaly] = field(default_factory=list)
    stathera_summaries: list[StatheraSummary] = field(default_factory=list)
    backfill_queue: BackfillQueueStatus | None = None
    checked_pairs: int = 0
    skipped_pairs: int = 0  # pairs with 0 bars
    stale_pairs: list[CoverageSummary] = field(default_factory=list)


# =============================================================================
# Connection
# =============================================================================


def get_client(
    host: str = "localhost",
    port: int = 8123,
) -> clickhouse_connect.driver.Client:
    """Get a ClickHouse client connection."""
    import clickhouse_connect

    client = clickhouse_connect.get_client(host=host, port=port)
    client.command("SELECT 1")  # verify
    return client


def connect() -> tuple[clickhouse_connect.driver.Client, SSHTunnel | None]:
    """Connect to ClickHouse based on environment configuration.

    Returns (client, tunnel). Caller must stop tunnel when done.
    """
    mode = os.getenv("OPENDEVIATIONBAR_MODE", "remote").lower()
    ch_host = os.getenv("CLICKHOUSE_HOST", "localhost")
    ch_port = int(os.getenv("CLICKHOUSE_PORT", "8123"))

    if mode == "local":
        return get_client(ch_host, ch_port), None

    if mode == "remote":
        ssh_alias = (
            os.getenv("OPENDEVIATIONBAR_CH_HOSTS", "bigblack").split(",")[0].strip()
        )
        # Check if already tunneled (e.g., mise run preflight already opened it)
        if _is_port_open("localhost", 18123, timeout=0.5):
            return get_client("localhost", 18123), None
        # Try direct connection to resolved SSH alias
        try:
            result = subprocess.run(
                ["ssh", "-G", ssh_alias],
                capture_output=True,
                text=True,
                timeout=2,
                check=False,
            )
            for line in result.stdout.splitlines():
                if line.startswith("hostname "):
                    resolved_ip = line.split()[1]
                    if _is_port_open(resolved_ip, 8123, timeout=2.0):
                        return get_client(resolved_ip, 8123), None
        except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
            pass
        # Fallback: SSH tunnel
        tunnel = SSHTunnel(ssh_alias)
        local_port = tunnel.start()
        return get_client("localhost", local_port), tunnel

    # auto mode: try localhost first
    if _is_port_open(ch_host, ch_port, timeout=1.0):
        return get_client(ch_host, ch_port), None

    # Fallback to remote
    ssh_alias = os.getenv("OPENDEVIATIONBAR_CH_HOSTS", "bigblack").split(",")[0].strip()
    if ssh_alias:
        tunnel = SSHTunnel(ssh_alias)
        local_port = tunnel.start()
        return get_client("localhost", local_port), tunnel

    print("ERROR: No ClickHouse connection available.", file=sys.stderr)
    print(
        "Set OPENDEVIATIONBAR_MODE=local or OPENDEVIATIONBAR_CH_HOSTS=<ssh-alias>",
        file=sys.stderr,
    )
    sys.exit(2)


# =============================================================================
# Gap Detection Queries
# =============================================================================


def discover_pairs(client: clickhouse_connect.driver.Client) -> list[tuple[str, int]]:
    """Discover all (symbol, threshold) pairs present in ClickHouse."""
    result = client.query("""
        SELECT DISTINCT symbol, threshold_decimal_bps
        FROM opendeviationbar_cache.open_deviation_bars
        ORDER BY symbol, threshold_decimal_bps
    """)
    return [(row[0], row[1]) for row in result.result_rows]


# =============================================================================
# Backfill Queue Health (Phase 10d)
# =============================================================================


def check_backfill_queue(
    client: clickhouse_connect.driver.Client,
) -> BackfillQueueStatus:
    """Check health of the backfill_requests queue."""
    status = BackfillQueueStatus()
    try:
        result = client.query("""
            SELECT
                status,
                count() AS n,
                min(toString(requested_at)) AS oldest,
                max(
                    CASE WHEN status = 'running'
                        THEN dateDiff(
                            'hour', started_at, now()
                        )
                        ELSE 0
                    END
                ) AS max_running_h
            FROM opendeviationbar_cache.backfill_requests FINAL
            GROUP BY status
        """)
        for row in result.result_rows:
            s, n, oldest, max_h = row
            status.by_status[s] = n
            if s == "pending" and oldest:
                status.oldest_pending = oldest
            if s == "running" and max_h:
                status.max_running_hours = float(max_h)
        pending = status.by_status.get("pending", 0)
        if pending > 50 or status.max_running_hours > 4:
            status.is_healthy = False
    except (OSError, RuntimeError):
        # Table may not exist — that's OK
        pass
    return status


# =============================================================================
# Stathera Audit (Trade-ID Alignment)
# =============================================================================


def stathera_audit(
    client: clickhouse_connect.driver.Client,
    symbol: str,
    threshold: int,
    ouroboros_mode: str | None = None,  # SSoT-OK: resolved by _resolve_ouroboros_mode()
) -> tuple[list[StatheraAnomaly], StatheraSummary | None]:
    """Stathera audit for a single (symbol, threshold) pair.

    Uses trade-ID continuity as source of truth:
    - tid_delta == 0 → clean (bar[i+1].first == bar[i].last + 1)
    - tid_delta > 0  → gap (missing aggTrade records)
    - tid_delta < 0  → overlap (bars share aggTrade ranges)

    Window ordered by first_agg_trade_id (not close_time_us) to handle
    overlapping bars correctly.
    """
    if ouroboros_mode is None:
        ouroboros_mode = _resolve_ouroboros_mode(symbol=symbol)

    params: dict = {
        "symbol": symbol,
        "threshold": threshold,
        "ouroboros": ouroboros_mode,
    }

    # Count legacy bars (no trade IDs) separately
    legacy_query = """
        SELECT count()
        FROM opendeviationbar_cache.open_deviation_bars FINAL
        WHERE symbol = {symbol:String}
          AND threshold_decimal_bps = {threshold:UInt32}
          AND ouroboros_mode = {ouroboros:String}
          AND (first_agg_trade_id = 0 OR last_agg_trade_id = 0)
    """
    legacy_result = client.query(legacy_query, parameters=params)
    legacy_bars = legacy_result.result_rows[0][0] if legacy_result.result_rows else 0

    # Core Stathera audit query (shared fragments from stathera_query.py)
    from opendeviationbar.clickhouse.stathera_query import (
        FULL_TABLE,
        STATHERA_CORE_COLUMNS,
        per_pair_filter,
        per_pair_window,
    )

    query = f"""
        SELECT
            close_time_us, first_agg_trade_id, last_agg_trade_id,
            opendeviationbar_version, prev_close_time_us, prev_last_tid, tid_delta
        FROM (
            SELECT opendeviationbar_version,
                   {STATHERA_CORE_COLUMNS}
            FROM {FULL_TABLE} FINAL
            {per_pair_filter()}
            {per_pair_window()}
        )
        WHERE rn > 1 AND tid_delta != 0
        ORDER BY first_agg_trade_id
    """
    result = client.query(query, parameters=params)

    anomalies: list[StatheraAnomaly] = []
    for row in result.result_rows:
        (ts, first_id, _last_id, version, prev_ts, prev_last_id, td) = row
        anomalies.append(
            StatheraAnomaly(
                symbol=symbol,
                threshold_dbps=threshold,
                anomaly_type="gap" if td > 0 else "overlap",
                close_time_us=ts,
                prev_close_time_us=prev_ts,
                first_agg_trade_id=first_id,
                prev_last_agg_trade_id=prev_last_id,
                tid_delta=td,
                opendeviationbar_version=version or "",
            )
        )

    # Total bars with trade IDs for summary
    total_query = """
        SELECT count()
        FROM opendeviationbar_cache.open_deviation_bars FINAL
        WHERE symbol = {symbol:String}
          AND threshold_decimal_bps = {threshold:UInt32}
          AND ouroboros_mode = {ouroboros:String}
          AND first_agg_trade_id > 0
          AND last_agg_trade_id > 0
    """
    total_result = client.query(total_query, parameters=params)
    total_bars = total_result.result_rows[0][0] if total_result.result_rows else 0

    if total_bars == 0 and legacy_bars == 0:
        return [], None

    gap_count = sum(1 for a in anomalies if a.anomaly_type == "gap")
    overlap_count = sum(1 for a in anomalies if a.anomaly_type == "overlap")
    total_missing = sum(a.tid_delta for a in anomalies if a.tid_delta > 0)
    total_overlap = sum(abs(a.tid_delta) for a in anomalies if a.tid_delta < 0)
    # transitions = total_bars - 1 (pairs), anomalies = gaps + overlaps
    clean = max(total_bars - 1 - gap_count - overlap_count, 0)

    summary = StatheraSummary(
        symbol=symbol,
        threshold_dbps=threshold,
        total_bars=total_bars,
        clean_transitions=clean,
        gap_count=gap_count,
        total_missing_trades=total_missing,
        overlap_count=overlap_count,
        total_overlap_trades=total_overlap,
        legacy_bars=legacy_bars,
    )

    return anomalies, summary


def stathera_audit_cross_pair(
    client: clickhouse_connect.driver.Client,
    ouroboros_mode: str | None = None,  # SSoT-OK: resolved by _resolve_ouroboros_mode()
) -> list[dict]:
    """Cross-pair Stathera summary (all symbols/thresholds in one query)."""
    if ouroboros_mode is None:
        ouroboros_mode = _resolve_ouroboros_mode()

    query = """
        SELECT symbol, threshold_decimal_bps,
               countIf(tid_delta = 0) AS clean,
               countIf(tid_delta > 0) AS gaps,
               sumIf(tid_delta, tid_delta > 0) AS total_missing,
               countIf(tid_delta < 0) AS overlaps,
               sumIf(abs(tid_delta), tid_delta < 0) AS total_overlap
        FROM (
            SELECT symbol, threshold_decimal_bps,
                   close_time_us,
                   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
            WHERE first_agg_trade_id > 0 AND last_agg_trade_id > 0
              AND ouroboros_mode = {ouroboros:String}
            WINDOW w AS (PARTITION BY symbol, threshold_decimal_bps,
                                      ouroboros_mode
                         ORDER BY first_agg_trade_id, close_time_us)
        ) WHERE rn > 1
        GROUP BY symbol, threshold_decimal_bps
        ORDER BY symbol, threshold_decimal_bps
    """
    result = client.query(query, parameters={"ouroboros": ouroboros_mode})
    return [
        {
            "symbol": row[0],
            "threshold_dbps": row[1],
            "clean": row[2],
            "gaps": row[3],
            "total_missing": row[4],
            "overlaps": row[5],
            "total_overlap": row[6],
        }
        for row in result.result_rows
    ]


# =============================================================================
# Main Detection Loop
# =============================================================================


def run_detection(
    client: clickhouse_connect.driver.Client,
    symbols: list[str] | None = None,
    thresholds: list[int] | None = None,
    recent_days: int | None = None,
    max_stale_hours: float | None = None,
    check_backfill: bool = False,
    ouroboros_mode: str = "aion",  # SSoT-OK: standalone script, resolved at CLI entry
) -> DetectionResult:
    """Run Stathera trade-ID alignment audit across all (symbol, threshold) pairs."""
    now_us = int(time.time() * 1_000_000)

    # Determine which pairs to check
    if symbols is None and thresholds is None:
        pairs = discover_pairs(client)
    else:
        syms = symbols if symbols is not None else [f"{b}USDT" for b in ALL_BASES]
        thds = thresholds if thresholds is not None else sorted({
            t for s in syms for t in get_thresholds_for_symbol(s)
        })
        pairs = [(s, t) for s in syms for t in thds]

    # Filter to enabled symbols only (respect Symbol Registry gate).
    from opendeviationbar.symbol_registry import filter_pairs_by_registry

    pairs = filter_pairs_by_registry(pairs)

    result = DetectionResult()

    for symbol, threshold in pairs:
        s_anomalies, s_summary = stathera_audit(
            client,
            symbol,
            threshold,
            ouroboros_mode=ouroboros_mode,
        )
        if s_summary is None:
            result.skipped_pairs += 1
            continue
        result.checked_pairs += 1
        result.stathera_anomalies.extend(s_anomalies)
        result.stathera_summaries.append(s_summary)

        # Freshness check (measures data recency)
        ts_result = client.query(
            "SELECT max(close_time_us) "
            "FROM opendeviationbar_cache.open_deviation_bars FINAL "
            "WHERE symbol = {symbol:String} "
            "AND threshold_decimal_bps = {threshold:UInt32} "
            "AND ouroboros_mode = {ouroboros:String}",
            parameters={
                "symbol": symbol,
                "threshold": threshold,
                "ouroboros": ouroboros_mode,
            },
        )
        latest_us = ts_result.result_rows[0][0] if ts_result.result_rows else 0
        if latest_us and max_stale_hours is not None:
            hours_since = (now_us - latest_us) / 3_600_000_000.0
            if hours_since > max_stale_hours:
                stale_cov = CoverageSummary(
                    symbol=symbol,
                    threshold_dbps=threshold,
                    total_bars=s_summary.total_bars,
                    earliest_us=0,
                    latest_us=latest_us,
                    days_covered=0,
                    bars_per_day=0,
                    gap_count=s_summary.gap_count,
                    total_gap_hours=0,
                    max_gap_hours=0,
                    hours_since_last_bar=hours_since,
                    is_stale=True,
                )
                result.stale_pairs.append(stale_cov)

    # Sort results by severity (largest first)
    result.stale_pairs.sort(
        key=lambda c: c.hours_since_last_bar,
        reverse=True,
    )
    result.stathera_anomalies.sort(
        key=lambda a: abs(a.tid_delta),
        reverse=True,
    )

    # Backfill queue health (Phase 10d)
    if check_backfill:
        result.backfill_queue = check_backfill_queue(client)

    return result


# =============================================================================
# Output Formatting
# =============================================================================


def print_text_report(
    result: DetectionResult,
    summary_only: bool = False,
) -> None:
    """Print human-readable Stathera report."""
    stathera_gaps = [a for a in result.stathera_anomalies if a.anomaly_type == "gap"]
    stathera_overlaps = [
        a for a in result.stathera_anomalies if a.anomaly_type == "overlap"
    ]

    print("=" * 90)
    print("  STATHERA TRADE-ID ALIGNMENT REPORT")
    print(f"  Generated: {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}")
    print("=" * 90)
    print()
    print(f"  Pairs checked: {result.checked_pairs}")
    print(f"  Pairs skipped (no data): {result.skipped_pairs}")
    print(f"  Stathera gaps: {len(stathera_gaps)}")
    print(f"  Stathera overlaps: {len(stathera_overlaps)}")
    total_missing = sum(a.tid_delta for a in stathera_gaps)
    total_overlap = sum(abs(a.tid_delta) for a in stathera_overlaps)
    if total_missing:
        print(f"  Total missing trades: {total_missing:,}")
    if total_overlap:
        print(f"  Total overlapping trades: {total_overlap:,}")
    print(f"  Stale pairs: {len(result.stale_pairs)}")
    print()

    # Stathera report sections
    if not summary_only and stathera_gaps:
        print("-" * 90)
        print("  STATHERA GAPS (missing aggTrade records)")
        print("-" * 90)
        print(
            f"  {'Symbol':<12} {'Threshold':>9} "
            f"{'After Bar':<20} {'Missing':>14} "
            f"{'Anchor TID':>16} {'Resume TID':>16}"
        )
        print(
            f"  {'------':<12} {'---------':>9} "
            f"{'----------':<20} {'-------':>14} "
            f"{'----------':>16} {'----------':>16}"
        )
        for a in stathera_gaps[:100]:
            print(
                f"  {a.symbol:<12} {a.threshold_dbps:>6} dbps "
                f"{a.prev_close_dt:<20} {a.tid_delta:>14,} "
                f"{a.prev_last_agg_trade_id:>16,} {a.first_agg_trade_id:>16,}"
            )
        if len(stathera_gaps) > 100:
            print(f"  ... and {len(stathera_gaps) - 100} more")
        print()

    if not summary_only and stathera_overlaps:
        print("-" * 90)
        print("  STATHERA OVERLAPS (bars sharing aggTrade ranges)")
        print("-" * 90)
        print(
            f"  {'Symbol':<12} {'Threshold':>9} "
            f"{'At Bar':<20} {'Overlap':>14} "
            f"{'Version':<20}"
        )
        print(
            f"  {'------':<12} {'---------':>9} "
            f"{'------':<20} {'-------':>14} "
            f"{'-------':<20}"
        )
        for a in stathera_overlaps[:100]:
            print(
                f"  {a.symbol:<12} {a.threshold_dbps:>6} dbps "
                f"{a.close_dt:<20} {abs(a.tid_delta):>14,} "
                f"{a.opendeviationbar_version:<20}"
            )
        if len(stathera_overlaps) > 100:
            print(f"  ... and {len(stathera_overlaps) - 100} more")
        print()

    # Stathera per-pair summary
    if result.stathera_summaries:
        print("-" * 90)
        print("  STATHERA SUMMARY (per pair)")
        print("-" * 90)
        print(
            f"  {'Symbol':<12} {'Threshold':>9} {'Bars':>10} "
            f"{'Clean':>8} {'Gaps':>6} {'Missing':>12} "
            f"{'Overlaps':>9} {'Legacy':>7}"
        )
        print(
            f"  {'------':<12} {'---------':>9} {'----':>10} "
            f"{'-----':>8} {'----':>6} {'-------':>12} "
            f"{'--------':>9} {'------':>7}"
        )
        for s in sorted(
            result.stathera_summaries,
            key=lambda x: (x.symbol, x.threshold_dbps),
        ):
            print(
                f"  {s.symbol:<12} {s.threshold_dbps:>6} dbps "
                f"{s.total_bars:>10,} {s.clean_transitions:>8,} "
                f"{s.gap_count:>6} {s.total_missing_trades:>12,} "
                f"{s.overlap_count:>9} {s.legacy_bars:>7}"
            )
        print()

    # Stale data report
    if result.stale_pairs:
        print("-" * 90)
        print("  STALE DATA (hours since last bar exceeds threshold)")
        print("-" * 90)
        print(
            f"  {'Symbol':<12} {'Threshold':>9} {'Last Bar':<12} {'Hours Stale':>12} {'Bars/Day':>10}"
        )
        print(
            f"  {'------':<12} {'---------':>9} {'--------':<12} {'-----------':>12} {'--------':>10}"
        )
        for cov in result.stale_pairs:
            print(
                f"  {cov.symbol:<12} {cov.threshold_dbps:>6} dbps "
                f"{cov.latest_dt:<12} {cov.hours_since_last_bar:>12.1f} "
                f"{cov.bars_per_day:>10.1f}"
            )
        print()

    # Backfill queue health (Phase 10d)
    if result.backfill_queue is not None:
        bq = result.backfill_queue
        print("-" * 90)
        print("  BACKFILL QUEUE STATUS")
        print("-" * 90)
        for status_name, count in sorted(bq.by_status.items()):
            print(f"  {status_name:<12}: {count}")
        if bq.max_running_hours > 0:
            print(f"  Max running time: {bq.max_running_hours:.1f}h")
        if not bq.is_healthy:
            print("  WARNING: Queue unhealthy (>50 pending or running >4h)")
        print()

    # Final verdict
    print("=" * 90)
    issues = []
    if stathera_gaps:
        worst_gap = stathera_gaps[0]
        issues.append(
            f"{len(stathera_gaps)} Stathera gap(s), "
            f"worst: {worst_gap.tid_delta:,} missing trades "
            f"({worst_gap.symbol}@{worst_gap.threshold_dbps})"
        )
    if stathera_overlaps:
        worst_ov = stathera_overlaps[0]
        issues.append(
            f"{len(stathera_overlaps)} overlap(s), "
            f"worst: {abs(worst_ov.tid_delta):,} trades "
            f"({worst_ov.symbol}@{worst_ov.threshold_dbps})"
        )
    if result.stale_pairs:
        worst = result.stale_pairs[0]
        issues.append(
            f"{len(result.stale_pairs)} stale pair(s), "
            f"worst: {worst.hours_since_last_bar:.1f}h "
            f"({worst.symbol}@{worst.threshold_dbps})"
        )
    bq = result.backfill_queue
    if bq is not None and not bq.is_healthy:
        issues.append("backfill queue unhealthy")
    if issues:
        print(f"  ISSUES DETECTED -- {'; '.join(issues)}")
    else:
        print("  CLEAN -- no gaps, overlaps, or stale data detected")
    print("=" * 90)


def print_json_report(result: DetectionResult) -> None:
    """Print JSON report for programmatic consumption."""
    stathera_gaps = [a for a in result.stathera_anomalies if a.anomaly_type == "gap"]
    stathera_overlaps = [
        a for a in result.stathera_anomalies if a.anomaly_type == "overlap"
    ]
    output: dict = {
        "generated_at": datetime.now(UTC).isoformat(),
        "checked_pairs": result.checked_pairs,
        "skipped_pairs": result.skipped_pairs,
        "stathera_gap_count": len(stathera_gaps),
        "stathera_overlap_count": len(stathera_overlaps),
        "stathera_anomalies": [
            {
                **asdict(a),
                "close_dt": a.close_dt,
                "prev_close_dt": a.prev_close_dt,
            }
            for a in result.stathera_anomalies
        ],
        "stathera_summaries": [asdict(s) for s in result.stathera_summaries],
    }

    output["stale_pair_count"] = len(result.stale_pairs)
    output["stale_pairs"] = [
        {
            "symbol": c.symbol,
            "threshold_dbps": c.threshold_dbps,
            "latest_bar": c.latest_dt,
            "hours_since_last_bar": round(
                c.hours_since_last_bar,
                1,
            ),
            "bars_per_day": c.bars_per_day,
        }
        for c in result.stale_pairs
    ]

    if result.backfill_queue is not None:
        output["backfill_queue"] = asdict(result.backfill_queue)
    print(json.dumps(output, indent=2))


# =============================================================================
# CLI
# =============================================================================


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Stathera: Trade-ID alignment audit for ClickHouse open_deviation_bars.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python detect_gaps.py                        # All symbols, all thresholds
  python detect_gaps.py --symbol BTCUSDT       # Single symbol
  python detect_gaps.py --threshold 500        # Single threshold, all symbols
  python detect_gaps.py --recent-days 7        # Only check last 7 days
  python detect_gaps.py --json                 # Machine-readable output
  python detect_gaps.py --summary              # Skip individual gap listing
        """,
    )
    parser.add_argument(
        "--symbol",
        type=str,
        default=None,
        help="Check specific symbol (e.g., BTCUSDT). Default: all discovered symbols.",
    )
    parser.add_argument(
        "--threshold",
        type=int,
        default=None,
        help="Check specific threshold in dbps (e.g., 500). Default: all discovered thresholds.",
    )
    parser.add_argument(
        "--recent-days",
        type=int,
        default=None,
        help="Only check bars from the last N days (default: check all data).",
    )
    parser.add_argument(
        "--max-stale-hours",
        type=float,
        default=None,
        help="Flag pairs where last bar is older than N hours (freshness check). "
        "Default: disabled. Recommended: 72 for monitoring.",
    )
    parser.add_argument(
        "--summary",
        action="store_true",
        help="Print coverage summary only, skip individual gap listing.",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help="Output as JSON for programmatic consumption.",
    )
    parser.add_argument(
        "--tier1-only",
        action="store_true",
        help="Only check TIER1 symbols (18 symbols x standard thresholds). "
        "Ignores non-TIER1 data in ClickHouse.",
    )
    parser.add_argument(
        "--ouroboros-mode",
        type=str,
        default=None,
        choices=["day", "aion"],
        help="Ouroboros mode filter (default: auto-detect from per-symbol registry).",
    )
    parser.add_argument(
        "--exhaustive",
        action="store_true",
        help="Scan ALL data (no --recent-days window). "
        "Slower but catches aged-out gaps.",
    )
    parser.add_argument(
        "--check-backfill-queue",
        action="store_true",
        help="Check backfill_requests queue health.",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()

    # Connect
    tunnel = None
    try:
        print("Connecting to ClickHouse...", file=sys.stderr)
        client, tunnel = connect()
        print("Connected.", file=sys.stderr)
    except (OSError, RuntimeError, ImportError) as e:
        print(f"ERROR: Failed to connect to ClickHouse: {e}", file=sys.stderr)
        return 2

    try:
        # Build symbol/threshold filters
        # --tier1-only: deterministic TIER1 x standard thresholds (ignores EXTRA data)
        if args.tier1_only and not args.symbol and not args.threshold:
            symbols = [f"{b}USDT" for b in TIER1_BASES]
            thresholds = sorted({
                t for s in symbols for t in get_thresholds_for_symbol(s)
            })
        else:
            symbols = [args.symbol] if args.symbol else None
            thresholds = [args.threshold] if args.threshold else None

        # --exhaustive overrides --recent-days
        recent_days = args.recent_days
        if args.exhaustive:
            recent_days = None

        print("Running Stathera audit...", file=sys.stderr)
        result = run_detection(
            client,
            symbols=symbols,
            thresholds=thresholds,
            recent_days=recent_days,
            max_stale_hours=args.max_stale_hours,
            check_backfill=args.check_backfill_queue,
            ouroboros_mode=args.ouroboros_mode or _resolve_ouroboros_mode(),
        )

        # Output
        if args.json:
            print_json_report(result)
        else:
            print_text_report(result, summary_only=args.summary)

        # Exit code: 1 if any actionable issues found
        has_issues = bool(
            result.stathera_anomalies
            or result.stale_pairs
            or (result.backfill_queue and not result.backfill_queue.is_healthy)
        )
        return 1 if has_issues else 0

    except (OSError, RuntimeError, ValueError) as e:
        print(f"ERROR: Detection failed: {e}", file=sys.stderr)
        import traceback

        traceback.print_exc(file=sys.stderr)
        return 2
    finally:
        try:
            client.close()
        except OSError as e:
            print(f"WARNING: Failed to close ClickHouse client: {e}", file=sys.stderr)
        if tunnel is not None:
            tunnel.stop()


if __name__ == "__main__":
    sys.exit(main())
