"""
Runtime for nostr explorer (Phase 2).

Split into:
- SharedRuntime — gateway connection + capability pool + agent config; singleton, init once.
- Session — owns its own Agent (built from SharedRuntime), per-session command/event queues,
  per-session asyncio task.
- SessionsManager — registry of sessions, idle GC, hard cap.

Each browser tab opens a WebSocket and posts commands tagged with its sid.
Tables are pushed to the GUI exclusively via the agent's `end_turn(show=...)` —
diagnostic SQL stays out of the user's view. `run_sql` itself is wrapped only to
enforce JOU tagging (prog=0) and a per-turn cap; it never auto-pushes anymore.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
import re
import sys
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable

logger = logging.getLogger("mcp_nostr_explorer.agent_runtime")

_HERE = os.path.dirname(os.path.abspath(__file__))
_REPO = os.path.dirname(_HERE)
_REPO_PARENT = os.path.dirname(_REPO)
_JOU = os.path.join(_REPO, "jou")
_CONF_PATH = os.path.join(_REPO_PARENT, "conf", "agent0.json")
_AGENTS_DIR = os.path.join(_JOU, "agents")
_NOSTR_AGENT = "nostr"

for p in (_REPO, _JOU):
    if p not in sys.path:
        sys.path.insert(0, p)

from sdk.agent import Agent  # noqa: E402
from sdk.adaptery.mcp_http import KlientMCP  # noqa: E402
from sdk.capability import Capability  # noqa: E402
from sdk.protokol_mcp import BłądNarzędzia  # noqa: E402
from sdk.harvesting import harvest_pool, select_resources, select_tools  # noqa: E402
from sdk.odkrycie_jou import OdkrycieJOU  # noqa: E402
from sdk.odkrycie_pelne import OdkryciePełne  # noqa: E402
from sdk.chat import SYSTEM_PROMPT  # noqa: E402
from core.agent_config import load_agent_configs  # noqa: E402

from uruchom import (  # noqa: E402
    klasa_dla_providera,
    klucz_z_sekcji,
    model_z_sekcji,
    sekcja_providera,
    wybierz_provider,
)

from . import billing  # noqa: E402

SUFIKS_NARZEDZIA_SQL = "run_sql"
NAZWA_SHOW_RESULT = "explorer__show_result"
NAZWA_SHOW_PUBKEY_STATUS = "explorer__show_pubkey_status"

# Resource URI template w mcp_nostr_db dla per-pubkey live stats.
# Push notifications (`resources/updated`) lecą po każdym evencie
# dotykającym tracked pubkeya, debounce po stronie serwera (NOTIFY_FLUSH_INTERVAL_S).
_PUBKEY_STATUS_URI_FMT = "tracked://pubkey/{hex}"

# Walidacja nazw tabel przekazywanych do `explorer__show_result(table=...)` —
# DuckDB zezwala na identyfikatory do 63 znaków, zaczynających się od litery
# lub podkreślnika. Chroni przed SQL-injection w server-side `SELECT * FROM`.
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$")

OPIS_GUI_DOPISKA = (
    "\n\nUWAGA: w tym deploymencie KAŻDE zapytanie `run_sql` jest pokazywane "
    "userowi do zatwierdzenia w modalu — po approve wynik renderuje się jako "
    "karta tabeli w GUI (user świadomie zobaczył co odpytałeś). Reject → "
    "narzędzie rzuca błąd. Konsekwencja: nie spamuj diagnostyką — user kliknie "
    "Enter na każde zapytanie. Dla finalnej prezentacji z dobrym tytułem nadal "
    "wołaj `explorer__show_result(title, table|sql)` — agent dostaje tylko "
    "{shown, rows}. Hard cap: max 3 wywołania `run_sql` na turę."
)

# Doklejane do system promptu sesji (explorer-only — patrz `_zainstaluj_aliasy`).
# Pubkeye/event-id nie docierają do Ciebie surowe: zamiast 64-znakowego hexa
# widzisz krótki alias `@p<liczba><litera>` (np. `@p5m`). Używaj go DOSŁOWNIE.
OPIS_ALIASOW = (
    "\n\n---\n\n"
    "## Aliasy pubkey/event-id\n\n"
    "W tym środowisku NIE widzisz surowych 64-znakowych hexów ani npub/note. "
    "Każdy taki ciąg dostajesz jako krótki alias `@p<liczba><litera>`, np. `@p5m`. "
    "Zasady:\n"
    "- Używaj aliasu DOKŁADNIE tak jak go dostałeś, włącznie z literą na końcu "
    "(to suma kontrolna). W SQL traktuj go jak zwykłą wartość: "
    "`WHERE pubkey = '@p5m'` — system rozwinie go do prawdziwego hexa przed "
    "wykonaniem. Tak samo w argach narzędzi (np. `show_pubkey_status`).\n"
    "- NIGDY nie zgaduj, nie skracaj ani nie sklejaj aliasów. Jeśli potrzebujesz "
    "pubkeya którego nie masz w aliasie — odczytaj go przez `jou_odczytaj`, "
    "dostaniesz świeży alias.\n"
    "- Przekręcony alias (zła litera/numer) → narzędzie rzuci błędem zamiast "
    "po cichu trafić w inny pubkey. Wtedy odczytaj wartość ponownie."
)

# Konfigurowalne — możemy podnieść jak będzie potrzeba
SESSION_IDLE_TIMEOUT_S = 30 * 60   # 30 min idle → tear down
SESSION_GC_INTERVAL_S = 60
MAX_SESSIONS = 50

# Symulacja "user już zapłacił" — startowy stan konta w sat.
# Wartość celowo dobrana tak, żeby nawet kilka turow opus-4-7 zostawiało
# widoczny ślad odejmowania (1 tura ≈ 10-100 sat przy obecnym kursie).
DEFAULT_BALANCE_SAT = 10_000

# Hard cap na liczbę wywołań run_sql w jednej turze. LLM bywa wpada w pętlę
# "jeszcze jedno przeformułowanie tego samego zapytania" — agent.py łapie
# tylko IDENTYCZNE powtórzenia (ten sam name+args), a nie kosmetyczne warianty
# (LIMIT 200, inna kolejność kolumn, inna nazwa CTE). 3 to liczba dobrana
# z obserwacji: po 3 wariantach typowo widać już co jest w bazie albo że
# nie ma. Po przekroczeniu wrapper zwraca błąd-strażnik do agenta.
MAX_RUN_SQL_PER_TURN = 3

# Timeout na potwierdzenie SQL przez usera. Po jego upływie agent dostaje
# RuntimeError „brak potwierdzenia" — bezpieczniej niż wisieć w nieskończoność
# (LLM trzyma turę otwartą, balance leci dalej). 5 min wystarcza na otwarcie
# laptopa po niquickiej przerwie; jak user nie zdąży, niech agent spróbuje
# ponownie w następnej turze.
SQL_APPROVAL_TIMEOUT_S = 5 * 60


def _na_compact_format(rows: list) -> dict[str, Any]:
    """list[dict] → {columns, rows: [[...], ...]}."""
    if not rows:
        return {"columns": [], "rows": []}
    if not all(isinstance(r, dict) for r in rows):
        return {
            "columns": ["value"],
            "rows": [[json.dumps(r, default=str, ensure_ascii=False)] for r in rows],
        }
    columns: list[str] = []
    seen: set[str] = set()
    for r in rows:
        for k in r:
            if k not in seen:
                seen.add(k)
                columns.append(k)
    compact_rows = [[r.get(c) for c in columns] for r in rows]
    return {"columns": columns, "rows": compact_rows}


# ---------- Alias registry (pubkey/event hex ↔ krótki token dla LLM) ----------
#
# Model przekręca długie ciągi hex/bech32, gdy musi je odtworzyć token-po-tokenie
# w SQL czy argach narzędzia. Zamiast tego nigdy nie pokazujemy mu surowego ciągu:
# na WYJŚCIU (każdy zwrot toola) podmieniamy hex/npub na krótki alias `@p<n><c>`,
# na WEJŚCIU (args każdego toola, w tym SQL) rozwijamy alias z powrotem. `<c>` to
# znak kontrolny — literówka w numerze albo w nim samym → twardy błąd (raise),
# nie cichy zły pubkey. Rejestr jest per-sesja i stabilny: ten sam ciąg dostaje
# zawsze ten sam alias.

# 64-znakowy lowercase hex (pubkey lub event-id) oraz bech32 (npub/note/…).
_HEX64_RE = re.compile(r"\b[0-9a-f]{64}\b")
_BECH32_RE = re.compile(r"\b(?:npub|note|nevent|nprofile|naddr)1[02-9ac-hj-np-z]{20,}\b")
# Token aliasu wstrzykiwany do kontekstu modelu i rozwijany z powrotem. Znak
# kontrolny jest LITERĄ — dzięki temu `@p<cyfry><litera>` jest jednoznaczny
# (gdyby był cyfrą, `@p52` byłoby dwuznaczne: n=5,c=2 vs n=52,c=?).
_ALIAS_RE = re.compile(r"@p(\d+)([a-z])")


def _znak_kontrolny(n: int) -> str:
    """Znak kontrolny aliasu — deterministyczny z numeru. Multiplikatywny, żeby
    zmiana jednej cyfry numeru prawie zawsze zmieniła też znak kontrolny."""
    return chr(ord("a") + (n * 7 + 3) % 26)


class AliasRegistry:
    """Per-sesja, dwukierunkowy rejestr ciąg↔alias. Stabilny: ten sam ciąg →
    zawsze ten sam alias. `expand` rzuca (hard error) na nieznanym aliasie albo
    złym znaku kontrolnym — nigdy nie zwraca cicho innego pubkeya."""

    def __init__(self) -> None:
        self._do_aliasu: dict[str, str] = {}
        self._do_ciągu: dict[str, str] = {}
        self._licznik = 0

    def mint(self, ciąg: str) -> str:
        alias = self._do_aliasu.get(ciąg)
        if alias is not None:
            return alias
        self._licznik += 1
        n = self._licznik
        alias = f"@p{n}{_znak_kontrolny(n)}"
        self._do_aliasu[ciąg] = alias
        self._do_ciągu[alias] = ciąg
        return alias

    def expand(self, alias: str) -> str:
        m = _ALIAS_RE.fullmatch(alias)
        if m is None:
            raise ValueError(f"Niepoprawny alias: {alias!r}")
        n, c = int(m.group(1)), m.group(2)
        if _znak_kontrolny(n) != c:
            raise ValueError(
                f"Alias {alias!r} ma błędny znak kontrolny — prawdopodobnie "
                "przekręcony. Odczytaj wartość ponownie (jou_odczytaj) zamiast "
                "zgadywać alias."
            )
        ciąg = self._do_ciągu.get(alias)
        if ciąg is None:
            raise ValueError(
                f"Nieznany alias {alias!r} — nie był nadany w tej sesji. "
                "Odczytaj wartość przez jou_odczytaj zamiast zgadywać alias."
            )
        return ciąg


def _podmień_wychodzące(obj: Any, reg: AliasRegistry) -> Any:
    """Rekursywnie zamienia każdy hex64/bech32 w strukturze (dict/list/str) na
    alias z `reg`. Wszystko inne (liczby, None, obiekty jak BłądNarzędzia)
    zostaje bez zmian."""
    if isinstance(obj, str):
        obj = _HEX64_RE.sub(lambda m: reg.mint(m.group(0)), obj)
        obj = _BECH32_RE.sub(lambda m: reg.mint(m.group(0)), obj)
        return obj
    if isinstance(obj, dict):
        return {k: _podmień_wychodzące(v, reg) for k, v in obj.items()}
    if isinstance(obj, list):
        return [_podmień_wychodzące(v, reg) for v in obj]
    if isinstance(obj, tuple):
        return tuple(_podmień_wychodzące(v, reg) for v in obj)
    return obj


def _rozwiń_przychodzące(obj: Any, reg: AliasRegistry) -> Any:
    """Rekursywnie rozwija każdy alias `@p<n><c>` na surowy ciąg. Nieznany lub
    przekręcony alias → wyjątek z `AliasRegistry.expand` (hard error). Dotyka
    wyłącznie tokenów `@p…` — `data['pubkey']`, tagi `#1` itd. zostają nietknięte."""
    if isinstance(obj, str):
        return _ALIAS_RE.sub(lambda m: reg.expand(m.group(0)), obj)
    if isinstance(obj, dict):
        return {k: _rozwiń_przychodzące(v, reg) for k, v in obj.items()}
    if isinstance(obj, list):
        return [_rozwiń_przychodzące(v, reg) for v in obj]
    if isinstance(obj, tuple):
        return tuple(_rozwiń_przychodzące(v, reg) for v in obj)
    return obj


def _rozwiń_do_wyświetlenia(text: str, reg: AliasRegistry) -> str:
    """Best-effort wersja dla `assistant_text` — alias→ciąg dla usera. Nieznany
    alias zostawiamy jak jest (display to nie egzekucja narzędzia, nie wywalamy
    całej tury przez literówkę modela w prozie)."""
    def repl(m: re.Match) -> str:
        try:
            return reg.expand(m.group(0))
        except ValueError:
            return m.group(0)
    return _ALIAS_RE.sub(repl, text)


def _zainstaluj_aliasy(agent: Agent, session: "Session") -> None:
    """Owija handler KAŻDEJ capability dwustronnie: args wchodzące → rozwijamy
    aliasy do surowych ciągów PRZED handlerem; wynik wychodzący → podmieniamy
    hex/bech32 na aliasy ZANIM dotrze do modelu. Surowy pubkey istnieje już
    tylko w magazynie JOU i w SQL-u lecącym do bazy — kontekst LLM jest go
    pozbawiony.

    MUSI być wołane jako OSTATNIE w setupie sesji — żeby owinąć też wrappery
    run_sql / show_* / show_pubkey_status zainstalowane wcześniej."""
    reg = session.aliases
    for nazwa in agent.registry.names():
        cap = agent.registry.get(nazwa)
        orig = cap.handler

        def make(orig_handler: Callable):
            async def wrapped(args: dict):
                args = _rozwiń_przychodzące(args, reg)
                raw = await orig_handler(args)
                return _podmień_wychodzące(raw, reg)
            return wrapped

        cap.handler = make(orig)
    logger.info(
        f"[{agent.nazwa}] alias I/O zainstalowane na {len(agent.registry.names())} narzędziach"
    )


# ---------- Shared (gateway + pool + agent config) ----------

class SharedRuntime:
    """Gateway connection + capability pool + LLM/agent config — wspólne dla wszystkich sesji."""

    def __init__(self) -> None:
        self._gateway: KlientMCP | None = None
        self.pool = None
        self.cfg_a = None  # AgentConfig dla nostr
        self.system_prompt: str = ""
        self.llm_class: Any = None
        self.api_key: str = ""
        self.agent_model: str = ""

    @property
    def ready(self) -> bool:
        return self._gateway is not None and self.pool is not None

    async def setup(self) -> None:
        with open(_CONF_PATH) as f:
            cfg = json.load(f)

        provider = wybierz_provider(cfg)
        klasa, domyślny_model = klasa_dla_providera(provider)
        sekcja = sekcja_providera(cfg, provider)
        api_key = klucz_z_sekcji(sekcja, provider)
        model = model_z_sekcji(sekcja, provider, domyślny_model)

        gateway_url = cfg.get("gateway_url")
        if not gateway_url:
            raise RuntimeError("Brak `gateway_url` w conf/agent0.json")

        logger.info(f"Łączę z gateway: {gateway_url} (czekam aż wstanie, Ctrl-C anuluje)")
        proba = 0
        while True:
            proba += 1
            self._gateway = KlientMCP(gateway_url)
            try:
                await asyncio.wait_for(self._gateway.połącz(), timeout=5)
                break
            except asyncio.CancelledError:
                # Ctrl-C — sprzątamy i propagujemy
                try:
                    await self._gateway.zamknij()
                except Exception:
                    pass
                raise
            except Exception as e:
                logger.warning(
                    f"Gateway {gateway_url} nieosiągalny "
                    f"({type(e).__name__}: {e}) — próba {proba}, ponawiam za 3s"
                )
                try:
                    await self._gateway.zamknij()
                except Exception:
                    pass
                await asyncio.sleep(3)
        if proba > 1:
            logger.info(f"Gateway odpowiedział po {proba} próbach")

        self.pool = await harvest_pool({"gateway": self._gateway})
        logger.info(f"Pula: {len(self.pool.tools)} narzędzi, {len(self.pool.resources)} zasobów")

        agent_configs = load_agent_configs(_AGENTS_DIR)
        if _NOSTR_AGENT not in agent_configs:
            raise RuntimeError(f"Brak konfiguracji agenta '{_NOSTR_AGENT}' w {_AGENTS_DIR}")
        self.cfg_a = agent_configs[_NOSTR_AGENT]

        prompt = SYSTEM_PROMPT
        if self.cfg_a.system_prompt:
            prompt = SYSTEM_PROMPT + "\n\n---\n\n" + self.cfg_a.system_prompt
        self.system_prompt = prompt + OPIS_ALIASOW

        self.llm_class = klasa
        self.api_key = api_key
        self.agent_model = self.cfg_a.model or model

        logger.info(
            f"SharedRuntime gotowy (provider={provider}, model={self.agent_model})"
        )

    def build_agent(self, sid: str) -> Agent:
        """Buduje Agent dla nowej sesji — z własnym JOU magazynem."""
        if not self.ready:
            raise RuntimeError("SharedRuntime nie zainicjalizowany")

        odkrycie = (
            OdkrycieJOU() if self.cfg_a.discovery_mode == "lazy_jou" else OdkryciePełne()
        )
        llm = self.llm_class(api_key=self.api_key, model=self.agent_model)
        agent = Agent(
            nazwa=f"{_NOSTR_AGENT}#{sid[:8]}",
            llm=llm,
            system_prompt=self.system_prompt,
            odkrycie=odkrycie,
        )
        if self.cfg_a.tools:
            agent.registry.add_many(select_tools(self.pool, self.cfg_a.tools))
        for rec in select_resources(self.pool, self.cfg_a.resources):
            agent.register_resource(rec.client, rec.uri)
        return agent

    async def close(self) -> None:
        if self._gateway is not None:
            try:
                await self._gateway.zamknij()
            except Exception:
                pass
            self._gateway = None
        self.pool = None


# ---------- Session ----------

@dataclass
class Session:
    sid: str
    agent: Agent
    cmd_queue: asyncio.Queue = field(default_factory=asyncio.Queue)
    # Fan-out: każdy aktywny WS ma własną kolejkę. emit() pushuje do wszystkich.
    # Lista (nie set) — kolejki nie są hashable, ale i tak jest ich mało (1-2).
    subscribers: list[asyncio.Queue] = field(default_factory=list)
    last_seen: float = field(default_factory=time.monotonic)
    task: asyncio.Task | None = None
    # Saldo w sat — symulacja "user już zapłacił". Po każdej turze zjadamy
    # tyle, ile model nas kosztował (z `usage.cost` payperq, przeliczone po
    # aktualnym kursie BTC/USD). Realnej integracji LN nie ma — `topup` to
    # endpoint-stub, który dolicza saty bez sprawdzania invoice.
    balance_sat: int = DEFAULT_BALANCE_SAT
    # Licznik wywołań run_sql w bieżącej turze. Zerowany w `loop()` przed
    # `agent.wyślij`, inkrementowany przez wrapper run_sql. Po przekroczeniu
    # MAX_RUN_SQL_PER_TURN wrapper zwraca błąd-strażnik agentowi.
    run_sql_calls_in_turn: int = 0
    # Rejestr aliasów pubkey/event-id dla tej sesji. Model widzi krótkie tokeny
    # `@p<n><c>` zamiast 64-znakowych hexów (eliminacja przekręcania); podmiana
    # in/out instalowana przez `_zainstaluj_aliasy` na handlerach capabilities.
    aliases: "AliasRegistry" = field(default_factory=lambda: AliasRegistry())
    # Otwarte panele live-stats per pubkey (hex → {uri, callback}). Każdy
    # panel ma jedną subskrypcję resource'a w gateway-u; callback re-czyta
    # snapshot i emituje `pubkey_panel_update` do WS subskrybentów sesji.
    # Cleanup przy zamykaniu sesji odsubskrybowuje wszystkie naraz.
    pubkey_panels: dict[str, dict] = field(default_factory=dict)
    # Oczekujące potwierdzenia SQL: request_id → Future. Każde
    # `request_sql_approval` tworzy Future, emituje WS event do usera i
    # czeka — Future rozwiązywany jest przez `resolve_sql_approval` z
    # pump_input (gdy user kliknie Zatwierdź/Odrzuć) albo przez timeout.
    pending_sql_approvals: dict[str, asyncio.Future] = field(default_factory=dict)

    def touch(self) -> None:
        self.last_seen = time.monotonic()

    def model(self) -> str:
        """Aktualnie używany model LLM dla tej sesji.
        Czytamy z `agent.llm._model` — adaptery (anthropic, openai, payperq)
        wszystkie trzymają go pod tą samą nazwą atrybutu."""
        return getattr(self.agent.llm, "_model", "")

    def set_model(self, model: str) -> None:
        """Override modelu dla nowych rund w tej sesji. Zmiana atrybutu LLM
        łapana jest w następnym `agent.llm.wyślij` — w środku tury bieżącej
        nie wywłaszczamy niczego."""
        if not isinstance(model, str) or not model.strip():
            raise ValueError("model musi być niepustym stringiem")
        self.agent.llm._model = model.strip()
        self.touch()

    async def charge_for_usd(self, cost_usd: float) -> dict:
        """Odejmuje od balance równowartość `cost_usd` w sat-ach
        (po aktualnym kursie BTC/USD). Zwraca słownik z polami eventu
        `balance` — caller emit'uje go do WS-a po `done`.

        Saldo nie schodzi poniżej zera — jeśli użytkownik nie ma środków,
        nadal pozwalamy turę policzyć (już się wykonała), ale balance ląduje
        na 0 i kolejne tury powinny być blokowane wcześniej (TODO: precheck
        w pump_input). Świadomie raczej oddamy turę za darmo niż złapiemy
        ujemne saldo, którego nie da się wytłumaczyć userowi."""
        sats = await billing.usd_to_sat(cost_usd)
        if sats > self.balance_sat:
            sats = self.balance_sat
        self.balance_sat -= sats
        return {
            "type": "balance",
            "sat": self.balance_sat,
            "last_charge_sat": sats,
            "last_charge_usd": float(cost_usd),
        }

    def add_sats(self, sats: int) -> dict:
        """Dolicza saty (topup-stub). Zwraca event `balance`."""
        if sats <= 0:
            raise ValueError("sats musi być > 0")
        self.balance_sat += int(sats)
        self.touch()
        return {
            "type": "balance",
            "sat": self.balance_sat,
            "last_charge_sat": -int(sats),  # ujemne = doładowanie
            "last_charge_usd": 0.0,
        }

    def subscribe(self) -> asyncio.Queue:
        """Rejestruje nowego subskrybenta WS. Zwraca jego prywatną kolejkę."""
        q: asyncio.Queue = asyncio.Queue()
        self.subscribers.append(q)
        self.touch()
        return q

    def unsubscribe(self, q: asyncio.Queue) -> None:
        try:
            self.subscribers.remove(q)
        except ValueError:
            pass

    async def emit(self, event: dict) -> None:
        """Broadcast eventu do wszystkich aktualnych subskrybentów.
        Brak subskrybentów = event przepada — sesja może produkować eventy
        zanim ktoś się podłączy (np. agent kończy turę po reconnect WS),
        ale każdy `enqueue_command` jest poprzedzony podpięciem WS po stronie
        klienta, więc realnie nie tracimy nic istotnego."""
        for q in list(self.subscribers):
            q.put_nowait(event)

    def emit_sync(self, event: dict) -> None:
        """Sync wariant — używany przez wrapper run_sql (callback z async fn,
        ale wewnątrz tego samego event-loopa). put_nowait do każdego subskrybenta."""
        for q in list(self.subscribers):
            q.put_nowait(event)

    async def enqueue_command(self, command: str) -> None:
        self.touch()
        await self.cmd_queue.put(command)

    async def close_pubkey_panel(self, pubkey_hex: str, gateway: KlientMCP) -> bool:
        """Zamyka jeden otwarty panel: odsubskrybuje resource w gateway-u
        i emituje `pubkey_panel_close` do WS-ów. Zwraca True jeśli faktycznie
        coś zamknął. Bezpieczne do wielokrotnego wywołania."""
        info = self.pubkey_panels.pop(pubkey_hex, None)
        if info is None:
            return False
        try:
            await gateway.odsubskrybuj_zasób(info["uri"], info["callback"])
        except Exception:
            logger.exception(
                f"[{self.sid[:8]}] odsubskrybuj_zasób({info['uri']}) zawiodło"
            )
        await self.emit({"type": "pubkey_panel_close", "hex": pubkey_hex})
        self.touch()
        return True

    async def close_all_pubkey_panels(self, gateway: KlientMCP) -> None:
        """Cleanup przy zamykaniu sesji: zerwij wszystkie subskrypcje. WS-y
        i tak właśnie giną, więc emitów nie wysyłamy."""
        for hex_ in list(self.pubkey_panels.keys()):
            info = self.pubkey_panels.pop(hex_, None)
            if info is None:
                continue
            try:
                await gateway.odsubskrybuj_zasób(info["uri"], info["callback"])
            except Exception:
                logger.exception(
                    f"[{self.sid[:8]}] odsubskrybuj_zasób({info['uri']}) zawiodło"
                )

    async def request_sql_approval(self, sql: str, purpose: str) -> None:
        """Emituje `sql_confirm_request` do WS-ów i blokuje aż user odpowie
        (Zatwierdź lub Odrzuć w GUI). Approve → return None; reject → rzuca
        RuntimeError z reasonem usera; timeout → rzuca RuntimeError.

        Każde zapytanie SQL konstruowane przez agenta przechodzi przez tę
        bramkę (run_sql wrapper i show_result), żeby user mógł zobaczyć
        i zatwierdzić/odrzucić — to świadoma decyzja deploymentu eksploratora,
        chronimy się przed agentowymi „przeczyść tabelę X" itp.

        `purpose` opisuje kontekst (np. `run_sql`, `show_result.table`) —
        czytane przez GUI w modalu.
        """
        request_id = uuid.uuid4().hex
        loop = asyncio.get_running_loop()
        fut: asyncio.Future = loop.create_future()
        self.pending_sql_approvals[request_id] = fut
        try:
            await self.emit({
                "type": "sql_confirm_request",
                "request_id": request_id,
                "sql": sql,
                "purpose": purpose,
            })
            try:
                approved, reason = await asyncio.wait_for(fut, timeout=SQL_APPROVAL_TIMEOUT_S)
            except asyncio.TimeoutError as e:
                raise RuntimeError(
                    f"Brak potwierdzenia SQL od usera w {SQL_APPROVAL_TIMEOUT_S}s — "
                    "zapytanie nie wykonane. Zakończ turę i poczekaj aż user wróci."
                ) from e
            if not approved:
                msg = "User odrzucił zapytanie SQL"
                if reason:
                    msg += f": {reason}"
                raise RuntimeError(msg)
        finally:
            self.pending_sql_approvals.pop(request_id, None)

    def resolve_sql_approval(self, request_id: str, approved: bool, reason: str = "") -> bool:
        """Wywoływane z pump_input gdy WS dostanie `sql_confirm_response`.
        Zwraca True jeśli faktycznie rozwiązano pending future (False = nieznany
        request_id albo future już zamknięty — np. timeout zdążył wcześniej)."""
        fut = self.pending_sql_approvals.get(request_id)
        if fut is None or fut.done():
            return False
        fut.set_result((bool(approved), reason or ""))
        return True

    def fail_all_sql_approvals(self, reason: str) -> None:
        """Wszystkie wiszące potwierdzenia → rejected. Używane przy
        teardown sesji — żeby task agenta nie wisiał na czekaniu na
        odpowiedź z zerwanego WS-a."""
        for request_id, fut in list(self.pending_sql_approvals.items()):
            if not fut.done():
                fut.set_result((False, reason))

    async def loop(self) -> None:
        """Pętla per-sesja: pobiera komendy, woła agenta, emituje eventy."""
        while True:
            try:
                cmd = await self.cmd_queue.get()
            except asyncio.CancelledError:
                break
            if cmd is None:  # sentinel = zamknij
                break

            self.touch()
            logger.info(f"[{self.sid[:8]}] user: {cmd}")
            await self.emit({"type": "started", "command": cmd})
            t0 = time.monotonic()
            cost = 0.0
            done_event = {"type": "done", "cost_usd": 0.0, "took_ms": 0}
            self.run_sql_calls_in_turn = 0
            try:
                koszt0 = self.agent.koszt_usd_łącznie
                text = await self.agent.wyślij(cmd)
                cost = self.agent.koszt_usd_łącznie - koszt0
                took_ms = int((time.monotonic() - t0) * 1000)
                if text:
                    text = _rozwiń_do_wyświetlenia(text, self.aliases)
                    await self.emit({"type": "assistant_text", "text": text})
                done_event = {"type": "done", "cost_usd": cost, "took_ms": took_ms}
            except Exception as e:
                logger.exception(f"[{self.sid[:8]}] błąd w pętli sesji")
                await self.emit({"type": "error", "message": str(e)})
                done_event = {
                    "type": "done",
                    "cost_usd": 0.0,
                    "took_ms": int((time.monotonic() - t0) * 1000),
                }

            # Najpierw `done` — UI ma zniknąć spinner; potem `balance` z aktualnym
            # stanem konta. Nawet przy cost=0 emitujemy balance (UX: user widzi
            # że nic mu nie ubyło, zamiast ciszy).
            await self.emit(done_event)
            try:
                balance_event = await self.charge_for_usd(cost)
                await self.emit(balance_event)
            except Exception:
                # Brak kursu BTC nie powinien zatrzymywać UI — log + skip event.
                logger.exception(f"[{self.sid[:8]}] charge_for_usd zawiodło")
            self.touch()


def _zainstaluj_wrapper_sql(
    agent: Agent,
    session: "Session",
    on_table: Callable[[dict], None],
) -> Capability | None:
    """Podmienia `*__run_sql` na wrapper, który: (a) wymusza JOU tagging
    (prog=0) — agent nigdy nie dostaje surowych wierszy inline; (b) trzyma
    hard cap MAX_RUN_SQL_PER_TURN. Wrapper NIE pushuje do GUI — to robi
    `_zainstaluj_wrapper_end_turn(show=...)`.

    Zwraca oryginalny `Capability` (sprzed wrappera) — caller przekazuje go
    do wrappera end_turn, żeby `show` mógł wykonać SELECT-y bez wliczania
    do limitu agenta i bez przepuszczania wyniku przez JOU."""
    reg = agent.registry
    kandydaci = [
        n for n in reg.names()
        if n == SUFIKS_NARZEDZIA_SQL or n.endswith(f"__{SUFIKS_NARZEDZIA_SQL}")
    ]
    if not kandydaci:
        logger.warning(
            f"[{agent.nazwa}] brak '*__{SUFIKS_NARZEDZIA_SQL}' w rejestrze — wrapper pominięty."
        )
        return None
    if len(kandydaci) > 1:
        raise RuntimeError(f"Wieloznaczne dopasowanie '*__{SUFIKS_NARZEDZIA_SQL}': {kandydaci}")

    nazwa = kandydaci[0]
    orig = reg.remove(nazwa)
    logger.info(f"[{agent.nazwa}] GUI-wrapper podmienia narzędzie: {nazwa}")

    async def wrapped_handler(args: dict):
        if session.run_sql_calls_in_turn >= MAX_RUN_SQL_PER_TURN:
            logger.info(
                f"[{agent.nazwa}] run_sql cap {MAX_RUN_SQL_PER_TURN} osiągnięty — "
                f"odrzucam kolejne wywołanie"
            )
            raise RuntimeError(
                f"Limit {MAX_RUN_SQL_PER_TURN} wywołań run_sql na turę przekroczony. "
                "Zakończ turę z dotychczasowymi wynikami i odpowiedz userowi w oparciu "
                "o to co już wiesz; jeśli za mało — poproś o doprecyzowanie zamiast "
                "ponawiać zapytania."
            )
        session.run_sql_calls_in_turn += 1
        args = dict(args)
        args.pop("_jou_prog", None)
        # User-approval gate: każde zapytanie SQL pokazywane userowi w GUI,
        # czekamy aż kliknie Zatwierdź lub Odrzuć. Reject → RuntimeError leci
        # do LLM jako BłądNarzędzia (agent decyduje co dalej — typowo
        # przeprasza i prosi o doprecyzowanie).
        sql_text = str(args.get("query") or args.get("sql") or "")
        await session.request_sql_approval(sql_text, purpose="run_sql")
        try:
            raw = await orig.handler(args)
        except Exception:
            logger.exception(f"[{agent.nazwa}] run_sql wrapper: oryginalny handler rzucił")
            raise

        # Tool error short-circuit — must NOT go through JOU. Putting a
        # BłądNarzędzia into `obsłuż_wynik_narzędzia` would wrap it as a
        # tagged object (rodzaj_obiektu='nieznany'), and the next
        # `jou_odczytaj` would return a dict containing the raw exception
        # in `ładunek` — `json.dumps` then crashes in `zapisz_wyniki`.
        # Mirrors `Agent._wykonaj_jedno_wywołanie` for the non-wrapped path.
        if isinstance(raw, BłądNarzędzia):
            logger.info(f"[{agent.nazwa}] run_sql → BłądNarzędzia (inline)")
            return raw.jako_wynik()

        if isinstance(raw, list):
            logger.info(f"[{agent.nazwa}] run_sql → {len(raw)} wierszy (do JOU + GUI)")
            # User zatwierdził to zapytanie świadomie — pokaż mu wynik jako
            # kartę tabeli, żeby widział co przyszło. Failure renderingu
            # (np. wynik nie-list[dict]) nie blokuje agenta — leci normalnie
            # przez JOU.
            try:
                kompakt = _na_compact_format(raw)
                sql_one_line = " ".join(sql_text.split())
                title = sql_one_line[:60] + ("…" if len(sql_one_line) > 60 else "")
                on_table({"title": title or "(run_sql)", "table": kompakt})
            except Exception:
                logger.exception(f"[{agent.nazwa}] run_sql: rendering tabeli w GUI zawiódł")
        else:
            logger.info(f"[{agent.nazwa}] run_sql → typ wyniku {type(raw).__name__}")

        return agent.jou.obsłuż_wynik_narzędzia(raw, prog=0)

    wrapped = Capability(
        name=orig.name,
        description=orig.description + OPIS_GUI_DOPISKA,
        input_schema=orig.input_schema,
        handler=wrapped_handler,
        source=orig.source + "+gui_wrapper",
        requires_approval=orig.requires_approval,
        wraps_in_jou=False,
    )
    reg.add(wrapped)
    return orig


def _dodaj_show_result(
    agent: Agent,
    session: "Session",
    on_table: Callable[[dict], None],
    run_sql_orig: Capability | None,
) -> None:
    """Dodaje narzędzie `explorer__show_result(title, table|sql)` do rejestru
    agenta. Z perspektywy LLM wygląda jak każde inne MCP tool z prefixem backendu.

    Wykonanie SELECT-a idzie przez `run_sql_orig` (oryginalny capability sprzed
    GUI-wrappera), więc nie wlicza się do MAX_RUN_SQL_PER_TURN (to ograniczenie
    chroni przed pętlą reformułowania, nie przed prezentacją)."""
    if run_sql_orig is None:
        raise RuntimeError(
            f"[{agent.nazwa}] brak run_sql_orig — show_result wymaga handlera SQL "
            "do wykonania SELECT-ów po stronie serwera"
        )

    DEFINICJA = {
        "name": NAZWA_SHOW_RESULT,
        "description": (
            "Pokaż userowi tabelę w GUI eksploratora. Wynik leci wprost do "
            "przeglądarki, do Ciebie wraca tylko `{shown: True, rows: N}`. "
            "Wywołuj wielokrotnie w turze gdy chcesz pokazać kilka tabel.\n"
            "\n"
            "Podaj `title` (krótki tytuł karty) i DOKŁADNIE jedno z:\n"
            "- `table`: nazwa istniejącej tabeli/widoku — server wykona "
            "`SELECT * FROM <table>` i pokaże wynik. Najczęściej Twoja `t_*`.\n"
            "- `sql`: dowolny SELECT — server wykona i pokaże wynik. Używaj "
            "gdy nie masz nazwanej tabeli lub chcesz węższego rzutu kolumn.\n"
            "\n"
            "Wynik nie wraca do Ciebie pełen — bezkosztowo dla kontekstu LLM, "
            "kosztuje tylko query w DuckDB. Pre-formatowanie pod prezentację "
            "(np. `substr(content, 1, 200)`) MA SENS — GUI pokazuje surowo."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "title": {
                    "type": "string",
                    "description": "Krótki tytuł karty w GUI.",
                },
                "table": {
                    "type": "string",
                    "description": (
                        "Nazwa istniejącej tabeli/widoku. Podaj LUB `sql`, nie oba."
                    ),
                },
                "sql": {
                    "type": "string",
                    "description": (
                        "Dowolny SELECT. Podaj LUB `table`, nie oba."
                    ),
                },
            },
            "required": ["title"],
        },
    }

    async def handler(args: dict):
        title = (args.get("title") or "").strip()
        if not title:
            raise RuntimeError("show_result: brak `title`")
        table = (args.get("table") or "").strip() or None
        sql = (args.get("sql") or "").strip() or None
        if (table is None) == (sql is None):
            raise RuntimeError(
                "show_result: podaj DOKŁADNIE jedno z `table` lub `sql` "
                f"(dostałem: table={table!r}, sql={sql!r})"
            )
        if table is not None:
            if not _IDENT_RE.match(table):
                raise RuntimeError(
                    f"show_result: niepoprawna nazwa tabeli {table!r} — "
                    "tylko [A-Za-z_][A-Za-z0-9_]{0,62}"
                )
            query = f'SELECT * FROM "{table}"'
            approval_purpose = "show_result.table"
        else:
            query = sql
            approval_purpose = "show_result.sql"
        # Gate na potwierdzenie usera — show_result też konstruuje SQL
        # (`SELECT * FROM "<table>"` lub user-supplied `sql`), user ma to
        # zobaczyć i zatwierdzić zanim DuckDB go wykona.
        await session.request_sql_approval(query, purpose=approval_purpose)
        try:
            rows = await run_sql_orig.handler({"query": query})
        except Exception as e:
            raise RuntimeError(
                f"show_result(\"{title}\"): SQL padł: {type(e).__name__}: {e}"
            ) from e
        if not isinstance(rows, list):
            raise RuntimeError(
                f"show_result(\"{title}\"): SQL nie zwrócił listy "
                f"(typ wyniku: {type(rows).__name__}). Upewnij się że `table` "
                "wskazuje na istniejącą tabelę a `sql` to SELECT, nie DDL."
            )
        kompakt = _na_compact_format(rows)
        on_table({"title": title, "table": kompakt})
        logger.info(
            f"[{agent.nazwa}] show_result '{title}' → "
            f"{len(rows)} wierszy, {len(kompakt['columns'])} kolumn"
        )
        return {"shown": True, "rows": len(rows)}

    cap = Capability(
        name=DEFINICJA["name"],
        description=DEFINICJA["description"],
        input_schema=DEFINICJA["inputSchema"],
        handler=handler,
        source="mcp_nostr_explorer:show_result",
        requires_approval=False,
        wraps_in_jou=False,
    )
    agent.registry.add(cap)
    logger.info(f"[{agent.nazwa}] dodano narzędzie {NAZWA_SHOW_RESULT}")


def _dodaj_show_pubkey_status(
    agent: Agent,
    session: "Session",
    gateway: KlientMCP,
) -> None:
    """Dodaje narzędzie `explorer__show_pubkey_status(pubkey)` do rejestru
    agenta. Pokazuje userowi LIVE-panel ze statusem jednego tracked pubkeya
    — agent wywołuje raz, panel sam się aktualizuje przez subskrypcję
    resource'a `tracked://pubkey/{hex}` w mcp_nostr_db (push, debounce 2s).

    Pubkey MUSI być 64-char lowercase hex. Jeśli agent ma `npub1...`, niech
    najpierw zawoła `nostr_admin__npub_to_hex` — duplikowanie dekodera bech32
    w explorerze tylko po to żeby zapisać jeden round LLM nie ma sensu.

    Untracked → handler zwraca `{shown: False, tracked: False, hint: ...}`,
    zostawiając LLM-owi decyzję co dalej (typowo: zapyta usera czy
    subskrybować przez `nostr_admin__subscribe_to_pubkey`)."""
    DEFINICJA = {
        "name": NAZWA_SHOW_PUBKEY_STATUS,
        "description": (
            "Otwórz w GUI eksploratora live-panel ze statusem jednego "
            "tracked pubkeya (rola, profil, liczniki eventów, kindy, "
            "last_seen per relay, secondaries). Panel sam się aktualizuje — "
            "wywołujesz RAZ.\n"
            "\n"
            "Wejście: `pubkey` — 64-char lowercase hex. Jeśli masz `npub1...`,"
            " najpierw zawołaj `nostr_admin__npub_to_hex`.\n"
            "\n"
            "Stan zwrotu:\n"
            "- `{shown: True}` — panel otwarty (lub był już otwarty).\n"
            "- `{shown: False, tracked: False, hex, hint}` — pubkey nie jest "
            "obserwowany. Powiedz userowi co znalazłeś i zapytaj czy "
            "zasubskrybować jako primary (jeśli tak — wywołaj "
            "`nostr_admin__subscribe_to_pubkey`, potem ponownie ten tool).\n"
            "\n"
            "Wynik nie wraca pełen — bezkosztowo dla kontekstu LLM. Zamknięcie"
            " panelu zostaje w gestii usera (X w UI)."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "pubkey": {
                    "type": "string",
                    "description": "64-char lowercase hex pubkey.",
                },
            },
            "required": ["pubkey"],
        },
    }

    _HEX_RE = re.compile(r"^[0-9a-f]{64}$")
    # Hard cap per gateway round-trip. Bez tego wisząca odpowiedź MCP
    # (np. gateway ze starym kodem nieznającym template'a) blokuje turę
    # agenta bezterminowo — z perspektywy usera „GUI nic nie pokazuje".
    GATEWAY_OP_TIMEOUT_S = 10.0

    async def handler(args: dict):
        pubkey = (args.get("pubkey") or "").strip().lower()
        if not _HEX_RE.match(pubkey):
            raise RuntimeError(
                f"show_pubkey_status: `pubkey` must be 64-char lowercase hex "
                f"(got {pubkey!r}). Use `nostr_admin__npub_to_hex` first if "
                "you only have an npub1... bech32 form."
            )
        if pubkey in session.pubkey_panels:
            logger.info(
                f"[{agent.nazwa}] show_pubkey_status({pubkey[:6]}…) — already open"
            )
            return {"shown": True, "already": True}

        uri = _PUBKEY_STATUS_URI_FMT.format(hex=pubkey)
        logger.info(f"[{agent.nazwa}] show_pubkey_status: read {uri}")
        try:
            raw = await asyncio.wait_for(
                gateway.czytaj_zasób(uri), timeout=GATEWAY_OP_TIMEOUT_S,
            )
        except asyncio.TimeoutError as e:
            raise RuntimeError(
                f"show_pubkey_status: timed out reading {uri} "
                f"(>{GATEWAY_OP_TIMEOUT_S:.0f}s) — gateway probably doesn't "
                "know the resource template. Restart mcp_gateway."
            ) from e
        except Exception as e:
            raise RuntimeError(
                f"show_pubkey_status: failed to read {uri}: {type(e).__name__}: {e}"
            ) from e
        logger.info(f"[{agent.nazwa}] show_pubkey_status: read OK ({len(raw)} bytes)")
        try:
            data = json.loads(raw)
        except (TypeError, ValueError) as e:
            raise RuntimeError(
                f"show_pubkey_status: resource {uri} returned non-JSON: {e}"
            ) from e

        if not data.get("tracked"):
            logger.info(
                f"[{agent.nazwa}] show_pubkey_status({pubkey[:6]}…) — untracked"
            )
            return {
                "shown": False,
                "tracked": False,
                "hex": pubkey,
                "hint": (
                    "Pubkey is not observed. Tell the user we don't track "
                    "this pubkey yet and ask whether to subscribe to it as "
                    "primary. If they confirm, call "
                    "`nostr_admin__subscribe_to_pubkey` with this hex, then "
                    "re-invoke `explorer__show_pubkey_status`."
                ),
            }

        async def on_update(_uri: str) -> None:
            try:
                raw2 = await asyncio.wait_for(
                    gateway.czytaj_zasób(uri), timeout=GATEWAY_OP_TIMEOUT_S,
                )
                data2 = json.loads(raw2)
            except Exception:
                logger.exception(
                    f"[{agent.nazwa}] pubkey panel update failed for {uri}"
                )
                return
            await session.emit({
                "type": "pubkey_panel_update",
                "hex": pubkey,
                "data": data2,
            })

        logger.info(f"[{agent.nazwa}] show_pubkey_status: subscribe {uri}")
        try:
            await asyncio.wait_for(
                gateway.subskrybuj_zasób(uri, on_update),
                timeout=GATEWAY_OP_TIMEOUT_S,
            )
        except asyncio.TimeoutError as e:
            raise RuntimeError(
                f"show_pubkey_status: timed out subscribing to {uri} "
                f"(>{GATEWAY_OP_TIMEOUT_S:.0f}s) — gateway/server stuck."
            ) from e
        except Exception as e:
            raise RuntimeError(
                f"show_pubkey_status: subscribe failed for {uri}: {e}"
            ) from e
        logger.info(f"[{agent.nazwa}] show_pubkey_status: subscribe OK")

        session.pubkey_panels[pubkey] = {"uri": uri, "callback": on_update}
        await session.emit({
            "type": "pubkey_panel_open",
            "hex": pubkey,
            "data": data,
        })
        logger.info(
            f"[{agent.nazwa}] show_pubkey_status({pubkey[:6]}…) — panel opened"
        )
        return {"shown": True}

    cap = Capability(
        name=DEFINICJA["name"],
        description=DEFINICJA["description"],
        input_schema=DEFINICJA["inputSchema"],
        handler=handler,
        source="mcp_nostr_explorer:show_pubkey_status",
        requires_approval=False,
        wraps_in_jou=False,
    )
    agent.registry.add(cap)
    logger.info(f"[{agent.nazwa}] dodano narzędzie {NAZWA_SHOW_PUBKEY_STATUS}")


def _usun_end_turn(agent: Agent) -> None:
    """Wyrzuca `end_turn` z rejestru sesji eksploratora.

    Rationale: `end_turn` istnieje w jou żeby zaoszczędzić jeden round LLM
    (sentinel zamiast generowania finalnego tekstu). W eksploratorze chcemy
    żeby agent ZAWSZE skończył turę naturalnym tekstem — wtedy LLM ma pełny
    kontekst (po `show_result`) i daje userowi sensowną informację, zamiast
    krótkiego \"✓ done\" z reason. JOU globalnie nie tykamy."""
    reg = agent.registry
    if "end_turn" in reg.names():
        reg.remove("end_turn")
        logger.info(f"[{agent.nazwa}] usunięto end_turn z rejestru")


