# FILE-SIZE-OK: Self-contained heartbeat script (no package imports for Telegram)
#!/usr/bin/env python3
# Issue #122: Ephemeral health heartbeat for Telegram (@obdpy_bot).
#
# Runs every 5 minutes via systemd timer. Behavior:
#   - Healthy: sends silent message, auto-deleted after 15 minutes (~2-3 visible)
#   - Unhealthy: sends LOUD persistent message (never deleted)
#
# Pattern: no off-the-shelf library exists for per-message TTL on Telegram.
# Telegram's deleteMessage API requires tracking message IDs externally.
# We use a JSON state file ({message_id, sent_at} tuples) cleaned up each run.
#
# Usage:
#     python scripts/health_heartbeat.py
"""Ephemeral health heartbeat for Telegram (@obdpy_bot)."""

from __future__ import annotations

import hashlib
import html
import json
import logging
import os
import socket
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import TypedDict

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("health-heartbeat")

# Healthy messages are deleted after this many seconds
_TTL_SECONDS = 15 * 60  # 15 minutes

# State file: stores [{message_id, sent_at}, ...] for pending deletions
_STATE_DIR = Path.home() / ".cache" / "opendeviationbar" / "heartbeat"
_STATE_FILE = _STATE_DIR / "pending_messages.json"
_STATHERA_STATE_FILE = _STATE_DIR / "stathera_counts.json"

# Known Stathera gap fingerprints — specific (symbol, threshold, first_agg_trade_id,
# prev_last_tid) combos where the root cause is definitively identified.
# When ALL active gaps match a known fingerprint, the Telegram alert shows a detailed
# diagnostic block instead of a generic 🔴 alert, so the reader immediately knows
# it's a known transient and no manual action is needed.
_KNOWN_GAP_FINGERPRINTS: dict[tuple, dict] = {
    # ETHUSDT@500: REST pagination bug — fetch_aggtrades_rest() uses startTime+1ms when
    # advancing past a page boundary. Trade 1941397634 (T=1773422823391ms) was at the
    # same millisecond as page-end trade 1941397633, so the next page started at
    # T+1=1773422823392ms, silently skipping it.
    # Self-heals when Binance Vision publishes the day's archive (~24-48h from trade time).
    ("ETHUSDT", 500, 1941397635, 1941397633): {
        "cause": "pagination_bug_same_ms",
        "dropped_trade_id": 1941397634,
        "boundary_ts_ms": 1773422823391,
    },
}


def _load_pending() -> list[dict]:
    """Load pending message records from state file."""
    if not _STATE_FILE.exists():
        return []
    try:
        return json.loads(_STATE_FILE.read_text())
    except (json.JSONDecodeError, OSError):
        return []


def _save_pending(records: list[dict]) -> None:
    """Save pending message records to state file."""
    _STATE_DIR.mkdir(parents=True, exist_ok=True)
    _STATE_FILE.write_text(json.dumps(records))


def _load_stathera_counts() -> dict:
    """Load previous Stathera anomaly counts for delta tracking."""
    if not _STATHERA_STATE_FILE.exists():
        return {}
    try:
        return json.loads(_STATHERA_STATE_FILE.read_text())
    except (json.JSONDecodeError, OSError):
        return {}


def _save_stathera_counts(overlap_count: int, gap_count: int) -> None:
    """Save current Stathera anomaly counts for next heartbeat comparison."""
    _STATE_DIR.mkdir(parents=True, exist_ok=True)
    _STATHERA_STATE_FILE.write_text(
        json.dumps(
            {
                "overlap_count": overlap_count,
                "gap_count": gap_count,
                "timestamp": time.time(),
            }
        )
    )


def _delete_telegram_message(token: str, chat_id: str, message_id: int) -> bool:
    """Delete a Telegram message via Bot API (self-contained, no package import)."""
    import requests

    try:
        r = requests.post(
            f"https://api.telegram.org/bot{token}/deleteMessage",
            json={"chat_id": chat_id, "message_id": message_id},
            timeout=10,
        )
        return r.ok
    except requests.exceptions.RequestException:
        return False


def _delete_expired() -> int:
    """Delete Telegram messages older than TTL. Returns count deleted."""
    records = _load_pending()
    if not records:
        return 0

    token = os.environ.get("OPENDEVIATIONBAR_TELEGRAM_TOKEN", "")
    chat_id = os.environ.get("OPENDEVIATIONBAR_TELEGRAM_CHAT_ID", "90417581")
    if not token:
        return 0

    now = time.time()
    still_pending = []
    deleted = 0

    for rec in records:
        age = now - rec["sent_at"]
        if age >= _TTL_SECONDS:
            if _delete_telegram_message(token, chat_id, rec["message_id"]):
                deleted += 1
                logger.info(
                    "Deleted heartbeat message %d (age %.0fs)",
                    rec["message_id"],
                    age,
                )
            else:
                # Failed to delete — drop it anyway to avoid infinite retries
                logger.warning(
                    "Failed to delete message %d, dropping from state",
                    rec["message_id"],
                )
        else:
            still_pending.append(rec)

    _save_pending(still_pending)
    return deleted


_ALERT_DEDUP_TTL = 3600  # Suppress duplicate alerts for 1 hour


def _get_redis() -> object | None:
    """Get Redis client (best-effort, returns None if unavailable)."""
    try:
        import redis

        r = redis.Redis(
            host=os.environ.get("OPENDEVIATIONBAR_REDIS_HOST", "localhost"),
            port=int(os.environ.get("OPENDEVIATIONBAR_REDIS_PORT", "6379")),
            socket_timeout=2,
        )
        r.ping()
        return r
    except Exception:
        logger.warning("Redis connect failed — continuing without dedup", exc_info=True)
        return None


def _should_alert(redis_client: object | None, condition: str) -> bool:
    """Check if we should send an alert for this condition (Redis dedup).

    Returns True if the alert should be sent (not a duplicate within TTL).
    Always returns True if Redis is unavailable (fail-open).
    """
    if redis_client is None:
        return True
    key = f"odb:heartbeat:alert:{hashlib.md5(condition.encode()).hexdigest()[:12]}"
    try:
        if redis_client.exists(key):  # type: ignore[union-attr]
            return False
        redis_client.setex(key, _ALERT_DEDUP_TTL, "1")  # type: ignore[union-attr]
        return True
    except Exception:
        logger.warning("Redis dedup check failed — sending alert (fail-open)", exc_info=True)
        return True  # Fail-open: send the alert


def _check_oom_kills(service: str, window_minutes: int = 30) -> int:
    """Count OOM kills for a service in the last N minutes via journalctl."""
    try:
        r = subprocess.run(
            [
                "journalctl",
                "--user",
                "-u",
                f"opendeviationbar-{service}",
                "--since",
                f"{window_minutes} min ago",
                "--no-pager",
                "-q",
                "--grep",
                "oom-kill|out-of-memory|killed process",
            ],
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )
        if r.returncode == 0 and r.stdout.strip():
            return len(r.stdout.strip().split("\n"))
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        pass
    return 0


def _check_systemd_overrides() -> list[str]:
    """Detect active systemd override files for opendeviationbar services."""
    overrides = []
    override_base = Path.home() / ".config" / "systemd" / "user"
    if not override_base.exists():
        return overrides
    for service_dir in override_base.glob("opendeviationbar-*.service.d"):
        confs = list(service_dir.glob("*.conf"))
        if confs:
            svc_name = service_dir.name.replace(".service.d", "")
            overrides.append(svc_name)
    return overrides


def _get_kintsugi_daemon_pid() -> int | None:
    """Get kintsugi daemon MainPID from systemd (None if unavailable).

    Checks both normal and catch-up service (only one runs at a time).
    """
    for unit in ("opendeviationbar-kintsugi-catchup", "opendeviationbar-kintsugi"):
        try:
            r = subprocess.run(
                ["systemctl", "--user", "show", unit, "--property=MainPID"],
                capture_output=True,
                text=True,
                timeout=5,
                check=False,
            )
            for line in r.stdout.strip().split("\n"):
                if line.startswith("MainPID="):
                    pid = int(line.split("=", 1)[1])
                    if pid > 0:
                        return pid
        except (FileNotFoundError, subprocess.TimeoutExpired, OSError, ValueError):
            continue
    return None


class _KintsugiNdjsonResult(TypedDict):
    daemon_age_h: float | None
    stuck_repair: str | None
    total_shards: int | None
    active_repair: str | None  # in-flight repair (< 1h, not yet stuck)
    recent_healed: list[str]   # last 3 healed repairs: "BTCUSDT@250 91 bars"


