# 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 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"


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:
        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:
        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


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,
    )
    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 repair: unmatched repair_start > 1h (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"])
            stuck_h = (now - start_ts).total_seconds() / 3600
            if stuck_h > 1.0:
                sym = last_start.get("symbol", "?")
                thr = last_start.get("threshold", "?")
                result["stuck_repair"] = f"{sym}@{thr} {stuck_h:.1f}h"
        except (KeyError, ValueError):
            pass

    # 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)

        # Overall stalest (single line for backward compat)
        sym, thr, stale_min = row.result_rows[0]
        results["ch_stalest"] = f"{sym}@{thr} ({stale_min / 60:.1f}h ago)"

        # 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",)


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"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

    # Build ordered list: priority symbols first, then by freshness
    priority_rows = []
    other_rows = []
    for sym, latest, stale_min, total_bars in row.result_rows:
        entry = (sym, latest, stale_min, total_bars)
        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 = []
    for sym, _latest, stale_min, total_bars 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 "  "
        freshness_lines.append(f"{prefix} {sym}: {age_str} ago ({total_bars:,} bars)")

    results["symbol_freshness"] = "\n".join(freshness_lines)


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():
        results["version_drift"] = False
        results["version_drift_detail"] = (
            "editable .pth shadowing wheel! "
            "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

        results["version_github"] = gh_tag

        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}"


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:
        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

    # 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"
            )
            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"
            )
            bars_24h = row2.result_rows[0][0] or 0
            results["kintsugi_bars_24h"] = f"{bars_24h:,}"
        except (OSError, RuntimeError, ConnectionError, ValueError):
            logger.debug("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.debug("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,
            )

            row = client.query(
                f"""
                SELECT countIf(tid_delta < 0) AS overlap_count,
                       countIf(tid_delta > 0) AS gap_count
                FROM (
                    SELECT first_agg_trade_id
                             - lagInFrame(last_agg_trade_id, 1) OVER w - 1
                             AS tid_delta
                    FROM {FULL_TABLE} FINAL
                    {cross_pair_filter()}
                    {cross_pair_window()}
                )
                WHERE tid_delta != 0
                """
            )
            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

            # 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
            kintsugi_healing = (
                results.get("kintsugi") is True
                and results.get("kintsugi_stuck") is not False
            )
            anomalies_growing = overlap_delta > 0 or gap_delta > 0
            has_anomalies = overlap_count > 0 or gap_count > 0

            if not has_anomalies:
                results["stathera_overlaps"] = True
                results["stathera_gaps"] = True
            elif anomalies_growing and not kintsugi_healing:
                results["stathera_overlaps"] = overlap_count == 0
                results["stathera_gaps"] = gap_count == 0
            else:
                results["stathera_overlaps"] = overlap_count == 0 or kintsugi_healing
                results["stathera_gaps"] = gap_count == 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)

            # 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 (PARTITION BY symbol, threshold_decimal_bps,
                                                      ouroboros_mode
                                         ORDER BY first_agg_trade_id, close_time_ms) - 1
                                 AS tid_delta
                        FROM {FULL_TABLE} FINAL
                        {cross_pair_filter()}
                    )
                    WHERE tid_delta < 0
                    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
                        FROM {FULL_TABLE} FINAL
                        {cross_pair_filter()}
                        {cross_pair_window()}
                    )
                    WHERE tid_delta > 0
                    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)

            # (#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"

    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": "Real gaps detected and kintsugi is not healing — check kintsugi service",
        "stathera_overlaps": "Overlaps detected and kintsugi is not healing — check kintsugi service",
        "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",
    }

    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}")

    # 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")
        else:
            lines.append("")
            lines.append("<b>Version</b>")
            lines.append(f"  \u274c <b>DRIFT</b>: {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:
            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"
            )
    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}")

    # -- 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("  ✅ trade-ID continuity: clean")
        else:
            if has_overlaps:
                overlap_pairs = results.get("stathera_overlap_pairs", "")
                icon = (
                    "🔴"
                    if overlap_delta > 0
                    else ("🔧" if int(overlap_count) > 0 else "✅")
                )
                lines.append(
                    f"  {icon} <b>{overlap_count}</b> overlaps{_delta_str(overlap_delta)}"
                )
                if overlap_pairs:
                    lines.append(f"  ⚠️ top: <code>{overlap_pairs}</code>")
            if has_gaps:
                gap_pairs = results.get("stathera_gap_pairs", "")
                icon = "🔴" if gap_delta > 0 else ("🔧" if int(gap_count) > 0 else "✅")
                lines.append(
                    f"  {icon} <b>{gap_count}</b> gaps{_delta_str(gap_delta)}"
                )
                if gap_pairs:
                    lines.append(f"  ⚠️ top: <code>{gap_pairs}</code>")

    # -- 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}")

    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.debug("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

    # 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)
        # Oneshot "is-failed" is informational — timer freshness is the
        # real health signal (stathera audit in heartbeat replaces daily-gap-check)
        and not k.startswith("oneshot_")
    )

    # 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
    message = _format_message(results, healthy)
    msg_id = _send_telegram(token, chat_id, message, silent=healthy)

    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())
