"""Read-only stats + observability for ``IngestDaemonSQL``.

Lives outside ``ingest_sql.py`` so the storage layer stays focused on the
write path. Everything here is best-effort and goes through a fresh
DuckDB cursor (no ``_sql_lock``) — the status panel must stay responsive
even when ingest is holding the write lock under a burst.

Two pieces:

* Module-level ``_runtime_snapshot`` and friends — pure functions reading
  ``/proc/self/*``, the GC, the relay-bus. Linux-only; degrade to zeros on
  other platforms.
* ``StatsMixin`` — methods that read DuckDB and the in-memory rolling
  ingest-rate deque. Mixed into ``IngestDaemonSQL`` so they can use
  ``self._conn`` / ``self._ingest_times`` / etc. directly.
"""

from __future__ import annotations

import collections
import gc
import logging
import os
import time
from typing import Any

log = logging.getLogger("ingest_sql")


# ---- runtime / process observability -------------------------------------------
# Cheap snapshot used by `status://overview` so the status panel can spot
# resource leaks (thread/FD growth, bus subscription accumulation) before they
# turn into incidents. Linux-only — relies on /proc/self/*. Failures degrade
# silently to zeros; the status panel must keep working even on platforms
# without procfs.


def _read_proc_status() -> dict[str, int]:
    """Return selected /proc/self/status fields in bytes/integers."""
    out: dict[str, int] = {}
    try:
        with open("/proc/self/status", "r", encoding="utf-8") as fh:
            for line in fh:
                if line.startswith(("VmRSS:", "VmSize:", "Threads:")):
                    key, _, rest = line.partition(":")
                    parts = rest.strip().split()
                    if parts:
                        try:
                            out[key] = int(parts[0]) * (1024 if key.startswith("Vm") else 1)
                        except ValueError:
                            pass
    except OSError:
        pass
    return out


def _count_open_fds() -> int:
    try:
        return len(os.listdir("/proc/self/fd"))
    except OSError:
        return 0


def _fds_by_type() -> dict[str, int]:
    """Histogram of /proc/self/fd targets, bucketed by readlink prefix.

    Buckets: 'socket', 'pipe', 'anon_inode', 'eventpoll' (subset of
    anon_inode for clarity), 'regular' (any absolute filesystem path),
    'other' (anything else / unreadable). A growing 'socket' count
    correlates one-to-one with a WS / TCP leak; 'anon_inode' / 'pipe'
    growth points at asyncio internals (selectors, self-pipes).
    """
    out: dict[str, int] = collections.defaultdict(int)
    try:
        names = os.listdir("/proc/self/fd")
    except OSError:
        return {}
    for n in names:
        try:
            target = os.readlink(f"/proc/self/fd/{n}")
        except OSError:
            out["other"] += 1
            continue
        if target.startswith("socket:"):
            out["socket"] += 1
        elif target.startswith("pipe:"):
            out["pipe"] += 1
        elif target.startswith("anon_inode:[eventpoll]"):
            out["eventpoll"] += 1
        elif target.startswith("anon_inode:"):
            out["anon_inode"] += 1
        elif target.startswith("/"):
            out["regular"] += 1
        else:
            out["other"] += 1
    return dict(out)


def _threads_by_name() -> dict[str, int]:
    """Histogram of kernel thread names (`/proc/self/task/<tid>/comm`).

    Python threads inherit the `Thread(name=...)` value via prctl, so this
    distinguishes e.g. `relay-bus-loop` from FastMCP workers and DuckDB's
    own pool — useful when the bare thread count creeps up but you can't
    tell which subsystem is responsible. Names are truncated to 15 chars
    by the kernel.
    """
    out: dict[str, int] = collections.defaultdict(int)
    try:
        tids = os.listdir("/proc/self/task")
    except OSError:
        return {}
    for tid in tids:
        try:
            with open(f"/proc/self/task/{tid}/comm", "r", encoding="utf-8") as fh:
                name = fh.read().strip() or "?"
        except OSError:
            name = "?"
        out[name] += 1
    return dict(out)


def _gc_snapshot() -> dict[str, Any]:
    """Cheap GC counters. `counts` is the per-generation allocation count
    since last collection (gen0, gen1, gen2); `collections` is the total
    number of collections done in each generation since process start.
    Both come from CPython internals without walking the object graph,
    so they're safe to sample on every status read.
    """
    try:
        counts = list(gc.get_count())
        stats = gc.get_stats()
        coll = [int(s.get("collections", 0)) for s in stats]
    except Exception:
        return {}
    return {"counts": counts, "collections": coll}