# ---------- SessionsManager ----------

class SessionsManager:
    def __init__(self, shared: SharedRuntime) -> None:
        self.shared = shared
        self._sessions: dict[str, Session] = {}
        self._lock = asyncio.Lock()
        self._gc_task: asyncio.Task | None = None

    @property
    def count(self) -> int:
        return len(self._sessions)

    def has(self, sid: str) -> bool:
        return sid in self._sessions

    async def get_or_create(self, sid: str) -> Session:
        async with self._lock:
            sess = self._sessions.get(sid)
            if sess is not None:
                sess.touch()
                return sess
            if len(self._sessions) >= MAX_SESSIONS:
                raise RuntimeError(
                    f"Limit {MAX_SESSIONS} aktywnych sesji osiągnięty — odrzucam nową."
                )
            agent = self.shared.build_agent(sid)
            sess = Session(sid=sid, agent=agent)

            def push_table(payload: dict) -> None:
                # `payload` ma kształt {"title": str, "table": {"columns", "rows"}}.
                # Z wrappera (sync, ale w tym samym event-loopie) → broadcast
                # przez emit_sync (put_nowait do każdej kolejki).
                sess.emit_sync({
                    "type": "table",
                    "title": payload.get("title", ""),
                    "table": payload["table"],
                })

            run_sql_orig = _zainstaluj_wrapper_sql(agent, sess, push_table)
            _dodaj_show_result(agent, sess, push_table, run_sql_orig)
            _dodaj_show_pubkey_status(agent, sess, self.shared._gateway)
            _usun_end_turn(agent)
            # MUSI być ostatnie — owija też wrappery run_sql / show_* powyżej,
            # żeby aliasy w ich argach (SQL, pubkey) rozwinęły się przed
            # approvalem i przed handlerem.
            _zainstaluj_aliasy(agent, sess)
            sess.task = asyncio.create_task(sess.loop(), name=f"session-{sid[:8]}")
            self._sessions[sid] = sess
            logger.info(f"Utworzono sesję {sid[:8]} (aktywnych: {len(self._sessions)})")
            return sess

    async def close_session(self, sid: str) -> None:
        async with self._lock:
            sess = self._sessions.pop(sid, None)
        if sess is None:
            return
        # Najpierw zerwij subskrypcje resource-ów panelu — gateway może
        # przestać do nas pisać. Robimy to przed wysłaniem sentinela do
        # cmd_queue, żeby pętla sesji nie wpadła w handler już
        # zerwanego eventu w trakcie zamykania.
        if self.shared._gateway is not None:
            try:
                await sess.close_all_pubkey_panels(self.shared._gateway)
            except Exception:
                logger.exception(f"[{sid[:8]}] cleanup paneli zawiódł")
        # Odblokuj task agenta, jeśli czeka na potwierdzenie SQL — bez tego
        # `await sess.task` wisi do timeoutu i potem cancel-killuje brutalnie.
        sess.fail_all_sql_approvals("sesja zamykana")
        await sess.cmd_queue.put(None)  # sentinel
        if sess.task is not None:
            try:
                await asyncio.wait_for(sess.task, timeout=5)
            except (asyncio.TimeoutError, asyncio.CancelledError):
                sess.task.cancel()
        logger.info(f"Zamknięto sesję {sid[:8]}")

    async def start_gc(self) -> None:
        self._gc_task = asyncio.create_task(self._gc_loop(), name="sessions-gc")

    async def _gc_loop(self) -> None:
        while True:
            try:
                await asyncio.sleep(SESSION_GC_INTERVAL_S)
            except asyncio.CancelledError:
                break
            now = time.monotonic()
            stale = [
                sid for sid, s in list(self._sessions.items())
                if now - s.last_seen > SESSION_IDLE_TIMEOUT_S
            ]
            for sid in stale:
                logger.info(f"GC: zamykam idle-sesję {sid[:8]}")
                try:
                    await self.close_session(sid)
                except Exception:
                    logger.exception(f"GC: błąd przy zamykaniu {sid[:8]}")

    async def shutdown(self) -> None:
        if self._gc_task is not None:
            self._gc_task.cancel()
            try:
                await self._gc_task
            except (asyncio.CancelledError, Exception):
                pass
        for sid in list(self._sessions.keys()):
            await self.close_session(sid)


# ---------- Module-level singletons ----------

_shared: SharedRuntime | None = None
_manager: SessionsManager | None = None


async def init_runtime() -> tuple[SharedRuntime | None, SessionsManager | None]:
    """Returns (shared, manager) or (None, None) if setup failed."""
    global _shared, _manager
    if _shared is not None and _manager is not None:
        return _shared, _manager
    sr = SharedRuntime()
    try:
        await sr.setup()
    except Exception:
        logger.exception("SharedRuntime nie wystartował.")
        return None, None
    sm = SessionsManager(sr)
    await sm.start_gc()
    _shared = sr
    _manager = sm
    return _shared, _manager


def get_shared() -> SharedRuntime | None:
    return _shared


def get_manager() -> SessionsManager | None:
    return _manager


async def close_runtime() -> None:
    global _shared, _manager
    if _manager is not None:
        await _manager.shutdown()
        _manager = None
    if _shared is not None:
        await _shared.close()
        _shared = None