def _parse_kintsugi_ndjson(
    ndjson: Path,
) -> _KintsugiNdjsonResult:
    """Parse kintsugi NDJSON for daemon freshness, stuck detection, and shard stats.

    Returns dict with:
      daemon_age_h: hours since last daemon event (None if no PID-tagged events)
      stuck_repair: description of stuck repair, or None if healthy
      total_shards: total shard count from latest discovery (None if unavailable)
    """
    result = _KintsugiNdjsonResult(
        daemon_age_h=None,
        stuck_repair=None,
        total_shards=None,
        active_repair=None,
        recent_healed=[],
    )
    if not ndjson.exists():
        return result

    # Read tail of file (last 100 lines — cheap even on large files)
    try:
        raw = ndjson.read_text()
    except OSError:
        return result
    lines = raw.strip().split("\n")
    tail = lines[-100:]

    events = []
    for line in tail:
        try:
            events.append(json.loads(line))
        except (json.JSONDecodeError, ValueError):
            continue

    if not events:
        return result

    daemon_pid = _get_kintsugi_daemon_pid()
    now = datetime.now(timezone.utc)

    # Filter to daemon PID if available, otherwise use all events
    if daemon_pid is not None:
        pid_events = [e for e in events if e.get("pid") == daemon_pid]
        if pid_events:
            events = pid_events
        # else: pre-upgrade events without pid — use all

    # Daemon freshness: age of latest event
    try:
        last_ts = datetime.fromisoformat(events[-1]["ts"])
        result["daemon_age_h"] = (now - last_ts).total_seconds() / 3600
    except (KeyError, ValueError):
        pass

    # Stuck/active repair: unmatched repair_start (current daemon only)
    # Only count events from the current daemon PID to avoid orphaned
    # starts from a previous daemon that was killed mid-repair.
    starts = [e for e in events if e.get("event") == "repair_start"]
    completes = [e for e in events if e.get("event") == "repair_complete"]
    if len(starts) > len(completes):
        last_start = starts[-1]
        try:
            start_ts = datetime.fromisoformat(last_start["ts"])
            elapsed_m = (now - start_ts).total_seconds() / 60
            sym = last_start.get("symbol", "?")
            thr = last_start.get("threshold", "?")
            gap_h = last_start.get("gap_hours")
            gap_suffix = f" {gap_h:.0f}h gap" if gap_h is not None else ""
            if elapsed_m > 60:
                result["stuck_repair"] = f"{sym}@{thr} {elapsed_m / 60:.1f}h"
            else:
                result["active_repair"] = (
                    f"{sym}@{thr}{gap_suffix}, {elapsed_m:.0f}m"
                )
        except (KeyError, ValueError):
            pass

    # Recent healed repairs (last 3, for peace-of-mind progress display)
    healed = [
        e for e in events
        if e.get("event") == "repair_complete" and e.get("result") == "healed"
    ]
    for e in healed[-3:]:
        sym = e.get("symbol", "?")
        thr = e.get("threshold", "?")
        bars = e.get("bars_written", "?")
        result["recent_healed"].append(f"{sym}@{thr} {bars} bars")

    # Latest discovery stats (shard queue depth)
    for e in reversed(events):
        if e.get("event") == "discovery_complete":
            # Backward-compat: old NDJSON had p1+p2, new has total_shards
            result["total_shards"] = e.get(
                "total_shards", e.get("p1", 0) + e.get("p2", 0)
            )
            break

    return result


def _collect_service_stats(results: dict[str, bool | str]) -> None:
    """Collect uptime and memory for daemon services."""
    for svc in ("sidecar", "kintsugi"):
        # For kintsugi, check catch-up service if normal is not active
        name = f"opendeviationbar-{svc}"
        if svc == "kintsugi" and results.get("kintsugi_mode") == "catch-up":
            name = "opendeviationbar-kintsugi-catchup"
        try:
            r = subprocess.run(
                [
                    "systemctl",
                    "--user",
                    "show",
                    name,
                    "--property=MemoryCurrent,ExecMainStartTimestamp",
                ],
                capture_output=True,
                text=True,
                timeout=5,
                check=False,
            )
            props = {}
            for line in r.stdout.strip().split("\n"):
                if "=" in line:
                    k, v = line.split("=", 1)
                    props[k] = v

            # Memory (MB)
            mem_bytes = int(props.get("MemoryCurrent", 0))
            if mem_bytes > 0:
                results[f"{svc}_mem"] = f"{mem_bytes / 1024 / 1024:.0f}"

            # Uptime from ExecMainStartTimestamp
            ts_str = props.get("ExecMainStartTimestamp", "")
            if ts_str and ts_str != "n/a":
                # Format: "Mon 2026-03-03 11:39:11 PST"
                # Parse with subprocess date for timezone handling
                try:
                    r2 = subprocess.run(
                        ["date", "-d", ts_str, "+%s"],
                        capture_output=True,
                        text=True,
                        timeout=2,
                        check=False,
                    )
                    if r2.returncode == 0 and r2.stdout.strip():
                        start_epoch = int(r2.stdout.strip())
                        uptime_h = (time.time() - start_epoch) / 3600
                        if uptime_h < 1:
                            results[f"{svc}_uptime"] = f"{uptime_h * 60:.0f}m"
                        elif uptime_h < 24:
                            results[f"{svc}_uptime"] = f"{uptime_h:.1f}h"
                        else:
                            results[f"{svc}_uptime"] = f"{uptime_h / 24:.1f}d"
                except (ValueError, OSError):
                    pass
        except (FileNotFoundError, subprocess.TimeoutExpired, OSError, ValueError):
            pass


def _collect_clickhouse_stats(
    client: object,  # clickhouse_connect Client
    results: dict[str, bool | str],
) -> None:
    """Collect ClickHouse database statistics into results dict."""
    from opendeviationbar.clickhouse.stathera_query import (
        FULL_TABLE as _FT,
    )
    from opendeviationbar.clickhouse.stathera_query import (
        enabled_symbols_sql,
    )

    _sym_filter = enabled_symbols_sql()

    # Total bars, pair count, and active symbol count
    row = client.query(  # type: ignore[union-attr]
        f"SELECT count(), uniqExact(symbol, threshold_decimal_bps), uniqExact(symbol) "
        f"FROM {_FT} WHERE 1=1 {_sym_filter}"
    )
    total_bars, n_pairs, n_symbols = row.result_rows[0]
    results["ch_total_bars"] = f"{total_bars:,}"
    results["ch_pairs"] = str(n_pairs)
    results["ch_symbols"] = str(n_symbols)

    # Database size on disk
    row = client.query(  # type: ignore[union-attr]
        "SELECT sum(bytes_on_disk), count() "
        "FROM system.parts "
        "WHERE database = 'opendeviationbar_cache' AND active"
    )
    bytes_on_disk, active_parts = row.result_rows[0]
    if bytes_on_disk < 1024 * 1024 * 1024:
        results["ch_db_size"] = f"{bytes_on_disk / 1024 / 1024:.1f} MiB"
    else:
        results["ch_db_size"] = f"{bytes_on_disk / 1024 / 1024 / 1024:.2f} GiB"
    results["ch_parts"] = str(active_parts)

    # 24h growth (bars inserted recently via computed_at)
    row = client.query(  # type: ignore[union-attr]
        f"SELECT count() FROM {_FT} "
        f"WHERE computed_at > now() - INTERVAL 24 HOUR {_sym_filter}"
    )
    bars_24h = row.result_rows[0][0]
    results["ch_bars_24h"] = f"+{bars_24h:,}"

    # Per-threshold staleness: top 5 stalest + freshest pair
    row = client.query(  # type: ignore[union-attr]
        f"SELECT symbol, threshold_decimal_bps, "
        f"dateDiff('minute', max(toDateTime(intDiv(close_time_ms, 1000), 'UTC')), now('UTC')) AS stale_min "
        f"FROM {_FT} "
        f"WHERE close_time_ms > 0 {_sym_filter} "
        f"GROUP BY symbol, threshold_decimal_bps "
        f"ORDER BY stale_min DESC"
    )
    if row.result_rows:
        # Stalest 5 pairs
        stalest_lines = []
        for sym, thr, stale_min in row.result_rows[:5]:
            stale_h = stale_min / 60
            age = f"{stale_h:.1f}h" if stale_h < 24 else f"{stale_h / 24:.1f}d"
            stalest_lines.append(f"{sym}@{thr} ({age} ago)")
        results["ch_stalest_pairs"] = "\n".join(stalest_lines)

        # Freshest pair (last in DESC order)
        sym, thr, fresh_min = row.result_rows[-1]
        results["ch_freshest"] = f"{sym}@{thr} ({fresh_min / 60:.1f}h ago)"

    # Per-symbol freshness: latest bar time for each symbol (BTCUSDT first)
    _collect_symbol_freshness(client, results)


# Priority symbols shown first in the freshness section (matches kintsugi)
_HEARTBEAT_PRIORITY_SYMBOLS = ("BTCUSDT", "ETHUSDT")


