"""FastMCP shell wrapping ``admin_core.AdminCore``.

This module owns the transport layer (FastMCP tools / resources, MCP resource
subscriptions, async notification fan-out, uvicorn / HTTP) and nothing else.
Every tool and resource handler delegates 1:1 to an ``AdminCore`` method —
the logic lives there so it can be tested end-to-end without standing up
FastMCP + uvicorn.
"""

import asyncio
import contextlib
import faulthandler
import json
import logging
import os
import threading
from pathlib import Path as FsPath

# Install faulthandler BEFORE any C-extension import. On SIGABRT/SIGSEGV/SIGBUS
# (e.g. heap corruption from a misbehaving native lib — websockets speedups,
# coincurve, duckdb) Python dumps the stack of every thread to this file
# before dying, so we can see which native call was on the stack when the
# heap blew up. Append mode preserves traces across multiple crashes.
_FAULT_PATH = FsPath(__file__).parent / "mcp_admin.faulthandler.log"
_fault_fp = _FAULT_PATH.open("a", buffering=1)
faulthandler.enable(file=_fault_fp, all_threads=True)

import uvicorn
from dotenv import load_dotenv
from fastmcp import FastMCP
from mcp.server.lowlevel.server import request_ctx
from pydantic import AnyUrl

from admin_core import AdminCore, read_run_sql_description

load_dotenv(FsPath(__file__).parent / ".env")

# Logging: console + plik mcp_admin.log obok skryptu (INFO+, append). Plik
# daje grep-owalny ślad co się działo nawet po restarcie procesu — nasze
# markery (register_primary, frontier:, persist, auto-tracker, …) są na
# INFO. Root celowo NIE na DEBUG — websockets/sse_starlette/httpx wypluwają
# wtedy ping/pong na każdą ramkę i pasożytują CPU oraz topią log w szumie.
_LOG_PATH = FsPath(__file__).parent / "mcp_admin.log"
_root_logger = logging.getLogger()
_root_logger.setLevel(logging.INFO)
_log_fmt = logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
_console = logging.StreamHandler()
_console.setLevel(logging.INFO)
_console.setFormatter(_log_fmt)
_root_logger.addHandler(_console)
_file_h = logging.FileHandler(str(_LOG_PATH), mode="a", encoding="utf-8")
_file_h.setLevel(logging.INFO)
_file_h.setFormatter(_log_fmt)
_root_logger.addHandler(_file_h)
logging.getLogger("mcp_admin").info(f"--- start, log file: {_LOG_PATH} ---")

NOSTR_DUCKDB_PATH = os.environ.get(
    "NOSTR_DUCKDB_PATH",
    str(FsPath(__file__).parent.parent.parent / "duckdb" / "nostr.duckdb"),
)

log = logging.getLogger("mcp_admin")
log.info("duckdb=%s", NOSTR_DUCKDB_PATH)

# Single AdminCore instance for the lifetime of this process. Owns the daemon,
# the run_sql DB connection, and the NotifyBroker. Every FastMCP handler in
# this file is a thin adapter over one of its methods.
app = AdminCore(NOSTR_DUCKDB_PATH)

# Pre-load the run_sql tool description so FastMCP can publish it in
# `tools/list` without an extra file read per handshake.
_RUN_SQL_DESCRIPTION = read_run_sql_description()


@contextlib.asynccontextmanager
async def _lifespan(_server):
    # Notification flusher coalesces high-frequency change pings from the
    # ingest path and emits resources/updated on a fixed cadence.
    flusher = asyncio.create_task(_flush_notifications())
    # Warmup (revive_related_ignored + replay_on_startup + auto-tracker +
    # mention-sweep) runs in a background thread so the lifespan yields
    # immediately — without that, uvicorn never gets past startup and the
    # HTTP port stays unbound while replay crawls through hundreds of
    # `pool.add` calls under sql_lock + GIL contention.
    threading.Thread(
        target=app.warmup, daemon=True, name="lifespan-warmup",
    ).start()
    try:
        yield {}
    finally:
        flusher.cancel()
        try:
            await flusher
        except (asyncio.CancelledError, Exception):
            pass
        app.stop()


mcp = FastMCP("nostr-admin", lifespan=_lifespan)


# ---- resources -------------------------------------------------------------


@mcp.resource(AdminCore.SCHEMA_RESOURCE_URI, mime_type="text/markdown")
def schema_nostr_duckdb() -> str:
    """Schemat DuckDB mirror grafu Nostr czytany bezpośrednio z bazy."""
    return app.schema_markdown()