def _runtime_snapshot(bus) -> dict:
    """Compose a single observability payload for the status resource."""
    proc = _read_proc_status()
    try:
        bus_stats = bus.stats() if bus is not None else {}
    except Exception:
        bus_stats = {}
    return {
        "rss_mb": round(proc.get("VmRSS", 0) / (1024 * 1024), 1),
        "vsz_mb": round(proc.get("VmSize", 0) / (1024 * 1024), 1),
        "threads": proc.get("Threads", 0),
        "threads_by_name": _threads_by_name(),
        "fds_open": _count_open_fds(),
        "fds_by_type": _fds_by_type(),
        "gc": _gc_snapshot(),
        "bus": bus_stats,
    }


# ---- mixin -------------------------------------------------------------------


class StatsMixin:
    """``IngestDaemonSQL`` mixin: ``pubkey_stats``, ``status_overview`` and
    the rolling ingest-rate counters.

    All reads use a fresh ``self._conn.cursor()`` so they share the DuckDB
    file with ingest but never block on ``_sql_lock``. Trade-off documented
    on each method: cross-query consistency goes out the window in exchange
    for the status panel staying responsive under load.
    """

    # Prune horizon for the rolling timestamp deque. Sized to cover the
    # widest window any caller asks for (events_per_hour) so we don't keep
    # two parallel deques. Memory bound = peak ingest rate × 3600s; at
    # 10 ev/sec that's ~36k entries (~1 MB).
    _INGEST_TIMES_HORIZON_S = 3600.0

    def _count_recent_ingests(self, window_s: float) -> int:
        """Trim the rolling deque to the global horizon and count entries
        whose timestamp is inside the trailing ``window_s`` window."""
        now = time.time()
        horizon_cutoff = now - self._INGEST_TIMES_HORIZON_S
        window_cutoff = now - window_s
        with self._ingest_times_lock:
            while self._ingest_times and self._ingest_times[0] < horizon_cutoff:
                self._ingest_times.popleft()
            return sum(1 for t in self._ingest_times if t >= window_cutoff)

    def events_per_minute(self) -> int:
        return self._count_recent_ingests(60.0)

    def events_per_hour(self) -> int:
        return self._count_recent_ingests(3600.0)

    def pubkey_stats(self, hex_key: str) -> dict:
        """Snapshot for the ``tracked://pubkey/{hex}`` resource. Read-only.

        Returns ``{tracked: False, hex}`` for an untracked pubkey, otherwise
        a flat dict ready for JSON serialization.

        Runs through ``self._conn.cursor()`` — DuckDB cursors share the
        underlying database but have their own transaction state, so reads
        proceed concurrently with whatever writer (live-sub ingest,
        auto-tracker discovery) is holding ``_sql_lock``. Without this,
        after a rescan the retroactive p-tag expansion + 3 fresh live subs
        starting burst-ingest under the lock would starve ``pubkey_stats``
        past the explorer's 10s timeout.
        """
        cur = self._conn.cursor()
        try:
            tracked = cur.execute(
                "SELECT role, added_at, last_scan_at, limit_per_relay, productive_relays "
                "FROM tracked_pubkeys WHERE hex = ?",
                [hex_key],
            ).fetchone()
            if tracked is None:
                return {"hex": hex_key, "tracked": False}

            profile_row = cur.execute(
                "SELECT name, display_name, about, picture, nip05 "
                "FROM profiles WHERE pubkey = ?",
                [hex_key],
            ).fetchone()

            authored = cur.execute(
                "SELECT count(*) FROM events WHERE pubkey = ? AND kind IS NOT NULL",
                [hex_key],
            ).fetchone()[0]

            referencing = cur.execute(
                "SELECT count(DISTINCT t.event_id) "
                "FROM event_tags t JOIN events e ON e.id = t.event_id "
                "WHERE t.name = 'p' AND t.elem_idx = 1 AND t.value = ? "
                "  AND e.pubkey IS DISTINCT FROM ? "
                "  AND e.kind IS NOT NULL",
                [hex_key, hex_key],
            ).fetchone()[0]

            kinds = cur.execute(
                "SELECT kind, count(*) AS n FROM events "
                "WHERE pubkey = ? AND kind IS NOT NULL "
                "GROUP BY kind ORDER BY n DESC LIMIT 10",
                [hex_key],
            ).fetchall()

            last_seen = cur.execute(
                "SELECT s.relay_url, max(e.created_at) AS ts "
                "FROM events e JOIN event_seen_on s ON s.event_id = e.id "
                "WHERE e.kind IS NOT NULL AND ("
                "  e.pubkey = ? "
                "  OR EXISTS ("
                "    SELECT 1 FROM event_tags t "
                "    WHERE t.event_id = e.id AND t.name = 'p' AND t.elem_idx = 1 AND t.value = ?"
                "  )"
                ") GROUP BY s.relay_url ORDER BY ts DESC",
                [hex_key, hex_key],
            ).fetchall()

            secondaries_from_me = 0
            if tracked[0] == "primary":
                secondaries_from_me = cur.execute(
                    "SELECT count(DISTINCT t.value) "
                    "FROM event_tags t JOIN events e ON e.id = t.event_id "
                    "WHERE e.pubkey = ? AND t.name = 'p' AND t.elem_idx = 1 "
                    "  AND t.value != ? "
                    "  AND t.value IN ("
                    "    SELECT hex FROM tracked_pubkeys WHERE role = 'secondary'"
                    "  )",
                    [hex_key, hex_key],
                ).fetchone()[0]
        finally:
            cur.close()

        return {
            "hex": hex_key,
            "tracked": True,
            "role": tracked[0],
            "added_at": tracked[1],
            "last_scan_at": tracked[2],
            "limit_per_relay": tracked[3],
            "productive_relays": list(tracked[4]) if tracked[4] is not None else [],
            "profile": (
                {
                    "name": profile_row[0],
                    "display_name": profile_row[1],
                    "about": profile_row[2],
                    "picture": profile_row[3],
                    "nip05": profile_row[4],
                }
                if profile_row is not None
                else None
            ),
            "events": {
                "authored": int(authored),
                "referencing": int(referencing),
                "kinds_top": [{"kind": int(k), "count": int(n)} for k, n in kinds],
            },
            "last_seen_per_relay": [
                {"relay": r, "ts": int(ts)} for r, ts in last_seen if ts is not None
            ],
            "secondaries_from_me": int(secondaries_from_me),
        }

    def status_overview(self) -> dict:
        """Aggregate counters for the ``status://overview`` resource.

        Cheap SQL — no per-row payload — so the status panel can
        poll/subscribe without the cost of ``tracked://list``. Read via a
        fresh ``cursor()`` (same rationale as ``pubkey_stats``): stays out
        of ``_sql_lock`` so a busy ingest doesn't starve the panel.
        Live-sub count comes from the in-memory pool, not the DB.
        """
        cur = self._conn.cursor()
        try:
            primary_pending = cur.execute(
                "SELECT count(*) FROM tracked_pubkeys "
                "WHERE role = 'primary' AND last_scan_at IS NULL"
            ).fetchone()[0]
            secondary_pending = cur.execute(
                "SELECT count(*) FROM tracked_pubkeys "
                "WHERE role = 'secondary' AND last_scan_at IS NULL"
            ).fetchone()[0]
            ignored_total = cur.execute(
                "SELECT count(*) FROM tracked_pubkeys WHERE role = 'ignored'"
            ).fetchone()[0]
            relay_total, relay_active, relay_ok = cur.execute(
                "SELECT count(*), "
                "       count(*) FILTER (WHERE COALESCE(active, true)), "
                "       count(*) FILTER (WHERE COALESCE(active, true) AND status = 'ok') "
                "FROM relays"
            ).fetchone()
            events_total = cur.execute("SELECT count(*) FROM events").fetchone()[0]
        finally:
            cur.close()

        pool = self.live_subs
        live_observed = pool.count_pubkeys() if pool is not None else 0

        try:
            db_bytes = os.path.getsize(self._db_path)
        except OSError:
            db_bytes = 0

        return {
            "tracked": {
                "primary_pending": int(primary_pending),
                "secondary_pending": int(secondary_pending),
                "ignored_total": int(ignored_total),
                "live_observed": int(live_observed),
            },
            "relays": {
                "total": int(relay_total),
                "active": int(relay_active),
                "ok": int(relay_ok),
                "failing": int(relay_active) - int(relay_ok),
            },
            "events_total": int(events_total),
            "events_per_minute": self.events_per_minute(),
            "events_per_hour": self.events_per_hour(),
            "db_size_mb": round(db_bytes / (1024 * 1024), 1),
            "scan_mode": self._scan_mode,
            "runtime": _runtime_snapshot(self.bus),
        }