def _collect_symbol_freshness(
    client: object,  # clickhouse_connect Client
    results: dict[str, bool | str],
) -> None:
    """Collect per-symbol freshness data.  BTCUSDT shown first."""
    from opendeviationbar.clickhouse.stathera_query import (
        FULL_TABLE as _FT,
    )
    from opendeviationbar.clickhouse.stathera_query import (
        enabled_symbols_sql,
    )

    _sym_filter = enabled_symbols_sql()
    row = client.query(  # type: ignore[union-attr]
        f"SELECT symbol, "
        f"  max(toDateTime(intDiv(close_time_ms, 1000), 'UTC')) AS latest_bar, "
        f"  dateDiff('minute', latest_bar, now('UTC')) AS stale_min, "
        f"  count() AS total_bars, "
        f"  min(toDate(intDiv(open_time_ms, 1000), 'UTC')) AS oldest_bar_date "
        f"FROM {_FT} "
        f"WHERE close_time_ms > 0 {_sym_filter} "
        f"GROUP BY symbol "
        f"ORDER BY stale_min ASC"
    )
    if not row.result_rows:
        return

    # Load symbol registry for backfill coverage check (best-effort)
    _sym_entries: dict = {}
    try:
        from opendeviationbar.symbol_registry import get_symbol_entries

        _sym_entries = dict(get_symbol_entries())
    except Exception:
        logger.warning("Symbol registry load failed — skipping coverage check", exc_info=True)

    # Build ordered list: priority symbols first, then by freshness
    priority_rows = []
    other_rows = []
    for sym, latest, stale_min, total_bars, oldest_bar_date in row.result_rows:
        entry = (sym, latest, stale_min, total_bars, oldest_bar_date)
        if sym in _HEARTBEAT_PRIORITY_SYMBOLS:
            priority_rows.append(entry)
        else:
            other_rows.append(entry)

    ordered = priority_rows + other_rows

    # Store as JSON-like list for the formatter
    freshness_lines = []
    needs_backfill: list[str] = []
    needs_seeding: list[str] = []
    _today = datetime.now(timezone.utc).date()
    _genesis_onboarding_days = 30  # symbol < this many days old = genesis case
    for sym, _latest, stale_min, total_bars, oldest_bar_date in ordered[:8]:  # Top 8
        stale_h = stale_min / 60
        if stale_h < 1:
            age_str = f"{stale_min}m"
        elif stale_h < 24:
            age_str = f"{stale_h:.1f}h"
        else:
            age_str = f"{stale_h / 24:.1f}d"
        is_priority = sym in _HEARTBEAT_PRIORITY_SYMBOLS
        prefix = "\u2b50" if is_priority else "  "

        # Coverage check: flag symbols with < 10% of expected minimum bars.
        # Expected minimum = days_since_effective_start x n_thresholds x 1 bar/day.
        # This catches symbols where sidecar is streaming today but historical
        # ClickHouse bars are essentially empty (e.g., ETHUSDT after re-seed).
        #
        # Two cases:
        #   Genesis: symbol just enabled (oldest bar < _genesis_onboarding_days),
        #            effective_start is years ago → needs populate_cache_resumable(),
        #            NOT kintsugi:catchup (kintsugi cannot detect genesis gaps).
        #   Gap:     symbol has older data but < 10% coverage → kintsugi:catchup
        #            will discover and repair Stathera shards.
        backfill_warn = ""
        if _sym_entries:
            entry = _sym_entries.get(sym)
            if entry is not None:
                start = entry.effective_start or entry.listing_date
                days_since = (_today - start).days
                n_thresholds = len(entry.thresholds) if entry.thresholds else 1
                # Floor: 1 bar/day/threshold (actual rates are ~4-10x higher)
                min_expected = days_since * n_thresholds
                if min_expected > 0 and total_bars < min_expected * 0.10:
                    oldest_age_days = (
                        (_today - oldest_bar_date).days
                        if oldest_bar_date is not None
                        else 0
                    )
                    is_genesis = oldest_age_days < _genesis_onboarding_days and days_since > 365
                    if is_genesis:
                        # Newly-enabled symbol: no historical ClickHouse data seeded yet.
                        # kintsugi:catchup cannot help — it only repairs Stathera gaps
                        # between existing bars. Use populate_cache_resumable() first.
                        backfill_warn = (
                            f" \u26a0\ufe0f needs initial seeding"
                            f" ({oldest_age_days}d of data, history from {start})"
                        )
                        needs_seeding.append(sym)
                    else:
                        backfill_warn = " \u26a0\ufe0f needs kintsugi:catchup"
                        needs_backfill.append(sym)

        freshness_lines.append(
            f"{prefix} {sym}: {age_str} ago ({total_bars:,} bars){backfill_warn}"
        )

    results["symbol_freshness"] = "\n".join(freshness_lines)
    if needs_backfill:
        results["symbol_needs_backfill"] = ", ".join(needs_backfill)
    if needs_seeding:
        results["symbol_needs_seeding"] = ", ".join(needs_seeding)


def _is_kintsugi_catchup_active() -> bool:
    """Check if kintsugi-catchup service is active (Issue #158 Phase 9)."""
    try:
        r = subprocess.run(
            ["systemctl", "--user", "is-active", "opendeviationbar-kintsugi-catchup"],
            capture_output=True,
            text=True,
            timeout=5,
            check=False,
        )
        return r.returncode == 0
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        return False


def _detect_hotfix_files(site_pkg: Path) -> list[str]:
    """Detect .py files scp'd to site-packages after wheel install.

    Compares mtime of .py files in the opendeviationbar package against
    the dist-info METADATA (written at wheel install time). Files with
    mtime > METADATA mtime were patched post-install (hotfix).

    Returns list of relative file paths that were hotfixed, empty if none.
    """
    dist_info = list(site_pkg.glob("opendeviationbar-*.dist-info"))
    if not dist_info:
        return []
    metadata = dist_info[0] / "METADATA"
    if not metadata.exists():
        return []

    install_mtime = metadata.stat().st_mtime
    pkg_dir = site_pkg / "opendeviationbar"
    if not pkg_dir.is_dir():
        return []

    # Compare .py files — 60s tolerance for filesystem jitter
    hotfixed = []
    for py_file in pkg_dir.rglob("*.py"):
        if py_file.stat().st_mtime > install_mtime + 60:
            hotfixed.append(str(py_file.relative_to(pkg_dir)))
    return sorted(hotfixed)


def _check_version_drift(results: dict[str, bool | str]) -> None:
    """Check if installed version matches latest GitHub release tag.

    Uses `gh` CLI for authenticated access to private repo.
    On probe failure, reports the failure to Telegram (never silently skips).
    Issue #250: prevents stale binary deployment from going undetected.
    """
    # Detect editable install shadowing (the recurring root cause).
    # A .pth file in site-packages adds the git checkout to sys.path,
    # causing Python to load stale code from disk instead of the PyPI wheel.
    site_pkg = (
        Path(sys.prefix)
        / "lib"
        / f"python{sys.version_info.major}.{sys.version_info.minor}"
        / "site-packages"
    )
    pth_file = site_pkg / "opendeviationbar.pth"
    if pth_file.exists():
        # Auto-heal: remove .pth only if the wheel dist-info is present.
        # Safe: running processes already have the module loaded in memory.
        # The .pth is leftover from `maturin develop`; `deploy:bigblack` installs
        # the wheel but doesn't clean it up.
        # A non-editable wheel has a WHEEL file but no `direct_url.json` with
        # `dir_info.editable = true`.  `maturin develop` and `uv pip install -e`
        # both produce editable dist-infos that also contain a WHEEL file, so
        # checking WHEEL alone is insufficient.
        import json as _json

        def _is_non_editable_wheel(dist_info: Path) -> bool:
            if not (dist_info / "WHEEL").exists():
                return False
            du = dist_info / "direct_url.json"
            if du.exists():
                try:
                    info = _json.loads(du.read_text())
                    if info.get("dir_info", {}).get("editable"):
                        return False  # editable install
                except Exception:
                    pass
            return True

        wheel_dist_infos = list(site_pkg.glob("opendeviationbar-*.dist-info"))
        wheel_present = any(_is_non_editable_wheel(d) for d in wheel_dist_infos)
        if wheel_present:
            try:
                pth_file.unlink()
                logger.info(
                    "auto-heal: removed editable .pth at %s (wheel is active now)",
                    pth_file,
                )
                results["auto_healed_pth"] = str(pth_file)
                # Fall through to normal version check with .pth gone
            except OSError as e:
                logger.warning("auto-heal: failed to remove .pth: %s", e)
                results["version_drift"] = False
                results["version_drift_detail"] = (
                    f"editable .pth shadowing wheel (auto-heal failed: {e})! "
                    "Fix: uv pip uninstall opendeviationbar && "
                    "uv pip install opendeviationbar=={version}"
                )
                return
        else:
            results["version_drift"] = False
            results["version_drift_detail"] = (
                "editable .pth shadowing wheel (no wheel dist-info found)! "
                "Fix: uv pip uninstall opendeviationbar && "
                "uv pip install opendeviationbar=={version}"
            )
            return

    # Get installed version
    try:
        from opendeviationbar import __version__ as installed

        results["version_installed"] = installed
    except ImportError:
        results["version_drift"] = False
        results["version_drift_detail"] = "opendeviationbar not importable"
        return

    # Get latest GitHub release tag via gh CLI (authenticated for private repo)
    try:
        r = subprocess.run(
            [
                "gh",
                "api",
                "repos/terrylica/opendeviationbar-py/releases/latest",
                "--jq",
                ".tag_name",
            ],
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )
        if r.returncode != 0:
            stderr = r.stderr.strip()[:120]
            results["version_drift"] = False
            results["version_drift_detail"] = f"gh probe failed: {stderr}"
            return

        gh_tag = r.stdout.strip().lstrip("v")  # "v13.1.0" → "13.1.0"
        if not gh_tag:
            results["version_drift"] = False
            results["version_drift_detail"] = "empty tag from GitHub"
            return

        if installed == gh_tag:
            results["version_drift"] = True  # healthy: versions match
        else:
            # Detect hotfix: if .py files in site-packages are newer than
            # the dist-info METADATA, individual files were scp'd post-install.
            hotfix_files = _detect_hotfix_files(site_pkg)
            if hotfix_files:
                results["version_drift"] = True  # healthy: hotfix applied
                results["version_drift_detail"] = (
                    f"v{installed}+hotfix ({len(hotfix_files)} files patched)"
                )
            else:
                results["version_drift"] = False  # UNHEALTHY: drift detected
                results["version_drift_detail"] = (
                    f"installed=v{installed} github=v{gh_tag}"
                )

        # Also verify the Rust .so binary version matches the Python version.
        # Catches: git pull updates Python files but .so remains stale.
        try:
            from opendeviationbar._core import __version__ as so_version

            if so_version != installed:
                results["version_drift"] = False
                results["version_drift_detail"] = (
                    f"Python=v{installed} .so=v{so_version} "
                    "(Rust binary stale — rebuild or reinstall wheel)"
                )
        except (ImportError, AttributeError):
            pass  # _core may not expose __version__ in all builds
    except FileNotFoundError:
        results["version_drift"] = False
        results["version_drift_detail"] = "gh CLI not installed"
    except subprocess.TimeoutExpired:
        results["version_drift"] = False
        results["version_drift_detail"] = "gh API timeout (10s)"
    except OSError as e:
        results["version_drift"] = False
        results["version_drift_detail"] = f"gh error: {e}"