@mcp.resource("tracked://list", mime_type="application/json")
def tracked_list() -> str:
    """Snapshot of every pubkey we keep subscribed to (primary + secondary).

    Subscribe to this URI to be told when the set changes — a new primary
    via `subscribe_to_pubkey`, a new secondary discovered through a
    primary's `p`-tag, a promotion from secondary to primary, or a
    discovery run that updated `productive_relays` / `last_scan_at`.

    Per-pubkey deep stats (event counts, kinds breakdown, last-seen per
    relay, profile, secondaries-from-me): subscribe to
    `tracked://pubkey/{hex}`. Aggregate counters for the status panel
    (pending discovery, live-observed, relay health) live in
    `status://overview` — kept separate so this list can stay cheap to
    parse even with hundreds of secondaries.
    """
    return json.dumps(app.tracked_list(), ensure_ascii=False)


@mcp.resource("status://overview", mime_type="application/json")
def status_overview() -> str:
    """Tiny aggregate snapshot for the status panel — counters only, no rows.

    Shape:
      {
        "tracked": {
          "primary_pending":   int,  # role='primary'   AND last_scan_at IS NULL
          "secondary_pending": int,  # role='secondary' AND last_scan_at IS NULL
          "ignored_total":     int,  # role='ignored' (tombstone)
          "live_observed":     int,  # pubkeys with ≥1 active live-sub
        },
        "relays": {
          "total":   int,  # all rows in `relays`
          "active":  int,  # active=true
          "ok":      int,  # active=true AND status='ok'
          "failing": int,  # active=true AND status != 'ok' (or NULL)
        },
        "events_total":      int,    # count(*) FROM events
        "events_per_minute": int,    # fresh ingests in trailing 60s
        "db_size_mb":        float,  # DuckDB file size on disk, MB (1 decimal)
        "scan_mode":         str,    # 'full-scan' | 'no-scan' (see set_scan_mode)
      }

    Pushed on roster changes (same triggers as `tracked://list`), on every
    relay status reconcile tick from the daemon, and on `set_scan_mode` flips.
    """
    return json.dumps(app.status_overview(), ensure_ascii=False)


@mcp.resource("tracked://pubkey/{hex}", mime_type="application/json")
def tracked_pubkey(hex: str) -> str:
    """Stats for one tracked pubkey. Subscribe to this URI to see live
    counters move as the live subs ingest events; pings are coalesced to
    at most one per `NOTIFY_FLUSH_INTERVAL_S` so a popular pubkey under
    heavy traffic doesn't drown the client.

    `hex` accepts a 64-char hex pubkey or `npub1...`. For an untracked
    pubkey the response is `{"hex": ..., "tracked": false}` — subscribing
    is allowed but no notifications will fire until `subscribe_to_pubkey`
    promotes it (or a primary's `p`-tag pulls it in as secondary).

    Fields when tracked:
      role, added_at, last_scan_at, limit_per_relay, productive_relays,
      profile (NIP-01 metadata or null), events.{authored, referencing,
      kinds_top}, last_seen_per_relay, secondaries_from_me (primary only).
    """
    return json.dumps(app.pubkey_stats(hex), ensure_ascii=False)


# ---- resource subscriptions / notification fan-out -------------------------

# Sesje, które subskrybowały dany URI, żyją w ``app.notify``. Handler-y
# poniżej tylko mostkują request_ctx.session do brokera. Sesje, które zerwały
# połączenie, usuwane są leniwie — przy próbie ``send_resource_updated``
# rzucany wyjątek powoduje ``app.notify.unsubscribe(uri, session)``.


@mcp._mcp_server.subscribe_resource()
async def _zarejestruj_subskrypcję(uri: AnyUrl) -> None:
    session = request_ctx.get().session
    app.notify.subscribe(str(uri), session)


@mcp._mcp_server.unsubscribe_resource()
async def _wyrejestruj_subskrypcję(uri: AnyUrl) -> None:
    session = request_ctx.get().session
    app.notify.unsubscribe(str(uri), session)


async def _wyślij_aktualizację(uri: str) -> None:
    """Drop subscribers `resources/updated` for one URI; prune dead sessions."""
    sesje = app.notify.sinks_for(uri)
    if not sesje:
        return
    for s in sesje:
        try:
            await s.send_resource_updated(AnyUrl(uri))
        except Exception:
            app.notify.unsubscribe(uri, s)


