"""
FastAPI app dla mcp_nostr_explorer (Phase 3: WebSocket).

Endpointy:
  GET    /                       — index.html
  GET    /api/health             — realny status backendu (probes gateway.list_tools)
  WS     /api/ws?sid=...         — pojedynczy bidirekcyjny kanał per sesja
  DELETE /api/session/{sid}      — explicit teardown (browser onbeforeunload)

Protokół WebSocket (JSON in both directions):

  client → server:
    {"type": "run",                "command": "..."}     — enqueue komendę do agenta
    {"type": "set_model",          "model": "..."}       — zmień model dla sesji
    {"type": "pubkey_panel_close", "hex": "..."}         — zamknij live-panel pubkeya
    {"type": "ping"}                                     — heartbeat (server odpowiada "pong")

  server → client:
    {"type": "hello",         "sid": "...", "backend": "real"|"down"}
    {"type": "started",       "command": "..."}
    {"type": "table",         "table": {"columns":[...], "rows":[[...],...]}}
    {"type": "assistant_text","text": "..."}
    {"type": "error",         "message": "..."}
    {"type": "done",          "cost_usd": 0.001, "took_ms": 12345}
    {"type": "pong"}
    {"type": "health",            "backend": "real"|"down"}    — push przy zmianie stanu
    {"type": "pubkey_panel_open", "hex": "...", "data": {...}} — otwórz panel z initial snapshot
    {"type": "pubkey_panel_update","hex": "...", "data": {...}} — push update (po notyfikacji)
    {"type": "pubkey_panel_close","hex": "..."}                — zamknij panel (sesja zniknęła
                                                                  lub user kliknął X)

Disconnect klienta zostawia sesję żywą do GC (30 min idle), żeby F5 nie tracił
agenta. Wiele równoległych WS na jeden sid: dozwolone — każdy dostaje swoją kopię
strumienia eventów (server side fan-out po per-WS kolejce).
"""

from __future__ import annotations

import asyncio
import json
import logging
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from mcp_nostr_explorer import agent_runtime, models_catalog

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

STATIC_DIR = Path(__file__).parent / "static"

# Realny health probe: timeout dla gateway.lista_narzędzi() — krótki, żeby
# /api/health nie wisiał. Gateway w okolicach 1s normalnie odpowiada.
HEALTH_PROBE_TIMEOUT_S = 2.0


@asynccontextmanager
async def lifespan(app: FastAPI):
    shared, manager = await agent_runtime.init_runtime()
    if shared is None:
        logger.error(
            "BACKEND niedostępny — zobacz traceback wyżej. /api/ws będzie zwracał błąd."
        )
    else:
        logger.info("BACKEND: real (jou nostr agent, per-session)")
    try:
        yield
    finally:
        await agent_runtime.close_runtime()


async def _backend_alive() -> bool:
    """Realny probe: `gateway.lista_narzędzi()` z timeoutem.
    SharedRuntime.ready niewystarczający — gateway mógł paść po setupie."""
    shared = agent_runtime.get_shared()
    if shared is None or not shared.ready:
        return False
    try:
        await asyncio.wait_for(
            shared._gateway.lista_narzędzi(),
            timeout=HEALTH_PROBE_TIMEOUT_S,
        )
        return True
    except (asyncio.TimeoutError, Exception):
        return False