_PROMETHEUS_URL = os.environ.get("OPENDEVIATIONBAR_PROMETHEUS_URL", "http://localhost:9090")
_RESOURCE_WINDOW_MINUTES = 15


def _query_prometheus_range(query: str, minutes: int) -> list[float]:
    """Query Prometheus range API, return float values over last N minutes."""
    import urllib.parse
    import urllib.request

    end = time.time()
    start = end - minutes * 60
    params = urllib.parse.urlencode(
        {"query": query, "start": str(start), "end": str(end), "step": "15s"}
    )
    url = f"{_PROMETHEUS_URL}/api/v1/query_range?{params}"
    try:
        req = urllib.request.Request(url, headers={"Accept": "application/json"})
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read())
        if data.get("status") != "success":
            return []
        results_data = data.get("data", {}).get("result", [])
        if not results_data:
            return []
        import math

        values = [float(v[1]) for v in results_data[0].get("values", [])]
        return [v for v in values if not math.isnan(v)]
    except Exception:
        logger.warning("Prometheus query failed", exc_info=True)
        return []


def _smart_peak(values: list[float]) -> float | None:
    """Return outlier-screened peak via Tukey IQR upper fence (Q3 + 1.5*IQR).

    Falls back to raw max when there are fewer than 4 samples.
    Does NOT require fitter — pure numpy IQR is magic-number-free.
    """
    if not values:
        return None
    try:
        import numpy as np

        arr = np.array(values)
        if len(arr) < 4:
            return float(arr.max())
        q1, q3 = float(np.percentile(arr, 25)), float(np.percentile(arr, 75))
        fence = q3 + 1.5 * (q3 - q1)
        inliers = arr[arr <= fence]
        return float(inliers.max()) if len(inliers) > 0 else float(arr.max())
    except ImportError:
        return max(values)


def _collect_resource_peaks(results: dict[str, bool | str]) -> None:
    """Query Prometheus for CPU/RAM/load peaks over the last 15 min.

    Populates:
      resource_cpu_avg, resource_cpu_abs_peak, resource_cpu_typ_peak  (%)
      resource_ram_used_gb, resource_ram_abs_peak_gb, resource_ram_typ_peak_gb
      resource_load_avg, resource_load_abs_peak, resource_load_typ_peak
    All values are best-effort: if Prometheus is unreachable the keys are absent.
    """
    w = _RESOURCE_WINDOW_MINUTES

    # CPU busy (100 - idle)
    cpu_vals = _query_prometheus_range(
        '100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)',
        w,
    )
    if cpu_vals:
        import statistics

        results["resource_cpu_avg"] = f"{statistics.mean(cpu_vals):.1f}"
        results["resource_cpu_abs_peak"] = f"{max(cpu_vals):.1f}"
        sp = _smart_peak(cpu_vals)
        if sp is not None:
            results["resource_cpu_typ_peak"] = f"{sp:.1f}"

    # RAM used (GB) — node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes
    ram_vals = _query_prometheus_range(
        "(node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / 1073741824",
        w,
    )
    if ram_vals:
        import statistics

        results["resource_ram_used_gb"] = f"{statistics.mean(ram_vals):.1f}"
        results["resource_ram_abs_peak_gb"] = f"{max(ram_vals):.1f}"
        sp = _smart_peak(ram_vals)
        if sp is not None:
            results["resource_ram_typ_peak_gb"] = f"{sp:.1f}"

    # 1-min load average
    load_vals = _query_prometheus_range("node_load1", w)
    if load_vals:
        import statistics

        results["resource_load_avg"] = f"{statistics.mean(load_vals):.2f}"
        results["resource_load_abs_peak"] = f"{max(load_vals):.2f}"
        sp = _smart_peak(load_vals)
        if sp is not None:
            results["resource_load_typ_peak"] = f"{sp:.2f}"


def _check_seeder(results: dict[str, bool | str]) -> None:
    """Parse seeder state file and check timer freshness (Issue #270).

    ``seeder_ok`` is a boolean and participates in the healthy gate.  A failing
    seeder means kintsugi falls back to Vision downloads instead of the fast
    Parquet-cached path, degrading repair throughput — important enough to flag
    as UNHEALTHY.  All other ``seeder_*`` keys are strings (informational only).
    """
    state_path = Path("/var/tmp/opendeviationbar-seeder-state.txt")
    try:
        text = state_path.read_text()
        stat = state_path.stat()
    except OSError:
        results["seeder_ok"] = False
        return

    # Parse key=value pairs (the first line is the status word, not key=value)
    kv = dict(line.split("=", 1) for line in text.strip().splitlines() if "=" in line)
    status_line = text.strip().splitlines()[0] if text.strip() else ""
    age_min = (time.time() - stat.st_mtime) / 60

    results["seeder_status"] = status_line
    results["seeder_age_min"] = f"{age_min:.0f}m"

    if status_line == "running":
        # Compute elapsed from original run-start timestamp (preserved across progress writes)
        ts_str = kv.get("timestamp", "")
        if ts_str:
            try:
                import datetime as _dt
                started = _dt.datetime.fromisoformat(ts_str)
                elapsed_min = (
                    _dt.datetime.now(_dt.timezone.utc) - started
                ).total_seconds() / 60
                results["seeder_running_min"] = f"{elapsed_min:.0f}m"
            except ValueError:
                pass
        # Progress fields written every 2 months by _seed_symbol() (new format).
        # Fallback: parse journal tail for the latest "Seeded / downloading" line
        # so live progress is visible even for processes started before this change.
        cur_sym = kv.get("current_symbol", "")
        months_done = kv.get("months_done", "")
        days_done = kv.get("days_done", "")
        if cur_sym:
            results["seeder_current_symbol"] = cur_sym
        if months_done:
            results["seeder_months_done"] = months_done
        if days_done:
            results["seeder_days_done"] = days_done
        if not cur_sym:
            # No state-file progress yet — scrape latest activity from journal
            try:
                r = subprocess.run(
                    ["journalctl", "--user", "-u", "opendeviationbar-seeder",
                     "-n", "20", "--no-pager", "--output=short-iso"],
                    capture_output=True, text=True, timeout=5, check=False,
                )
                import re as _re
                # Match lines like: "Seeded ETHUSDT 2021-07: 31 day(s), 27M trades"
                # or "ETHUSDT 2021-06: downloading 30 missing day(s)"
                latest_month = None
                latest_seeded_line = None
                for line in reversed(r.stdout.strip().splitlines()):
                    m = _re.search(
                        r"(\w+USDT)\s+(\d{4}-\d{2}):\s+"
                        r"(?:downloading (\d+)|(\w.+?)(?:,|$))",
                        line,
                    )
                    if m:
                        latest_month = f"{m.group(1)} {m.group(2)}"
                        latest_seeded_line = line.split("] ")[-1].strip()
                        break
                if latest_month:
                    results["seeder_journal_month"] = latest_month
                if latest_seeded_line:
                    results["seeder_journal_line"] = latest_seeded_line
            except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
                pass
    else:
        results["seeder_seeded"] = kv.get("seeded", "?")
        results["seeder_elapsed"] = kv.get("elapsed_s", "?")
        # Per-symbol seeded day counts (keys like BTCUSDT_seeded=150)
        sym_counts = {k[:-7]: v for k, v in kv.items() if k.endswith("_seeded")}
        if sym_counts:
            results["seeder_symbols"] = " | ".join(
                f"{s}:{c}d" for s, c in sym_counts.items()
            )

    # Non-zero failed= means at least one symbol raised an exception this pass
    failed_str = kv.get("failed", "")
    failed = int(failed_str) if failed_str.isdigit() else 0
    if failed > 0:
        results["seeder_failed"] = str(failed)
        results["seeder_ok"] = False
        return

    # Timer freshness: >15 min = 3 consecutive 5-min triggers missed
    if age_min > 15:
        results["seeder_ok"] = False
        return

    results["seeder_ok"] = status_line in ("ok", "running")


