"""AdminCore — non-transport admin logic for the Nostr→DuckDB ingest stack.

The MCP shell (``mcp_admin.py``) is a thin adapter that wires FastMCP tool /
resource handlers to instance methods of ``AdminCore``. Everything that is not
strictly transport-layer (JSON-RPC framing, HTTP, faulthandler bootstrap)
lives here so it can be exercised end-to-end without standing up FastMCP +
uvicorn.

Two classes:

* ``NotifyBroker`` — thread-safe coalescing pub-sub registry. Ingest threads
  call ``request(uri)`` from arbitrary contexts; an async flusher in the
  transport layer drains the queue on a fixed cadence and fans out
  ``resources/updated`` to subscribed MCP sessions.
* ``AdminCore`` — owns the ``IngestDaemonSQL`` daemon and a read-write DuckDB
  connection used by ``run_sql`` (writes gated by the SQL whitelist).
  Exposes the same tool and resource API the MCP shell wraps, plus a
  synchronous ``boot``/``warmup``/``start``/``stop`` lifecycle.
"""

from __future__ import annotations

import logging
import threading
from pathlib import Path as FsPath
from typing import Any, Callable, Iterable

import bech32
import duckdb

from ingest import _normalize_relay_url, apply_duckdb_runtime_config
from ingest_sql import IngestDaemonSQL
from relay_query import raw_query as _raw_query
from sql_whitelist import validate_read_only as _validate_read_only
from track_pubkey import replay_on_startup as _replay_tracked_pubkeys


log = logging.getLogger("admin_core")

SCHEMA_RESOURCE_URI = "schema://nostr-duckdb"

# Path to the run_sql tool description (NIP-01 schema overview + hard rules).
# Loaded into the FastMCP ``run_sql`` tool description at decorator time so the
# agent gets the full spec from ``tools/list`` without an additional resource
# fetch. The file itself is the source of truth for the DuckDB schema; update
# it whenever the table structure changes.
_RUN_SQL_DOC_PATH = FsPath(__file__).parent / "run_sql.md"


# ---- npub / hex helpers ----------------------------------------------------

_HEX_CHARS = set("0123456789abcdef")


def decode_npub(npub: str) -> str:
    """Decode an ``npub1...`` bech32 string into a 64-char lowercase hex
    pubkey. Raises ``ValueError`` on malformed input."""
    hrp, data = bech32.bech32_decode(npub)
    if hrp != "npub" or data is None:
        raise ValueError(f"not a valid npub: {npub!r}")
    decoded = bech32.convertbits(data, 5, 8, False)
    if not decoded or len(decoded) != 32:
        raise ValueError(f"npub decodes to wrong length: {npub!r}")
    return bytes(decoded).hex()


def encode_npub(pubkey_hex: str) -> str:
    """Encode a 64-char hex pubkey into the ``npub1...`` bech32 form."""
    raw = bytes.fromhex(pubkey_hex)
    if len(raw) != 32:
        raise ValueError(f"pubkey hex must be 32 bytes: {pubkey_hex!r}")
    data = bech32.convertbits(list(raw), 8, 5, True)
    return bech32.bech32_encode("npub", data)


def normalize_pubkey(s: str) -> str:
    """Accept either ``npub1...`` or 64-char hex and return canonical hex."""
    s = s.strip()
    if s.startswith("npub1"):
        return decode_npub(s)
    low = s.lower()
    if len(low) != 64 or not all(c in _HEX_CHARS for c in low):
        raise ValueError(f"expected 64-char hex or npub1...: {s!r}")
    return low


def read_run_sql_description() -> str:
    """Load the ``run_sql`` tool description (intro section of ``run_sql.md``)
    suffixed with a pointer to the full document. Used by the FastMCP shell
    to populate the tool description shown in ``tools/list``."""
    raw = _RUN_SQL_DOC_PATH.read_text(encoding="utf-8")
    head = raw.split("## Schemat", 1)[0].rstrip()
    return head + (
        "\n\nPełny schemat (12 tabel), twarde reguły (jednostki czasu, "
        "p-tag ≠ interakcja, LIMIT-y, format czasu) i pułapki DuckDB — patrz "
        "`admin/mcp/run_sql.md`.\n"
    )


# ---- NotifyBroker ----------------------------------------------------------