def create_app() -> FastAPI:
    app = FastAPI(title="mcp_nostr_explorer", version="0.3.0", lifespan=lifespan)

    @app.get("/")
    async def index() -> FileResponse:
        return FileResponse(STATIC_DIR / "index.html")

    @app.get("/api/health")
    async def health() -> dict:
        sm = agent_runtime.get_manager()
        alive = await _backend_alive()
        return {
            "backend": "real" if alive else "down",
            "sessions": sm.count if sm is not None else 0,
        }

    @app.get("/api/models")
    async def models() -> dict:
        """Lista modeli z PPQ — cache 5 min. Klucz API wstrzykujemy ze
        SharedRuntime (PPQ akceptuje brak Authorization, ale czasem
        ratelimituje anonimowych)."""
        shared = agent_runtime.get_shared()
        api_key = shared.api_key if shared is not None else None
        try:
            data = await models_catalog.fetch_chat_models(api_key)
            return {"models": data}
        except Exception as e:
            logger.warning(f"GET /api/models: {type(e).__name__}: {e}")
            raise HTTPException(status_code=502, detail=f"PPQ models: {e}")

    @app.post("/api/topup/confirm")
    async def topup_confirm(payload: dict) -> dict:
        """Stub topup — w realnej wersji weryfikowalibyśmy preimage z LND.
        Tu po prostu dolicza saty i emituje balance event do wszystkich WS
        subskrybentów sesji.

        Body: {"sid": str, "sats": int}.
        """
        sid = (payload.get("sid") or "").strip()
        sats = payload.get("sats")
        if not sid:
            raise HTTPException(status_code=400, detail="brak sid")
        if not isinstance(sats, int) or sats <= 0:
            raise HTTPException(status_code=400, detail="sats musi być int > 0")
        sm = agent_runtime.get_manager()
        if sm is None:
            raise HTTPException(status_code=503, detail="backend down")
        if not sm.has(sid):
            raise HTTPException(status_code=404, detail=f"brak sesji {sid}")
        session = await sm.get_or_create(sid)
        evt = session.add_sats(sats)
        await session.emit(evt)
        return {"sat": session.balance_sat}

    @app.websocket("/api/ws")
    async def ws(websocket: WebSocket) -> None:
        sid = websocket.query_params.get("sid", "").strip()
        if not sid:
            await websocket.close(code=1008, reason="brak sid")
            return

        await websocket.accept()
        sm = agent_runtime.get_manager()
        backend = "real" if await _backend_alive() else "down"

        if sm is None:
            await websocket.send_json({"type": "hello", "sid": sid, "backend": "down"})
            await websocket.send_json({
                "type": "error",
                "message": "backend (gateway) nie wstał — sprawdź logi serwera",
            })
            await websocket.close(code=1011)
            return

        try:
            session = await sm.get_or_create(sid)
        except Exception as e:
            # MAX_SESSIONS, build_agent error (np. brak narzędzi w puli), itp.
            # Wysyłamy hello + error przez WS żeby klient dostał czytelny komunikat
            # zamiast 500. Klient widzi error i nie spamuje reconnectami w nieskoń.
            logger.warning(f"WS {sid[:8]}: nie udało się stworzyć sesji: {e}")
            await websocket.send_json({"type": "hello", "sid": sid, "backend": backend})
            await websocket.send_json({
                "type": "error",
                "message": f"sesja nie powstała: {e}",
            })
            await websocket.close(code=1011)
            return

        # Per-WS kolejka — pozwala wielu klientom na ten sam sid (każdy dostaje
        # swoją kopię). Sesja fanouts do wszystkich subskrybentów.
        sub_queue = session.subscribe()

        # Hello z aktualnym stanem backendu, modelu i salda — klient ma od razu
        # czym wypełnić nagłówek bez dodatkowych roundtripów.
        await websocket.send_json({
            "type": "hello",
            "sid": sid,
            "backend": backend,
            "model": session.model(),
            "balance_sat": session.balance_sat,
        })

        async def pump_events() -> None:
            """Server → client: drenuje sub_queue do socketu."""
            while True:
                evt = await sub_queue.get()
                await websocket.send_json(evt)

        async def pump_input() -> None:
            """Client → server: parsuje wiadomości i tłumaczy na akcje sesji."""
            while True:
                raw = await websocket.receive_text()
                try:
                    msg = json.loads(raw)
                except (json.JSONDecodeError, ValueError):
                    await websocket.send_json({
                        "type": "error",
                        "message": "niepoprawny JSON",
                    })
                    continue
                kind = msg.get("type")
                if kind == "run":
                    command = (msg.get("command") or "").strip()
                    if not command:
                        await websocket.send_json({
                            "type": "error",
                            "message": "pusta komenda",
                        })
                        continue
                    # Hard gate na saldo: jak konto puste, nie wpuszczamy komendy
                    # do agenta — i tak by ją wykonał zanim zdąży zjeść kolejne
                    # saty. Frontend dostaje error + aktualne saldo, user widzi
                    # że trzeba doładować.
                    if session.balance_sat <= 0:
                        await session.emit({
                            "type": "error",
                            "message": (
                                f"saldo wyczerpane ({session.balance_sat} sat) — "
                                "doładuj konto (przycisk + obok salda) i spróbuj ponownie"
                            ),
                        })
                        await session.emit({
                            "type": "balance",
                            "sat": session.balance_sat,
                            "last_charge_sat": 0,
                            "last_charge_usd": 0.0,
                        })
                        continue
                    await session.enqueue_command(command)
                elif kind == "set_model":
                    model = (msg.get("model") or "").strip()
                    try:
                        session.set_model(model)
                    except ValueError as e:
                        await websocket.send_json({
                            "type": "error",
                            "message": f"set_model: {e}",
                        })
                        continue
                    # Broadcast do wszystkich WS sesji, nie tylko tego co wysłał —
                    # gdyby user miał dwie karty, obie mają widzieć aktualny model.
                    await session.emit({"type": "model", "model": model})
                elif kind == "ping":
                    await websocket.send_json({"type": "pong"})
                elif kind == "sql_confirm_response":
                    request_id = (msg.get("request_id") or "").strip()
                    if not request_id:
                        await websocket.send_json({
                            "type": "error",
                            "message": "sql_confirm_response: brak `request_id`",
                        })
                        continue
                    approved = bool(msg.get("approved"))
                    reason = msg.get("reason") or ""
                    resolved = session.resolve_sql_approval(request_id, approved, reason)
                    if not resolved:
                        # Stale request_id (timeout zdążył, inna karta już
                        # odpowiedziała) — nie spamuj usera errorem, to
                        # naturalna sytuacja przy wielu kartach.
                        logger.info(
                            f"[{sid[:8]}] sql_confirm_response dla {request_id[:8]}… "
                            f"odrzucony (już rozwiązany lub nieznany)"
                        )
                elif kind == "pubkey_panel_close":
                    hex_ = (msg.get("hex") or "").strip().lower()
                    if not hex_:
                        await websocket.send_json({
                            "type": "error",
                            "message": "pubkey_panel_close: brak `hex`",
                        })
                        continue
                    shared = agent_runtime.get_shared()
                    if shared is None or shared._gateway is None:
                        # Sesja jeszcze działa, ale gateway zerwany —
                        # usuń wpis lokalnie, subscribe na MCP serwerze
                        # i tak nas już nie ping-uje.
                        session.pubkey_panels.pop(hex_, None)
                        await session.emit({"type": "pubkey_panel_close", "hex": hex_})
                    else:
                        await session.close_pubkey_panel(hex_, shared._gateway)
                else:
                    await websocket.send_json({
                        "type": "error",
                        "message": f"nieznany typ wiadomości: {kind!r}",
                    })

        events_task = asyncio.create_task(pump_events(), name=f"ws-events-{sid[:8]}")
        input_task = asyncio.create_task(pump_input(), name=f"ws-input-{sid[:8]}")
        try:
            done, _ = await asyncio.wait(
                [events_task, input_task],
                return_when=asyncio.FIRST_COMPLETED,
            )
            # Jeśli któryś task zakończył się wyjątkiem — propaguj go do logu
            # (zwykle WebSocketDisconnect, traktujemy jako normalny rozłącz).
            for t in done:
                exc = t.exception()
                if exc is not None and not isinstance(exc, WebSocketDisconnect):
                    logger.warning(
                        f"[{sid[:8]}] WS pump zakończony wyjątkiem: "
                        f"{type(exc).__name__}: {exc}"
                    )
        finally:
            for t in (events_task, input_task):
                if not t.done():
                    t.cancel()
                    try:
                        await t
                    except (asyncio.CancelledError, Exception):
                        pass
            session.unsubscribe(sub_queue)
            try:
                await websocket.close()
            except Exception:
                pass
            logger.info(f"WS rozłączone dla {sid[:8]}")

    @app.delete("/api/session/{sid}")
    async def close_session(sid: str) -> dict:
        sm = agent_runtime.get_manager()
        if sm is None:
            return {"closed": False}
        await sm.close_session(sid)
        return {"closed": True}

    app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
    return app