def _run_checks() -> dict[str, bool | str]:
    """Run health checks. Returns dict of check_name -> pass/fail."""
    results: dict[str, bool | str] = {}

    # ClickHouse connectivity
    client = None
    try:
        import clickhouse_connect

        client = clickhouse_connect.get_client(host="localhost", port=8123)
        row = client.query("SELECT 1")
        results["clickhouse"] = row.result_rows[0][0] == 1
    except Exception:
        logger.warning("ClickHouse connectivity check failed", exc_info=True)
        results["clickhouse"] = False

    # Service status (daemons + catch-up)
    # kintsugi and kintsugi-catchup conflict — exactly one should be active.
    catchup_active = _is_kintsugi_catchup_active()
    for svc in ("sidecar", "kintsugi"):
        name = f"opendeviationbar-{svc}"
        try:
            r = subprocess.run(
                ["systemctl", "--user", "is-active", name],
                capture_output=True,
                text=True,
                timeout=5,
                check=False,
            )
            active = r.returncode == 0
            # kintsugi is "healthy" if either kintsugi or kintsugi-catchup is active
            if svc == "kintsugi" and not active and catchup_active:
                active = True
            results[svc] = active
        except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
            results[svc] = False

    # Track which kintsugi mode is running for display
    if catchup_active:
        results["kintsugi_mode"] = "catch-up"
    elif results.get("kintsugi"):
        results["kintsugi_mode"] = "normal"
    else:
        results["kintsugi_mode"] = "stopped"

    # Disk space (>10 GB free)
    try:
        import shutil

        usage = shutil.disk_usage("/")
        free_gb = usage.free / (1024**3)
        results["disk"] = free_gb > 10.0
        results["disk_free_gb"] = f"{free_gb:.1f}"
        results["disk_used_pct"] = f"{usage.used / usage.total * 100:.0f}"
    except OSError:
        results["disk"] = False

    # OOM kill detection: flag services with recent OOM kills (Issue #185)
    for svc in ("sidecar", "kintsugi"):
        oom_count = _check_oom_kills(svc)
        if oom_count > 0:
            results[f"{svc}_oom"] = oom_count
            # Override service status: active + OOM kills = degraded
            results[svc] = False

    # Systemd override detection: warn if kintsugi is restricted (Issue #185)
    overrides = _check_systemd_overrides()
    if overrides:
        results["systemd_overrides"] = ", ".join(overrides)
        # Overrides are informational only — intentional drop-ins (focus-mode,
        # memory tuning) should not cause UNHEALTHY. The ⚠️ display in the
        # heartbeat message already surfaces this warning to the operator.

    # Service uptime and memory (all 3 daemons)
    _collect_service_stats(results)

    # Kintsugi telemetry: parse NDJSON events instead of relying on mtime
    # mtime is unreliable — validation scripts (dry_run) refresh it, masking
    # a stuck daemon.  We read the last 50 lines, filter to the daemon PID,
    # and check the latest daemon event timestamp.
    ndjson = Path.home() / "opendeviationbar-py" / "logs" / "kintsugi.ndjson"
    kintsugi_ndjson = _parse_kintsugi_ndjson(ndjson)
    if kintsugi_ndjson["daemon_age_h"] is not None:
        results["kintsugi_telemetry"] = kintsugi_ndjson["daemon_age_h"] < 2.0
        results["kintsugi_telemetry_age"] = f"{kintsugi_ndjson['daemon_age_h']:.1f}h"
    elif ndjson.exists():
        # Fallback: no PID-tagged events yet (pre-upgrade), use mtime
        age_hours = (time.time() - ndjson.stat().st_mtime) / 3600
        results["kintsugi_telemetry"] = age_hours < 2.0
        results["kintsugi_telemetry_age"] = f"{age_hours:.1f}h"
    else:
        results["kintsugi_telemetry"] = False

    # Stuck repair detection: repair_start without repair_complete > 1h
    if kintsugi_ndjson["stuck_repair"]:
        results["kintsugi_stuck"] = False
        results["kintsugi_stuck_detail"] = kintsugi_ndjson["stuck_repair"]
    elif kintsugi_ndjson["daemon_age_h"] is not None:
        results["kintsugi_stuck"] = True  # no stuck repair = healthy

    # Active repair (in-flight, < 1h): for peace-of-mind visibility
    if kintsugi_ndjson["active_repair"]:
        results["kintsugi_active_repair"] = kintsugi_ndjson["active_repair"]
    if kintsugi_ndjson["recent_healed"]:
        results["kintsugi_recent_healed"] = " | ".join(kintsugi_ndjson["recent_healed"])

    # Shard queue depth (from latest NDJSON discovery_complete event)
    if kintsugi_ndjson["total_shards"] is not None:
        results["kintsugi_total_shards"] = str(kintsugi_ndjson["total_shards"])

    # Kintsugi 24h repair summary (from kintsugi_log)
    # Health boolean uses 1h window (not 24h) so fixes clear the alarm quickly.
    # 24h summary is still shown for context in the Telegram message.
    if client is not None:
        try:
            row = client.query(
                "SELECT countIf(result='healed'), countIf(result='clean'), "
                "countIf(result='failed'), "
                "countIf(result='failed' AND timestamp > now() - INTERVAL 1 HOUR) "
                "FROM opendeviationbar_cache.kintsugi_log "
                "WHERE timestamp > now() - INTERVAL 24 HOUR"
            )
            if not row.result_rows:
                logger.warning("kintsugi_log query returned empty result")
            else:
                healed, clean, failed, failed_recent = row.result_rows[0]
                results["kintsugi_repairs"] = f"{healed}h/{clean}c/{failed}f"
                results["kintsugi_failures"] = failed_recent == 0
                # Total bars written by kintsugi in last 24h
                row2 = client.query(
                    "SELECT sum(bars_written) "
                    "FROM opendeviationbar_cache.kintsugi_log "
                    "WHERE timestamp > now() - INTERVAL 24 HOUR"
                )
                if not row2.result_rows:
                    logger.warning("kintsugi bars_written query returned empty result")
                else:
                    bars_24h = row2.result_rows[0][0] or 0
                    results["kintsugi_bars_24h"] = f"{bars_24h:,}"
        except (OSError, RuntimeError, ConnectionError, ValueError):
            logger.warning("kintsugi repair query failed", exc_info=True)

    # ClickHouse database stats (best-effort, uses existing client)
    if client is not None:
        try:
            _collect_clickhouse_stats(client, results)
        except (OSError, RuntimeError, ConnectionError, ValueError):
            logger.warning("clickhouse stats query failed", exc_info=True)

    # Stathera audit: detect BOTH gaps and overlaps in aggTrade IDs (#238 Phase 3).
    # Gaps (tid_delta > 0): missing aggTrade records between consecutive bars.
    # Overlaps (tid_delta < 0): bars from different sources share trade-ID ranges.
    # Any anomaly is UNHEALTHY — kintsugi day-recompute handles both.
    if client is not None:
        try:
            # Per-pair breakdown with counts.
            # All tid_delta > 0 are real gaps — REST and Vision produce
            # identical deterministic day boundaries (tid_gap=0 at every
            # UTC midnight), so there are no "structural" day-boundary gaps.
            #
            # Classification:
            #   - tid_delta > 0 → gap (missing aggTrade records)
            #   - tid_delta < 0 → overlap (concurrent writers)
            from opendeviationbar.clickhouse.stathera_query import (
                FULL_TABLE,
                cross_pair_filter,
                cross_pair_window,
            )

            # Check for pending async mutations (ALTER TABLE DELETE from kintsugi
            # crash-safe replace). During the window after INSERT but before DELETE
            # completes, FINAL sees both old and new bars — producing spurious
            # tid_delta < 0 overlaps. Skip overlap classification when mutations
            # are in-flight; report as transient.
            pending_mutations_row = client.query(
                "SELECT countIf(is_done = 0) FROM system.mutations "
                "WHERE database = 'opendeviationbar_cache'"
            )
            pending_mutations = (
                pending_mutations_row.result_rows[0][0]
                if pending_mutations_row.result_rows
                else 0
            )
            results["stathera_pending_mutations"] = str(pending_mutations)

            row = client.query(
                f"""
                SELECT countIf(tid_delta < 0) AS overlap_count,
                       countIf(tid_delta > 0) AS gap_count,
                       countIf(tid_delta > 0 AND bar_date = prev_bar_date) AS intra_day_gaps
                FROM (
                    SELECT first_agg_trade_id
                             - lagInFrame(last_agg_trade_id, 1) OVER w - 1
                             AS tid_delta,
                           row_number() OVER w AS rn,
                           toDate(close_time_ms / 1000) AS bar_date,
                           toDate(lagInFrame(close_time_ms, 1) OVER w / 1000) AS prev_bar_date
                    FROM {FULL_TABLE} FINAL
                    {cross_pair_filter()}
                    {cross_pair_window()}
                )
                WHERE tid_delta != 0 AND rn > 1
                """
            )
            overlap_count = row.result_rows[0][0] if row.result_rows else 0
            gap_count = row.result_rows[0][1] if row.result_rows else 0
            intra_day_gaps = row.result_rows[0][2] if row.result_rows else 0

            # Delta tracking: compare with previous heartbeat to detect growth
            prev = _load_stathera_counts()
            prev_overlaps = prev.get("overlap_count", overlap_count)
            prev_gaps = prev.get("gap_count", gap_count)
            overlap_delta = overlap_count - prev_overlaps
            gap_delta = gap_count - prev_gaps
            _save_stathera_counts(overlap_count, gap_count)

            # Health logic:
            # - UNHEALTHY if anomalies are GROWING and kintsugi is NOT healing
            # - HEALTHY if kintsugi is actively healing (counts will decrease)
            # - HEALTHY if counts are stable or decreasing
            # - HEALTHY if pending_mutations > 0 (async DELETE in-flight = transient overlap)
            #
            # Note: kintsugi_stuck=True means "no stuck repair" (healthy pass);
            # kintsugi_stuck=False means "stuck repair detected" (unhealthy).
            kintsugi_healing = (
                results.get("kintsugi") is True
                and results.get("kintsugi_stuck") is not False
            )
            mutations_in_flight = pending_mutations > 0
            anomalies_growing = overlap_delta > 0 or gap_delta > 0
            has_anomalies = overlap_count > 0 or gap_count > 0
            # (#286 Spike F) Progressive backfill gaps are NOT anomalies —
            # they're unfilled days that kintsugi will reach. Only intra-day
            # gaps are true anomalies requiring attention.
            backfill_gaps_only = gap_count > 0 and intra_day_gaps == 0

            if not has_anomalies:
                results["stathera_overlaps"] = True
                results["stathera_gaps"] = True
            elif mutations_in_flight and overlap_count > 0 and gap_count == 0:
                # Overlaps only, with async DELETEs pending: transient state from
                # kintsugi crash-safe replace (INSERT completed, DELETE in-flight).
                # Treat as healthy — overlaps will resolve once mutation completes.
                results["stathera_overlaps"] = True
                results["stathera_gaps"] = True
            elif backfill_gaps_only and overlap_count == 0:
                # (#286 Spike F) All gaps are progressive backfill (no intra-day).
                # Healthy — kintsugi will fill missing days automatically.
                results["stathera_overlaps"] = True
                results["stathera_gaps"] = True
            elif anomalies_growing and not kintsugi_healing:
                results["stathera_overlaps"] = overlap_count == 0
                results["stathera_gaps"] = intra_day_gaps == 0
            else:
                results["stathera_overlaps"] = overlap_count == 0 or kintsugi_healing
                results["stathera_gaps"] = intra_day_gaps == 0 or kintsugi_healing

            results["stathera_overlap_count"] = str(overlap_count)
            results["stathera_gap_count"] = str(gap_count)
            results["stathera_overlap_delta"] = str(overlap_delta)
            results["stathera_gap_delta"] = str(gap_delta)
            results["stathera_intra_day_gaps"] = str(intra_day_gaps)

            # Top 5 unique pairs per anomaly type (separate queries for clarity)
            if overlap_count > 0:
                top = client.query(
                    f"""
                    SELECT concat(symbol, '@', toString(threshold_decimal_bps)) AS pair,
                           count() AS cnt
                    FROM (
                        SELECT symbol, threshold_decimal_bps,
                               first_agg_trade_id
                                 - lagInFrame(last_agg_trade_id, 1) OVER w - 1
                                 AS tid_delta,
                               row_number() OVER w AS rn
                        FROM {FULL_TABLE} FINAL
                        {cross_pair_filter()}
                        {cross_pair_window()}
                    )
                    WHERE tid_delta < 0 AND rn > 1
                    GROUP BY symbol, threshold_decimal_bps
                    ORDER BY cnt DESC
                    LIMIT 5
                    """
                )
                pairs = [f"{r[0]}({r[1]})" for r in top.result_rows]
                if pairs:
                    results["stathera_overlap_pairs"] = ", ".join(pairs)
            if gap_count > 0:
                top = client.query(
                    f"""
                    SELECT concat(symbol, '@', toString(threshold_decimal_bps)) AS pair,
                           count() AS cnt
                    FROM (
                        SELECT symbol, threshold_decimal_bps,
                               first_agg_trade_id
                                 - lagInFrame(last_agg_trade_id, 1) OVER w - 1
                                 AS tid_delta,
                               row_number() OVER w AS rn
                        FROM {FULL_TABLE} FINAL
                        {cross_pair_filter()}
                        {cross_pair_window()}
                    )
                    WHERE tid_delta > 0 AND rn > 1
                    GROUP BY symbol, threshold_decimal_bps
                    ORDER BY cnt DESC
                    LIMIT 5
                    """
                )
                pairs = [f"{r[0]}({r[1]})" for r in top.result_rows]
                if pairs:
                    results["stathera_gap_pairs"] = ", ".join(pairs)

            # Fingerprint active gaps against known root causes.
            # If every active gap matches a known fingerprint, populate
            # "stathera_gap_fingerprints" and "stathera_all_gaps_known" so
            # _format_message() can emit a detailed diagnostic block instead of
            # the generic 🔴 alert.
            if gap_count > 0:
                try:
                    gap_detail = client.query(
                        f"""
                        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 {FULL_TABLE} FINAL
                            {cross_pair_filter()}
                            {cross_pair_window()}
                        )
                        WHERE tid_delta > 0 AND rn > 1
                        ORDER BY tid_delta DESC
                        LIMIT 20
                        """
                    )
                    fingerprints: list[dict] = []
                    all_known = True
                    for row in gap_detail.result_rows:
                        sym, thr, prev_tid, first_tid, tdelta = row
                        key = (sym, int(thr), int(first_tid), int(prev_tid))
                        known = _KNOWN_GAP_FINGERPRINTS.get(key)
                        if known:
                            fingerprints.append(
                                {
                                    "symbol": sym,
                                    "threshold": int(thr),
                                    "first_agg_trade_id": int(first_tid),
                                    "prev_last_tid": int(prev_tid),
                                    "tid_delta": int(tdelta),
                                    **known,
                                }
                            )
                        else:
                            all_known = False
                    results["stathera_gap_fingerprints"] = fingerprints
                    # Informational only — NOT a health gate. Today's intra-day
                    # gaps are expected (Asclepius handles them, #280). The real
                    # health gates are stathera_gaps and stathera_overlaps above.
                    results["stathera_all_gaps_known"] = str(all_known and len(fingerprints) > 0)
                except Exception:
                    logger.warning("Gap fingerprint query failed", exc_info=True)

            # (#264) Cross-midnight bar oracle: detect day-mode invariant violations
            cross_midnight = client.query(
                f"""
                SELECT count() AS violations
                FROM {FULL_TABLE} FINAL
                WHERE toDate(open_time_ms / 1000, 'UTC')
                   != toDate(close_time_ms / 1000, 'UTC')
                """
            )
            cross_midnight_count = (
                cross_midnight.result_rows[0][0] if cross_midnight.result_rows else 0
            )
            results["cross_midnight_bars"] = str(cross_midnight_count)
            if cross_midnight_count > 0:
                # Cross-midnight bars are a separate concern from Stathera gaps.
                # Don't clobber stathera_gaps — kintsugi will clean these up.
                results["cross_midnight"] = cross_midnight_count == 0 or kintsugi_healing
                logger.warning(
                    "Day-mode invariant violated: %d cross-midnight bars",
                    cross_midnight_count,
                )

        except (OSError, RuntimeError, ConnectionError, ValueError):
            logger.debug("stathera audit failed", exc_info=True)

    # Kintsugi memory usage vs limit (check whichever mode is active)
    kintsugi_unit = (
        "opendeviationbar-kintsugi-catchup"
        if catchup_active
        else "opendeviationbar-kintsugi"
    )
    try:
        r = subprocess.run(
            [
                "systemctl",
                "--user",
                "show",
                kintsugi_unit,
                "--property=MemoryCurrent,MemoryHigh",
            ],
            capture_output=True,
            text=True,
            timeout=5,
            check=False,
        )
        props = dict(
            line.split("=", 1) for line in r.stdout.strip().split("\n") if "=" in line
        )
        current = int(props.get("MemoryCurrent", 0))
        high_str = props.get("MemoryHigh", "0")
        high = int(high_str) if high_str.isdigit() else 0  # "infinity" → 0
        if high > 0:
            pct = current / high * 100
            results["kintsugi_memory"] = pct < 95.0
            results["kintsugi_memory_pct"] = f"{pct:.0f}%"
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError, ValueError):
        logger.debug("kintsugi memory check failed", exc_info=True)

    # Version drift: compare installed version vs latest GitHub release tag.
    # If drift detected, all services are running stale code — UNHEALTHY.
    # Uses `gh` CLI for authenticated access to private repo.
    _check_version_drift(results)

    # Circuit breaker state (best-effort — may not be available in all versions)
    try:
        from opendeviationbar.orchestration.open_deviation_bars_cache import (
            get_fatal_write_breaker,
        )

        state = get_fatal_write_breaker().state.value
        results["circuit_breaker"] = state
        # OPEN breaker should trigger unhealthy (participates in bool check)
        results["circuit_breaker_healthy"] = state in ("closed", "n/a")
    except (ImportError, AttributeError):
        results["circuit_breaker"] = "n/a"

    # Resource peaks from Prometheus (best-effort; absent if Prometheus unreachable)
    _collect_resource_peaks(results)

    # Seeder state and timer freshness (Issue #270)
    _check_seeder(results)

    return results