class NotifyBroker:
    """Thread-safe coalescing pub-sub registry for resource-update pings.

    The ingest pipeline (LiveSub workers, FastMCP threads, auto-tracker) calls
    :meth:`request` from arbitrary threads to mark a resource URI as dirty.
    Pings are coalesced into a set so a burst of 100 events on one URI within
    a flush tick collapses into a single ``drain()`` entry.

    Sinks (opaque handles supplied by the transport layer — typically MCP
    ``ServerSession`` instances) are tracked per URI; transport drains the
    queue on a fixed cadence and fans the URI out to its sinks via whatever
    mechanism that transport uses (``send_resource_updated`` for MCP).
    """

    def __init__(self, flush_interval_s: float = 2.0):
        self.flush_interval_s = flush_interval_s
        self._pending: set[str] = set()
        self._subscribers: dict[str, set[Any]] = {}
        self._lock = threading.Lock()

    def request(self, uri: str) -> None:
        """Non-blocking coalescing enqueue. Safe to call from any thread."""
        with self._lock:
            self._pending.add(uri)

    def drain(self) -> list[str]:
        """Atomically snapshot and clear the pending set."""
        with self._lock:
            uris = list(self._pending)
            self._pending.clear()
        return uris

    def subscribe(self, uri: str, sink: Any) -> None:
        with self._lock:
            self._subscribers.setdefault(uri, set()).add(sink)

    def unsubscribe(self, uri: str, sink: Any) -> None:
        with self._lock:
            sinks = self._subscribers.get(uri)
            if sinks:
                sinks.discard(sink)

    def sinks_for(self, uri: str) -> list[Any]:
        """Snapshot of sinks subscribed to ``uri`` (safe to iterate outside
        the lock — the transport may take seconds to fan out)."""
        with self._lock:
            return list(self._subscribers.get(uri, ()))

    def diagnostics(self) -> dict:
        """Counters for the status panel. ``pending_uris`` growing between
        flush ticks means clients subscribe faster than transport drains.
        ``subscribers_total`` growing without matching unsubscribes means
        dead-session cleanup is lagging."""
        with self._lock:
            pending = len(self._pending)
            subscribed = list(self._subscribers.items())
        subs_total = sum(len(s) for _, s in subscribed)
        return {
            "pending_uris": pending,
            "subscribed_uris": len(subscribed),
            "subscribers_total": subs_total,
            "flush_interval_s": self.flush_interval_s,
        }


# ---- AdminCore -------------------------------------------------------------