async def _flush_notifications() -> None:
    """Drain the notify broker and fan out `resources/updated` to subscribers.
    One tick = one batch — clients always see at most one notification per
    URI per tick regardless of underlying event volume."""
    while True:
        await asyncio.sleep(app.notify.flush_interval_s)
        for uri in app.notify.drain():
            await _wyślij_aktualizację(uri)


# ---- tools -----------------------------------------------------------------


@mcp.tool()
def npub_to_hex(npub: str) -> str:
    """Decode an npub1... bech32 string into a 64-char lowercase hex pubkey."""
    return app.npub_to_hex(npub)


@mcp.tool()
def hex_to_npub(pubkey_hex: str) -> str:
    """Encode a 64-char hex pubkey into the npub1... bech32 form."""
    return app.hex_to_npub(pubkey_hex)


@mcp.tool(description=_RUN_SQL_DESCRIPTION)
def run_sql(query: str, params: list | None = None) -> list[dict]:
    return app.run_sql(query, params)


@mcp.tool()
def subscribe_to_pubkey(pubkey: str, limit: int = 100) -> str:
    """Mark a pubkey as PRIMARY and return immediately. Discovery + live-sub
    activation run in the background (auto-tracker thread, sequential).

    Two-tier model:
      - primary   — pubkey explicitly tracked. We pull its full timeline
                    (events authored AND events tagging it via `p`) and keep
                    a live subscription open in both directions. Every
                    `p`-tag in a primary's events auto-registers the target
                    as a SECONDARY.
      - secondary — discovered via a primary's `p`-tag. Pulled + subscribed
                    identically to a primary, but its own `p`-tags are NOT
                    expanded further (1-hop frontier).

    Calling this on an existing secondary promotes it to primary (its
    events will start expanding into new secondaries; the auto-tracker
    rescans because `last_scan_at` is reset). Calling this on an existing
    primary is a no-op.

    Accepts a 64-char hex pubkey or an npub1... bech32 string. `limit` is
    the per-relay REQ limit used by discovery and by every live (re)connect.

    Returns one of:
      - "Subscribed." — newly registered, or promoted from secondary
      - "It is already subscribed." — was already primary
    """
    return app.subscribe_to_pubkey(pubkey, limit)


@mcp.tool()
def set_tracking_status(pubkey: str, status: str, limit: int = 100) -> str:
    """Unified entry point for `tracked_pubkeys.role` transitions. Use this
    instead of `subscribe_to_pubkey` when you need to demote a primary down
    to secondary, ignore a pubkey entirely, or revive an ignored one.

    `status` values:
      - 'primary'   — discovery + live sub on both `{authors}` and `{"#p"}`,
                      every `p`-tag of their events registers the target as
                      a new secondary (forward frontier expansion).
      - 'secondary' — discovery + live sub on `{authors}` only; 1-hop frontier
                      (their `p`-tags do NOT generate further secondaries).
      - 'ignored'   — tombstone: no discovery, no live sub, frontier expansion
                      skips them (the row's mere existence blocks re-insertion
                      via `_register_secondary_unlocked`).

    Side effects detected from current → desired:
      - INTO primary/secondary: `last_scan_at` reset, auto-tracker re-runs
        discovery on the next tick (only in `scan_mode='full-scan'`).
      - AWAY FROM primary (→ secondary or → ignored): live subs torn down
        immediately so the `{"#p"}` filter stops firing.
      - TO ignored from any role: live subs torn down; `last_scan_at` stamped
        to "now" so the row is invisible to the auto-tracker even if a filter
        regresses.

    Accepts a 64-char hex pubkey or an npub1... bech32 string. `limit` is
    the per-relay REQ limit stored on the row (used by discovery + live
    REQs); ignored when `status='ignored'`. Defaults to 100.

    Returns the transition tag — one of: `already`, `new_primary`,
    `new_secondary`, `new_ignored`, `primary_to_secondary`,
    `primary_to_ignored`, `secondary_to_primary`, `secondary_to_ignored`,
    `ignored_to_primary`, `ignored_to_secondary`.
    """
    return app.set_tracking_status(pubkey, status, limit)