def _format_message(results: dict[str, bool | str], healthy: bool) -> str:
    """Format health check results as a sectioned Telegram message."""
    host = socket.gethostname()
    now = time.strftime("%H:%M:%S UTC", time.gmtime())

    def _icon(key: str) -> str:
        v = results.get(key)
        if v is True:
            return "\u2705"
        if v is False:
            return "\u274c"
        return "\u2139\ufe0f"

    # Per-check remediation hints for UNHEALTHY state
    _remediation = {
        "clickhouse": "Check: systemctl status clickhouse-server",
        "sidecar": "Check: journalctl --user -u opendeviationbar-sidecar -n 20",
        "kintsugi": "Check: journalctl --user -u opendeviationbar-kintsugi -n 20",
        "kintsugi_telemetry": "Kintsugi may be stuck or crashed — check journal",
        "kintsugi_stuck": "Repair running too long — check REST rate limits or CH writes",
        "kintsugi_failures": "Multiple repairs failing — check Binance Vision data + CH health",
        "stathera_gaps": "Bar-boundary gaps detected (different processor instances produced non-adjacent bars) — kintsugi day-recompute will resolve",
        "stathera_overlaps": "Duplicate bars from different sidecar instances awaiting ReplacingMergeTree merge — not data loss",
        "cross_midnight": "Cross-midnight bars detected and kintsugi is not healing — check kintsugi service",
        "disk": "Disk space critical — check du -sh /var/lib/clickhouse/",
        "circuit_breaker": "CH writes blocked — check: curl -s 'http://localhost:8123/?query=SELECT+1'",
        "version_drift": "Version mismatch! Run: mise run deploy:bigblack",
        "seeder_ok": "Seeder timer stale or symbol failed — journalctl --user -u opendeviationbar-seeder -n 30",
    }

    if healthy:
        lines = [f"\U0001f49a <b>Heartbeat OK</b> \u2014 {host} @ {now}"]
    else:
        lines = [f"\U0001f534 <b>UNHEALTHY</b> \u2014 {host} @ {now}"]
        # Collect failed checks with remediation hints
        failed_checks = [k for k in _remediation if results.get(k) is False]
        if failed_checks:
            lines.append("")
            lines.append("<b>Likely cause</b>")
            for k in failed_checks:
                lines.append(f"  \u2192 {k}: {_remediation[k]}")

    # -- Services (with uptime + memory) --
    lines.append("")
    lines.append("<b>Services</b>")
    lines.append(f"  {_icon('clickhouse')} clickhouse")
    for svc in ("sidecar", "kintsugi"):
        uptime = results.get(f"{svc}_uptime", "")
        mem = results.get(f"{svc}_mem", "")
        suffix_parts = []
        if uptime:
            suffix_parts.append(f"{uptime} \u2191")
        if mem:
            suffix_parts.append(f"{mem} MB")
        suffix = f" ({', '.join(suffix_parts)})" if suffix_parts else ""
        lines.append(f"  {_icon(svc)} {svc}{suffix}")

    # Seeder (timer-driven oneshot — no persistent PID, shown in Services)
    if "seeder_ok" in results:
        status = results.get("seeder_status", "")
        if status == "running":
            # Active multi-hour bulk seeding run — show elapsed + live progress
            running_min = results.get("seeder_running_min", "?")
            cur_sym = results.get("seeder_current_symbol", "")
            months_done = results.get("seeder_months_done", "")
            days_done = results.get("seeder_days_done", "")
            journal_month = results.get("seeder_journal_month", "")
            journal_line = results.get("seeder_journal_line", "")
            progress_parts = []
            if cur_sym:
                progress_parts.append(cur_sym)
                if months_done:
                    progress_parts.append(f"{months_done}mo")
                if days_done:
                    progress_parts.append(f"{days_done}d")
            elif journal_month:
                progress_parts.append(journal_month)
            progress = f" ({', '.join(progress_parts)})" if progress_parts else ""
            lines.append(f"  {_icon('seeder_ok')} seeder \u23f3 running {running_min}{progress}")
            if journal_line and not cur_sym:
                lines.append(f"    \u21b3 {journal_line}")
        else:
            # Completed pass — show summary
            age = results.get("seeder_age_min", "?")
            seeded = results.get("seeder_seeded", "?")
            elapsed = results.get("seeder_elapsed", "?")
            sym_detail = results.get("seeder_symbols", "")
            detail = f"{age} ago, {seeded}d seeded, {elapsed}s"
            if sym_detail:
                detail += f" ({sym_detail})"
            lines.append(f"  {_icon('seeder_ok')} seeder {detail}")
        failed = results.get("seeder_failed", "")
        if failed and failed != "0":
            lines.append(
                f"    \u274c {failed} symbol(s) failed\n"
                "      \u2192 journalctl --user -u opendeviationbar-seeder -n 30 | grep -i failed"
            )

    # OOM kill warnings (Issue #185)
    for svc in ("sidecar", "kintsugi"):
        oom_count = results.get(f"{svc}_oom")
        if oom_count:
            lines.append(
                f"  \U0001f4a5 {svc}: {oom_count} OOM kill(s) in 30min!\n"
                f"      \u2192 journalctl --user -u opendeviationbar-{svc} --since '30 min ago' | grep -i oom"
            )

    # Systemd override warnings (Issue #185)
    overrides_str = results.get("systemd_overrides", "")
    if overrides_str:
        lines.append(f"  \u26a0\ufe0f overrides active: {overrides_str}")

    # -- Version Drift (Issue #250) --
    if "version_drift" in results:
        installed = results.get("version_installed", "?")
        detail = results.get("version_drift_detail", "")
        if results["version_drift"] is True:
            if "hotfix" in detail:
                # Hotfix detected — healthy but note the patched files
                lines.append("")
                lines.append(f"<b>Version</b>: {detail} \U0001f529")
            else:
                lines.append("")
                lines.append(f"<b>Version</b>: v{installed} \u2705")
                if "auto_healed_pth" in results:
                    lines.append(
                        f"  \U0001f527 auto-healed: editable .pth removed"
                        f" (<code>{html.escape(results['auto_healed_pth'])}</code>)"
                    )
        else:
            lines.append("")
            lines.append("<b>Version</b>")
            lines.append(f"  \u274c <b>DRIFT</b>: {html.escape(detail)}")
            lines.append("      \u2192 <code>mise run deploy:bigblack</code>")

    # -- Kintsugi --
    lines.append("")
    lines.append("<b>Kintsugi</b>")
    tel_age = results.get("kintsugi_telemetry_age", "")
    tel_suffix = f" ({tel_age} ago)" if tel_age else ""
    lines.append(f"  {_icon('kintsugi_telemetry')} telemetry{tel_suffix}")
    if "kintsugi_stuck" in results:
        detail = results.get("kintsugi_stuck_detail", "")
        if results["kintsugi_stuck"] is True:
            active = results.get("kintsugi_active_repair")
            if active:
                lines.append(f"  🔄 repairing: {active}")
            else:
                lines.append(f"  {_icon('kintsugi_stuck')} no stuck repairs")
        else:
            lines.append(
                f"  {_icon('kintsugi_stuck')} stuck: {detail}\n"
                "      \u2192 expected &lt;30min; check REST rate limits or CH write latency"
            )
    recent_healed = results.get("kintsugi_recent_healed")
    if recent_healed:
        lines.append(f"  ✅ recent: {recent_healed}")
    repairs = results.get("kintsugi_repairs", "")
    bars_24h = results.get("kintsugi_bars_24h", "")
    if repairs:
        bars_suffix = f" | {bars_24h} bars" if bars_24h else ""
        lines.append(f"  {_icon('kintsugi_failures')} 24h: {repairs}{bars_suffix}")
    mem_pct = results.get("kintsugi_memory_pct", "")
    if mem_pct:
        lines.append(f"  {_icon('kintsugi_memory')} memory: {mem_pct}")
    # Shard queue depth
    total_shards = results.get("kintsugi_total_shards")
    if total_shards is not None:
        lines.append(f"  \U0001f4cb queue: shards={total_shards}")

    # -- Freshness (per-symbol, BTCUSDT first) --
    freshness = results.get("symbol_freshness", "")
    if freshness:
        lines.append("")
        lines.append("<b>Freshness</b>")
        for rline in str(freshness).split("\n"):
            lines.append(f"  {rline}")
        if "symbol_needs_backfill" in results:
            lines.append(
                "    \u2192 run: <code>mise run kintsugi:catchup</code>"
            )
        if "symbol_needs_seeding" in results:
            syms = results["symbol_needs_seeding"]
            lines.append(
                f"    \u2192 genesis seeding needed ({syms}):"
                f" <code>populate_cache_resumable(sym, effective_start, end)</code>"
            )

    # -- ClickHouse --
    if "ch_total_bars" in results:
        lines.append("")
        lines.append("<b>ClickHouse</b>")
        bars = results.get("ch_total_bars", "?")
        pairs = results.get("ch_pairs", "?")
        symbols = results.get("ch_symbols", "?")
        db_size = results.get("ch_db_size", "?")
        parts = results.get("ch_parts", "?")
        bars_24h = results.get("ch_bars_24h", "?")
        lines.append(f"  \U0001f4ca {bars} bars | {pairs} pairs | {symbols} symbols")
        lines.append(f"  \U0001f4be {db_size} ({parts} parts)")
        lines.append(f"  \U0001f4c8 {bars_24h} bars/24h")
        freshest = results.get("ch_freshest", "")
        stalest_pairs = results.get("ch_stalest_pairs", "")
        if freshest:
            lines.append(f"  \U0001f7e2 freshest: {freshest}")
        if stalest_pairs:
            lines.append("  \u23f3 stalest:")
            for sline in str(stalest_pairs).split("\n"):
                lines.append(f"    {sline}")

    # -- Stathera Integrity (#238 Phase 3: gaps + overlaps + delta tracking) --
    if "stathera_overlaps" in results or "stathera_gaps" in results:
        lines.append("")
        lines.append("<b>Stathera</b>")
        overlap_count = results.get("stathera_overlap_count", "0")
        gap_count = results.get("stathera_gap_count", "0")
        overlap_delta = int(results.get("stathera_overlap_delta", "0"))
        gap_delta = int(results.get("stathera_gap_delta", "0"))
        has_overlaps = overlap_count != "0"
        has_gaps = gap_count != "0"

        def _delta_str(delta: int) -> str:
            if delta > 0:
                return f" <b>↑{delta} NEW</b>"
            if delta < 0:
                return f" ↓{abs(delta)}"
            return ""

        if not has_overlaps and not has_gaps:
            lines.append("  ✅ bar-level continuity: clean")
        else:
            if has_overlaps:
                overlap_pairs = results.get("stathera_overlap_pairs", "")
                pending_mut = int(results.get("stathera_pending_mutations", "0"))
                # Overlaps = bars from different sidecar instances that share
                # trade-ID ranges but have different bar boundaries. Kintsugi
                # day-recompute resolves by DELETE + single-processor reprocess.
                # 🔧 = transient (kintsugi will heal), 🔴 = growing unexpectedly.
                if overlap_delta > 0 and pending_mut > 0:
                    icon = "🔧"
                    mut_note = f" ({pending_mut} mutations pending)"
                elif overlap_delta > 0:
                    icon = "🔴"
                    mut_note = ""
                else:
                    icon = "🔧" if int(overlap_count) > 0 else "✅"
                    mut_note = ""
                lines.append(
                    f"  {icon} <b>{overlap_count}</b> bar overlaps{_delta_str(overlap_delta)}{mut_note}"
                )
                if overlap_pairs:
                    lines.append(f"  ⚠️ top: <code>{overlap_pairs}</code>")
            if has_gaps:
                gap_pairs = results.get("stathera_gap_pairs", "")
                all_known = results.get("stathera_all_gaps_known", "False") == "True"
                fingerprints: list[dict] = results.get("stathera_gap_fingerprints", [])
                intra_day_gaps = int(results.get("stathera_intra_day_gaps", "0"))
                backfill_gaps = int(gap_count) - intra_day_gaps
                # Classification (Spike F, #286):
                # - Intra-day gaps: TRUE anomalies (same date, different bars) → RED
                # - Backfill gaps: missing days from progressive kintsugi fill → INFO
                if intra_day_gaps > 0:
                    icon = "🔴" if gap_delta > 0 else "🔧"
                    lines.append(
                        f"  {icon} <b>{intra_day_gaps}</b> intra-day gaps{_delta_str(gap_delta)}"
                    )
                if backfill_gaps > 0:
                    lines.append(
                        f"  📋 {backfill_gaps} backfill-progression gaps (self-resolving)"
                    )
                if gap_pairs:
                    lines.append(f"  ⚠️ top: <code>{gap_pairs}</code>")
                if all_known and fingerprints:
                    # Every active gap has a known root cause — show a detailed diagnostic
                    # block so the reader knows exactly why this gap exists and what to expect.
                    for fp in fingerprints:
                        cause = fp.get("cause", "")
                        if cause == "pagination_bug_same_ms":
                            dropped = fp.get("dropped_trade_id", "?")
                            ts_ms = fp.get("boundary_ts_ms", "?")
                            sym = fp.get("symbol", "?")
                            thr = fp.get("threshold", "?")
                            lines.append(
                                f"\n  📋 <b>Known root cause</b> ({sym}@{thr}): "
                                f"REST pagination bug (same-millisecond boundary)\n"
                                f"  • Rust <code>fetch_aggtrades_rest()</code> advanced "
                                f"<code>startTime</code> by +1ms at page boundary\n"
                                f"  • Trade <code>{dropped}</code> "
                                f"(T=<code>{ts_ms}ms</code>) was at the same ms as "
                                f"page-end trade — silently dropped\n"
                                f"  • Kintsugi cannot fix via REST (same bug path). "
                                f"Will self-heal when Binance Vision publishes the "
                                f"day's archive (~24-48h from trade time)\n"
                                f"  ✅ No manual action needed — expected transient"
                            )

    # -- System --
    lines.append("")
    lines.append("<b>System</b>")
    free = results.get("disk_free_gb", "?")
    used_pct = results.get("disk_used_pct", "")
    disk_suffix = f" ({used_pct}% used)" if used_pct else ""
    lines.append(f"  {_icon('disk')} disk: {free} GB free{disk_suffix}")
    cb = results.get("circuit_breaker", "n/a")
    lines.append(f"  \U0001f512 breaker: {cb}")

    # -- Resources (last 15 min peaks from Prometheus) --
    cpu_avg = results.get("resource_cpu_avg")
    ram_avg = results.get("resource_ram_used_gb")
    load_avg = results.get("resource_load_avg")
    if cpu_avg or ram_avg or load_avg:
        lines.append("")
        lines.append(f"<b>Resources</b> (last {_RESOURCE_WINDOW_MINUTES}m)")
        if cpu_avg:
            cpu_abs = results.get("resource_cpu_abs_peak", "?")
            cpu_typ = results.get("resource_cpu_typ_peak", "?")
            lines.append(
                f"  \U0001f4bb CPU: avg {cpu_avg}% │ ⚡ abs {cpu_abs}% │ \U0001f4ca typ {cpu_typ}%"
            )
        if ram_avg:
            ram_abs = results.get("resource_ram_abs_peak_gb", "?")
            ram_typ = results.get("resource_ram_typ_peak_gb", "?")
            lines.append(
                f"  \U0001f9e0 RAM: avg {ram_avg}G │ ⚡ abs {ram_abs}G │ \U0001f4ca typ {ram_typ}G"
            )
        if load_avg:
            load_abs = results.get("resource_load_abs_peak", "?")
            load_typ = results.get("resource_load_typ_peak", "?")
            lines.append(
                f"  \u26a1 load: avg {load_avg} │ ⚡ abs {load_abs} │ \U0001f4ca typ {load_typ}"
            )

    return "\n".join(lines)