class AdminCore:
    """Tool + resource + lifecycle surface for the admin MCP.

    Construction is cheap — the daemon object is created but no threads are
    started until :meth:`boot` runs. Tests typically call :meth:`start`
    (boot + warmup synchronously); the production shell calls :meth:`boot`
    in ``__main__`` before ``uvicorn.run`` and offloads :meth:`warmup` to a
    background thread from the FastMCP lifespan so the HTTP port binds
    before replay finishes.
    """

    SCHEMA_RESOURCE_URI = SCHEMA_RESOURCE_URI

    def __init__(
        self,
        duckdb_path: str,
        *,
        notify: NotifyBroker | None = None,
        auto_track_interval_s: float | None = None,
    ):
        self.duckdb_path = duckdb_path
        self.daemon = IngestDaemonSQL(duckdb_path)
        if auto_track_interval_s is not None:
            # Tests need the auto-tracker to pick up fresh inserts quickly;
            # production keeps the default 30s tick. Set on the daemon BEFORE
            # `start_auto_tracker` so the very first wait honors the override.
            self.daemon.auto_track_interval_s = auto_track_interval_s
        # Read-write connection for `run_sql`. Deliberately private — every
        # external read goes through `run_sql`, which enforces the SQL
        # whitelist. The daemon holds its own write connection to the same
        # file; writes from the agent would otherwise bypass the gate.
        self._db = duckdb.connect(duckdb_path)
        apply_duckdb_runtime_config(self._db, duckdb_path)
        self.notify = notify if notify is not None else NotifyBroker()
        if hasattr(self.daemon, "notify_callback"):
            self.daemon.notify_callback = self.notify.request
        self._booted = False
        self._warmed = False
        self._lifecycle_lock = threading.Lock()

    # ---- lifecycle ---------------------------------------------------------

    def boot(self) -> None:
        """Quick synchronous startup: bring up the relay bus and the daemon's
        reconcile loop. Auto-tracker and mention-sweep are NOT started here —
        callers that want a fully-warm daemon call :meth:`warmup` next, or
        use :meth:`start` to do both at once. Idempotent."""
        with self._lifecycle_lock:
            if self._booted:
                return
            self.daemon.start()
            self._booted = True

    def warmup(self) -> None:
        """Heavy lifecycle steps that follow :meth:`boot`:

          1. ``revive_related_ignored`` — promote any ``ignored`` pubkeys that
             gained a p-tag relation with a primary while they were tombstoned
          2. ``replay_on_startup`` — re-open live subs from ``tracked_pubkeys``
             on their stored ``productive_relays``
          3. ``start_auto_tracker`` — background discovery worker (claims
             ``last_scan_at IS NULL`` rows on a tick)
          4. ``start_mention_sweep`` — periodic reverse-frontier sweep across
             every active relay

        Production runs this in a background thread from the FastMCP lifespan
        so uvicorn can bind the HTTP port before replay finishes. Tests call
        it synchronously when they want a fully-warm daemon. Idempotent."""
        with self._lifecycle_lock:
            if self._warmed:
                return
            if not self._booted:
                raise RuntimeError("warmup() called before boot()")
            if self.daemon.live_subs is not None:
                if hasattr(self.daemon, "revive_related_ignored"):
                    revived = self.daemon.revive_related_ignored()
                    if revived:
                        log.info(
                            "startup: revived %d ignored pubkey(s) related to primary",
                            revived,
                        )
                replay_summary = _replay_tracked_pubkeys(self.daemon)
                log.info("tracked-pubkey replay: %s", replay_summary)
                if hasattr(self.daemon, "start_auto_tracker"):
                    self.daemon.start_auto_tracker()
                if hasattr(self.daemon, "start_mention_sweep"):
                    self.daemon.start_mention_sweep()
                if hasattr(self.daemon, "start_coverage_flush"):
                    self.daemon.start_coverage_flush()
            self._warmed = True

    def start(self) -> None:
        """Convenience: :meth:`boot` + :meth:`warmup` synchronously."""
        self.boot()
        self.warmup()

    def stop(self) -> None:
        """Reverse-order shutdown. Safe to call multiple times.

        Mirrors the explicit teardown sequence the previous monolithic
        lifespan used (mention-sweep → auto-tracker → live_subs → daemon)
        rather than relying solely on ``IngestDaemonSQL.stop`` so any future
        divergence in shutdown order stays visible here."""
        with self._lifecycle_lock:
            if hasattr(self.daemon, "stop_mention_sweep"):
                self.daemon.stop_mention_sweep()
            if hasattr(self.daemon, "stop_auto_tracker"):
                self.daemon.stop_auto_tracker()
            # Live-sub teardown + the final coverage flush happen inside
            # daemon.stop(): the flush reads the live-sub pool and must run
            # BEFORE stop_all clears it, so we don't tear subs down here.
            self.daemon.stop()
            self._warmed = False
            self._booted = False

    # ---- tool API (1:1 with @mcp.tool handlers in the shell) ---------------

    def npub_to_hex(self, npub: str) -> str:
        return decode_npub(npub.strip())

    def hex_to_npub(self, pubkey_hex: str) -> str:
        return encode_npub(pubkey_hex.strip().lower())

    def run_sql(self, query: str, params: list | None = None) -> list[dict]:
        _validate_read_only(query)
        cur = self._db.execute(query, params or [])
        cols = [d[0] for d in cur.description] if cur.description else []
        return [dict(zip(cols, row)) for row in cur.fetchall()]

    def subscribe_to_pubkey(self, pubkey: str, limit: int = 100) -> str:
        if int(limit) <= 0:
            raise ValueError(f"limit must be > 0, got {limit}")
        status = self.daemon.register_primary(normalize_pubkey(pubkey), int(limit))
        return "It is already subscribed." if status == "already" else "Subscribed."

    def set_tracking_status(
        self, pubkey: str, status: str, limit: int = 100,
    ) -> str:
        return self.daemon.set_tracking_status(
            normalize_pubkey(pubkey), status, limit=int(limit),
        )

    def rescan_pubkey(self, pubkey: str) -> str:
        status = self.daemon.mark_for_rescan(normalize_pubkey(pubkey))
        if status == "untracked":
            return "Not tracked. Use subscribe_to_pubkey first."
        return "Queued for rescan."

    def set_scan_mode(self, mode: str) -> dict:
        return {"scan_mode": self.daemon.set_scan_mode(mode)}

    def relay_query(
        self, filter: dict, timeout_s: float = 5.0,
        relays: str | list[str] | None = None,
    ) -> dict:
        if not isinstance(filter, dict) or not filter:
            raise ValueError("filter must be a non-empty dict per NIP-01")
        norm: list[str] | None = None
        if relays is not None:
            raw = [relays] if isinstance(relays, str) else list(relays)
            norm = []
            for r in raw:
                u = _normalize_relay_url(r)
                if u is None:
                    raise ValueError(f"not a valid ws(s):// relay URL: {r!r}")
                norm.append(u)
            if not norm:
                raise ValueError("relays must name at least one relay when given")
        return _raw_query(self.daemon, filter, timeout_s, norm)

    # ---- admin-only mutators (not exposed via MCP tools) -------------------

    def add_relay(self, url: str, *, active: bool = True) -> None:
        """Register a relay in the `relays` table. Deliberately not wrapped
        as an `@mcp.tool` — agents do not get to add relays. Useful for
        tests and for in-process admin scripts that prepare a fresh DB."""
        if not url.startswith(("ws://", "wss://")):
            raise ValueError(f"relay URL must be ws:// or wss://, got {url!r}")
        with self.daemon._sql_lock:
            self.daemon._conn.execute(
                "INSERT INTO relays(url, active) VALUES (?, ?) "
                "ON CONFLICT (url) DO UPDATE SET active = excluded.active",
                [url, active],
            )

    # ---- resource builders (return Python objects, transport serializes) ---

    def tracked_list(self) -> list[dict]:
        """Snapshot of every tracked pubkey (primary/secondary/ignored) with
        live-sub counts. Cheap enough to call on every roster change."""
        rows = self.daemon._read_tracked_pubkeys()
        pool = self.daemon.live_subs
        return [
            {
                "hex": r["hex"],
                "npub": encode_npub(r["hex"]),
                "role": r["role"],
                "added_at": r["added_at"],
                "last_scan_at": r["last_scan_at"],
                "limit_per_relay": r["limit_per_relay"],
                "productive_relays": r["productive_relays"],
                "live_subs_active": (
                    pool.count_for(r["hex"]) if pool is not None else 0
                ),
            }
            for r in rows
        ]

    def status_overview(self) -> dict:
        """Counters-only snapshot for the status panel. Augments the daemon's
        own payload with notify-broker diagnostics so a single subscribe to
        ``status://overview`` shows both ingest health and fan-out health."""
        payload = self.daemon.status_overview()
        runtime = payload.get("runtime")
        if isinstance(runtime, dict):
            runtime["notify"] = self.notify.diagnostics()
        return payload

    def pubkey_stats(self, hex_or_npub: str) -> dict:
        """Per-pubkey deep stats. Accepts hex or ``npub1...``. For untracked
        pubkeys the daemon returns ``{"hex": ..., "tracked": false}``."""
        return self.daemon.pubkey_stats(normalize_pubkey(hex_or_npub))

    def schema_markdown(self) -> str:
        """Live DuckDB schema rendered as markdown — tables, columns, indexes.
        Auto-generated from ``information_schema`` and ``duckdb_indexes()``;
        meant for the JOU client that subscribes to ``schema://nostr-duckdb``.
        Distinct from the human-curated ``run_sql.md`` description (which
        carries semantics, not just types)."""
        rows = self._db.execute(
            """
            SELECT
                table_schema,
                table_name,
                column_name,
                data_type,
                is_nullable,
                ordinal_position
            FROM information_schema.columns
            WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
            ORDER BY table_schema, table_name, ordinal_position
            """
        ).fetchall()
        indeksy = self._db.execute(
            """
            SELECT
                schema_name,
                table_name,
                index_name,
                sql
            FROM duckdb_indexes()
            ORDER BY schema_name, table_name, index_name
            """
        ).fetchall()

        linie = [
            "# Schemat DuckDB grafu Nostr",
            "",
            "Źródło: bezpośrednio z aktualnie podłączonej bazy DuckDB.",
            "",
            "## Tabele i kolumny",
            "",
        ]
        aktualna_tabela = None
        for (
            table_schema, table_name, column_name, data_type,
            is_nullable, ordinal_position,
        ) in rows:
            nazwa_tabeli = f"{table_schema}.{table_name}"
            if nazwa_tabeli != aktualna_tabela:
                if aktualna_tabela is not None:
                    linie.append("")
                aktualna_tabela = nazwa_tabeli
                linie.append(f"### `{nazwa_tabeli}`")
                linie.append("")
                linie.append("| # | Kolumna | Typ | NULL |")
                linie.append("|---:|---|---|---|")
            linie.append(
                f"| {ordinal_position} | `{column_name}` | `{data_type}` "
                f"| `{is_nullable}` |"
            )
        if indeksy:
            linie.extend(["", "## Indeksy", ""])
            for schema_name, table_name, index_name, sql in indeksy:
                linie.append(f"### `{schema_name}.{table_name}` / `{index_name}`")
                linie.append("")
                linie.append("```sql")
                linie.append(sql)
                linie.append("```")
                linie.append("")
        return "\n".join(linie)