@mcp.tool()
def rescan_pubkey(pubkey: str) -> str:
    """Re-run relay discovery for an already-tracked pubkey. Use when the
    pubkey's relay landscape changed — old `productive_relays` went stale,
    new productive relays appeared on the network — and you want fresh
    sources of their events.

    NIEBLOKUJĄCY. Sets `last_scan_at = NULL`; the auto-tracker thread picks
    the row up at its next tick (within `AUTO_TRACK_INTERVAL_S=30s`) and
    runs full Phase 1 probe of every active relay + Phase 2 greedy pick
    of up to 3. Old live subs are torn down and fresh ones opened by the
    rescan itself — caller doesn't need to do anything else.

    Role is preserved (primary stays primary, secondary stays secondary).
    For first-time tracking use `subscribe_to_pubkey`. Accepts hex or
    `npub1...` bech32. Returns:
      - 'Queued for rescan.'                  — row marked
      - 'Not tracked. Use subscribe_to_pubkey first.' — pubkey absent
    """
    return app.rescan_pubkey(pubkey)


@mcp.tool()
def set_scan_mode(mode: str) -> dict:
    """Suspend or resume active relay scanning without dropping the daemon.

    Modes:
      - 'full-scan' (default at every MCP boot) — auto-tracker, mention-sweep
        and `track_pubkey.run` discovery probes run normally.
      - 'no-scan' — those active scans are paused on their next tick so the
        DB stops absorbing discovery / mention-sweep traffic. Live subs and
        the relay reconcile loop keep running, so events that already-open
        relays push to us continue to be ingested.

    Flag is in-memory only — every MCP restart resets to 'full-scan'. Pending
    `last_scan_at IS NULL` rows queued by `subscribe_to_pubkey` /
    `rescan_pubkey` while in 'no-scan' are picked up automatically once you
    flip back to 'full-scan'. Returns `{"scan_mode": <new mode>}`.
    """
    return app.set_scan_mode(mode)


@mcp.tool()
def relay_query(
    filter: dict, timeout_s: float = 5.0,
    relays: str | list[str] | None = None,
) -> dict:
    """Send a NIP-01 REQ with the given filter and return matching events
    WITHOUT writing anything to storage. Orthogonal to
    update_graph_with_events_authored_by (which ingests).

    The filter is a standard NIP-01 filter object — any combination of ids,
    authors, kinds, since, until, limit, and tag filters (#e, #p, #a, ...).
    NIP-01 requires at least one key.

    `relays` pins the query to a specific relay (a single ws(s):// URL) or a
    list of them — the REQ goes only there, so you don't fan out across the
    whole registry. The URL may be an ad-hoc relay the daemon isn't tracking;
    the bus opens it on demand. An invalid URL is a hard error. When omitted,
    the query fans out to every active relay in the registry.

    Events are deduplicated by id across relays; each returned event has a
    "seen_on" list with the URLs of every relay that delivered it.
    """
    return app.relay_query(filter, timeout_s, relays)


# ---- entrypoint ------------------------------------------------------------

MCP_HOST = "localhost"
MCP_PORT = 8100


def _abort_if_port_taken(host: str, port: int) -> None:
    """Twardy fail przed startem: jeśli ktoś już słucha na (host, port),
    nie chcemy odpalać drugiej instancji (zbiłoby to write-lock DuckDB,
    auto-tracker bigałby przeciw sobie, gateway nie wiedziałby do kogo
    routować). Zamiast pozwolić uvicornowi wystartować i potem rzucić
    EADDRINUSE z mało czytelnym tracebackiem, sprawdzamy preflight.

    SO_REUSEADDR celowo NIE ustawiany — chcemy żeby się zderzyło. Krótkie
    `connect_ex` na 100ms wystarcza: jeśli ktoś listenuje na localhost,
    odpowiada od razu.
    """
    import socket

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(0.1)
    try:
        if sock.connect_ex((host, port)) == 0:
            raise SystemExit(
                f"FATAL: port {host}:{port} jest już zajęty — inna instancja "
                f"mcp_admin (lub coś innego) już słucha. Ubij ją zanim "
                f"odpalisz nową: `ss -ltnp | grep {port}` żeby znaleźć PID."
            )
    finally:
        sock.close()


if __name__ == "__main__":
    _abort_if_port_taken(MCP_HOST, MCP_PORT)
    app.boot()
    print(
        f"-------------- claude mcp add --transport http nostr_admin "
        f"http://{MCP_HOST}:{MCP_PORT}/mcp/",
        flush=True,
    )
    uvicorn.run(
        mcp.http_app(transport="streamable-http"),
        host=MCP_HOST, port=MCP_PORT,
    )