def _send_telegram(token: str, chat_id: str, message: str, *, silent: bool) -> int:
    """Send a Telegram message, return message_id (0 on failure)."""
    import requests

    try:
        r = requests.post(
            f"https://api.telegram.org/bot{token}/sendMessage",
            json={
                "chat_id": chat_id,
                "text": message,
                "parse_mode": "HTML",
                "disable_notification": silent,
            },
            timeout=10,
        )
        r.raise_for_status()
        return r.json().get("result", {}).get("message_id", 0)
    except requests.exceptions.RequestException as e:
        logger.warning("Telegram send failed: %s", e)
        return 0


_NTFY_TOPIC = os.environ.get(
    "OPENDEVIATIONBAR_NTFY_TOPIC", "opendeviationbar-heartbeat-deadman"
)
_NTFY_SERVER = os.environ.get("OPENDEVIATIONBAR_NTFY_SERVER", "https://ntfy.sh")


def _push_deadman_switch() -> None:
    """Push ntfy dead-man's-switch forward by 15 minutes.

    Every heartbeat (5 min) pushes a scheduled alert 15 min into the future.
    If 3 consecutive heartbeats fail, the message fires from the ntfy.sh server
    — an external dead-man's-switch that works even if bigblack is down.
    """
    import urllib.request

    host = socket.gethostname()
    url = f"{_NTFY_SERVER}/{_NTFY_TOPIC}"
    data = (
        f"DEAD-MAN ALERT: {host} heartbeat stopped. "
        f"No heartbeat received for 15+ minutes. Check systemd timers and host status."
    ).encode()
    req = urllib.request.Request(url, data=data, method="POST")
    req.add_header("Title", f"Heartbeat stopped: {host}")
    req.add_header("Priority", "urgent")
    req.add_header("Tags", "rotating_light,skull")
    req.add_header("At", "+15m")
    try:
        with urllib.request.urlopen(req, timeout=5):
            pass
    except Exception:
        logger.warning("ntfy dead-man's-switch push failed", exc_info=True)


def main() -> int:
    token = os.environ.get("OPENDEVIATIONBAR_TELEGRAM_TOKEN", "")
    chat_id = os.environ.get("OPENDEVIATIONBAR_TELEGRAM_CHAT_ID", "90417581")

    if not token:
        logger.error("Telegram not configured — set OPENDEVIATIONBAR_TELEGRAM_TOKEN")
        return 1

    try:
        return _main(token, chat_id)
    except Exception:
        host = os.environ.get("HOSTNAME") or os.uname().nodename
        logger.critical(
            "Unhandled exception in heartbeat — sending emergency alert", exc_info=True
        )
        # Best-effort emergency Telegram alert so the operator is not left in the dark
        try:
            _send_telegram(
                token,
                chat_id,
                f"🚨 HEARTBEAT CRASHED on {host}\\n\\nUnhandled exception in "
                f"health\\_heartbeat.py — monitoring is down. Check journalctl "
                f"immediately.\\n\\n```\\n{__import__('traceback').format_exc()[-1000:]}\\n```",
                silent=False,
            )
        except Exception:
            logger.critical("Emergency Telegram alert also failed", exc_info=True)
        return 2


def _main(token: str, chat_id: str) -> int:
    # Step 1: Delete expired healthy messages
    deleted = _delete_expired()
    if deleted:
        logger.info("Cleaned up %d expired heartbeat messages", deleted)

    # Step 2: Run health checks
    results = _run_checks()
    healthy = all(v is True for k, v in results.items() if isinstance(v, bool))

    # Step 3: Alert dedup via Redis (Issue #186)
    # Build a fingerprint from all failed checks to dedup identical alert states
    failed_checks = sorted(k for k, v in results.items() if v is False)
    alert_fingerprint = "|".join(failed_checks) if failed_checks else ""

    redis_client = _get_redis() if not healthy else None
    should_send_alert = healthy or _should_alert(redis_client, alert_fingerprint)

    if not should_send_alert:
        logger.info(
            "Alert suppressed (duplicate within %ds): %s",
            _ALERT_DEDUP_TTL,
            alert_fingerprint,
        )
        # Still send the healthy heartbeat message even when alert is suppressed
        # so the operator sees the channel is alive
        message = _format_message(results, healthy)
        msg_id = _send_telegram(token, chat_id, message, silent=True)
        if msg_id:
            records = _load_pending()
            records.append({"message_id": msg_id, "sent_at": time.time()})
            _save_pending(records)
        return 0

    # Step 4: Send message
    # Auto-heal events are notable even when healthy — send with notification.
    auto_healed = bool(results.get("auto_healed_pth"))
    message = _format_message(results, healthy)
    msg_id = _send_telegram(token, chat_id, message, silent=healthy and not auto_healed)

    if not msg_id:
        logger.error("Failed to send heartbeat message")
        return 1

    # Step 5: Track healthy messages for future deletion
    if healthy:
        records = _load_pending()
        records.append({"message_id": msg_id, "sent_at": time.time()})
        _save_pending(records)
        logger.info(
            "Heartbeat OK (message_id=%d, %d pending for cleanup)",
            msg_id,
            len(records),
        )
    else:
        logger.warning("UNHEALTHY heartbeat sent (persistent, will not auto-delete)")
        for check, value in results.items():
            if value is False:
                logger.warning("  FAILED: %s", check)

    # Step 6: ntfy dead-man's-switch (Issue #193)
    # Push a scheduled message 15 min into the future. If heartbeat stops,
    # the message fires from the ntfy.sh server (external to bigblack).
    _push_deadman_switch()

    # Step 7: Cleanup old artifacts (keep last 7 days, preserve symlinks)
    artifacts_dir = Path.home() / "opendeviationbar-py" / "artifacts"
    if artifacts_dir.exists():
        cutoff = time.time() - 7 * 86400
        for f in artifacts_dir.glob("*.json"):
            if f.is_symlink():
                continue  # Keep *_latest.json symlinks
            try:
                if f.stat().st_mtime < cutoff:
                    f.unlink(missing_ok=True)
            except OSError:
                logger.debug("failed to clean artifact %s", f, exc_info=True)

    return 0


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