#!/usr/bin/env python3
"""
MCP Gateway — punkt wejścia.

Konfiguracja w pliku config.json (lub innym podanym jako argument):
  python main.py             # używa config.json
  python main.py prod.json   # używa prod.json
"""

import asyncio
import json
import logging
import os
import socket
import subprocess
import sys
import time
from urllib.parse import urlparse

# Dodaj katalog nadrzędny do sys.path, żeby import mcp_gateway działał
# niezależnie od tego, czy skrypt uruchamiamy z tego katalogu czy z zewnątrz
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import uvicorn

from mcp_gateway.connection_pool import ConnectionPool
from mcp_gateway.executor import Executor
from mcp_gateway.gateway import Gateway
from mcp_gateway.management_server import ManagementServer
from mcp_gateway.orchestrator import Orchestrator, RECONNECT_INTERVAL
from mcp_gateway.registry import Registry

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("mcp_gateway.main")

AUTOSTART_REACHABLE_TIMEOUT_S = 15.0
AUTOSTART_SHUTDOWN_TIMEOUT_S = 5.0
# Hard ceiling on how long we block uvicorn startup waiting for an autostart
# backend to finish its MCP handshake (port-open ≠ ready; e.g. mcp_admin needs
# ~40s for tracked-pubkey replay before list_tools succeeds). Past this we
# raise — better to fail loudly than start serving with an incomplete pool
# that confuses clients (explorer used to cache the partial view at connect
# time and never recover).
AUTOSTART_ATTACH_TIMEOUT_S = 120.0
AUTOSTART_ATTACH_POLL_S = 1.0


def load_config(path: str) -> dict:
    with open(path) as f:
        return json.load(f)


def save_config(path: str, config: dict, registry: Registry) -> None:
    # Preserve autostart/sh_path (and any other extra metadata) per backend id.
    # Without this, dynamic add/remove via management would round-trip through
    # save_config and silently strip these fields off disk.
    extras_by_id = {
        b["id"]: {k: v for k, v in b.items() if k not in {"id", "url", "enabled"}}
        for b in config.get("backends", [])
        if isinstance(b, dict) and "id" in b
    }
    config["backends"] = [
        {
            "id": b.id,
            "url": b.url,
            "enabled": b.enabled,
            **extras_by_id.get(b.id, {}),
        }
        for b in registry._backends.values()
        if b.id != "management" and not b.transient
    ]
    with open(path, "w") as f:
        json.dump(config, f, indent=2)
    logger.info(f"Zapisano konfigurację do {path}")


def _autostart_spawn(backends: list[dict], config_path: str) -> dict[str, subprocess.Popen]:
    """Spawn every backend with `autostart: true` and `sh_path` set.

    Relative `sh_path` is resolved against the directory of `config_path` so
    configs stay portable. Stdout/stderr inherit the gateway's, which keeps
    backend logs visible without per-backend wiring.

    Jeśli port backendu jest już zajęty (czyli ktoś już go uruchomił ręcznie),
    pomijamy spawn i pozwalamy `add_backend` podłączyć się do istniejącego
    procesu. Nie wpisujemy go do `spawned`, więc shutdown nie wyśle SIGTERM
    do procesu, którego nie my odpaliliśmy.
    """
    spawned: dict[str, subprocess.Popen] = {}
    config_dir = os.path.dirname(os.path.abspath(config_path))
    for b in backends:
        if not b.get("autostart"):
            continue
        bid = b["id"]
        url = b.get("url") or ""
        parsed = urlparse(url)
        bh = parsed.hostname or "127.0.0.1"
        bp = parsed.port or (443 if parsed.scheme == "https" else 80)
        if not _port_free(bh, bp):
            logger.info(
                f"autostart: {bid} już słucha na {bh}:{bp} — pomijam spawn, "
                f"podłączam się do istniejącego procesu"
            )
            continue
        sh = b.get("sh_path")
        if not sh:
            raise ValueError(
                f"backend {bid!r}: autostart=true but sh_path is missing"
            )
        if not os.path.isabs(sh):
            sh = os.path.join(config_dir, sh)
        if not os.path.isfile(sh):
            raise FileNotFoundError(
                f"backend {bid!r}: sh_path does not exist: {sh}"
            )
        if not os.access(sh, os.X_OK):
            raise PermissionError(
                f"backend {bid!r}: sh_path is not executable: {sh}"
            )
        logger.info(f"autostart: uruchamiam {bid} ({sh})")
        # Own process group so SIGTERM on shutdown can hit any grandchildren
        # (uvicorn workers, ingest daemon threads spawning helpers, etc.).
        proc = subprocess.Popen([sh], cwd=os.path.dirname(sh), start_new_session=True)
        spawned[bid] = proc
    return spawned


def _port_free(host: str, port: int, probe_timeout: float = 0.1) -> bool:
    """True jeśli na (host, port) nikt nie listenuje. `connect_ex == 0` = ktoś
    odpowiedział, więc port zajęty."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(probe_timeout)
    try:
        return sock.connect_ex((host, port)) != 0
    finally:
        sock.close()


def _preflight_ports(host: str, port: int, management_port: int, backends: list[dict]) -> None:
    """Twardy fail przed czymkolwiek: porty którymi gateway sam nasłuchuje
    (own + management) muszą być WOLNE — i tak by się nie uruchomił przez
    EADDRINUSE, więc lepiej fail loud niż połowiczny start.

    Porty autostart-backendów NIE są tu sprawdzane: jeśli backend już słucha,
    `_autostart_spawn` pominie spawn i `add_backend` po prostu podłączy się do
    istniejącego procesu. To pożądane — pozwala uruchomić backend ręcznie
    (np. żeby zachować jego logi/state przez restart gateway) bez wymuszania
    zabijania.
    """
    conflicts: list[str] = []
    if not _port_free(host, port):
        conflicts.append(f"gateway {host}:{port}")
    # Management nasłuchuje na 127.0.0.1, niezależnie od `host` z configu.
    if not _port_free("127.0.0.1", management_port):
        conflicts.append(f"management 127.0.0.1:{management_port}")
    if conflicts:
        raise SystemExit(
            "FATAL: preflight wykrył zajęte porty gateway/management — "
            "odmawiam startu (EADDRINUSE i tak by zabił uvicorn). Konflikty:\n  - "
            + "\n  - ".join(conflicts)
            + "\nUbij konkurencyjne procesy (`ss -ltnp | grep <port>`) i odpal ponownie."
        )


def _wait_url_reachable(url: str, timeout: float) -> bool:
    parsed = urlparse(url)
    host = parsed.hostname or "127.0.0.1"
    port = parsed.port or (443 if parsed.scheme == "https" else 80)
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        try:
            with socket.create_connection((host, port), timeout=0.5):
                return True
        except OSError:
            time.sleep(0.2)
    return False


def _autostart_shutdown(spawned: dict[str, subprocess.Popen]) -> None:
    """SIGTERM every spawned backend, then SIGKILL whoever didn't exit in time."""
    if not spawned:
        return
    for bid, proc in spawned.items():
        if proc.poll() is None:
            logger.info(f"autostart: zamykam {bid} (pid={proc.pid})")
            proc.terminate()
    deadline = time.monotonic() + AUTOSTART_SHUTDOWN_TIMEOUT_S
    for bid, proc in spawned.items():
        remaining = max(0.1, deadline - time.monotonic())
        try:
            proc.wait(timeout=remaining)
        except subprocess.TimeoutExpired:
            logger.warning(f"autostart: {bid} nie zakończył się w {AUTOSTART_SHUTDOWN_TIMEOUT_S}s — SIGKILL")
            proc.kill()
            try:
                proc.wait(timeout=2.0)
            except subprocess.TimeoutExpired:
                logger.error(f"autostart: {bid} nie reaguje nawet na SIGKILL")


async def main() -> None:
    config_path = sys.argv[1] if len(sys.argv) > 1 else "config.json"
    config = load_config(config_path)

    host = config.get("host", "127.0.0.1")
    port = config.get("port", 8000)
    backends = config.get("backends", [])

    management_port = config.get("management_port", 8999)

    # Preflight: gateway own + management + each autostart backend port must
    # be free. Without this we end up with the silent-zombie scenario where a
    # stale mcp_admin holds its port, autostart spawns a sibling that dies
    # on EADDRINUSE, gateway never notices and routes to the stale instance.
    _preflight_ports(host, port, management_port, backends)

    registry = Registry()
    pool = ConnectionPool()
    executor = Executor(pool)
    gateway = Gateway(registry, executor)
    orchestrator = Orchestrator(
        registry, pool, executor,
        gateway=gateway,
        on_change=lambda: save_config(config_path, config, registry),
    )

    management = ManagementServer(orchestrator, registry, port=management_port)
    await management.start()
    # `persist=False`: management is internal — never lives in config.json.
    # Without this flag, on_change would fire and rewrite config.json before any
    # user-defined backend is added, wiping autostart/sh_path entries off disk.
    await orchestrator.add_backend("management", management.url, persist=False)

    if not backends:
        logger.warning("Brak backendów w konfiguracji")

    # Spawn autostarted MCP servers BEFORE we start probing them. Each spawned
    # process is tracked in `spawned`; a try/finally below sends SIGTERM and
    # waits on each on shutdown so the gateway never leaves orphans behind.
    spawned = _autostart_spawn(backends, config_path)
    for bid, proc in spawned.items():
        # Find the corresponding backend dict to know which URL to wait on.
        b = next((x for x in backends if x["id"] == bid), None)
        if b is None or not b.get("enabled", True):
            continue
        if _wait_url_reachable(b["url"], AUTOSTART_REACHABLE_TIMEOUT_S):
            logger.info(f"autostart: {bid} odpowiada na {b['url']}")
        else:
            logger.warning(
                f"autostart: {bid} nie wstał w {AUTOSTART_REACHABLE_TIMEOUT_S:.0f}s — "
                f"add_backend pójdzie do reconnect-loopa"
            )

    # Banner statusu po stronie startupu — jedna linia per backend, czytelnie
    # widoczna w konsoli. Same szczegóły dalej idą przez `logger` (DEBUG/INFO),
    # ale pierwszy rzut oka ma być z banneru.
    print()
    print("Backendy:")
    for backend in backends:
        enabled = backend.get("enabled", True)
        bid = backend["id"]
        url = backend["url"]
        autostart_mark = " *" if backend.get("autostart") else ""
        if not enabled:
            print(f"  [OFF]  {bid:<16}{autostart_mark} {url}")
            await orchestrator.add_backend(bid, url, enabled=False, persist=False)
            continue

        # Liczba tooli/resources w registry po add_backend pokazuje czy connect
        # poszedł — `unreachable_at_startup` zostawia wpis bez tooli.
        await orchestrator.add_backend(bid, url, enabled=True, persist=False)
        tools = [t for t in registry.list_tools() if t.backend.id == bid]
        resources = [r for r in registry.list_resources() if r.backend.id == bid]
        if tools or resources:
            print(f"  [OK]   {bid:<16}{autostart_mark} {url}  ({len(tools)} tools, {len(resources)} resources)")
        else:
            print(f"  [FAIL] {bid:<16}{autostart_mark} {url}  (unreachable, retrying every {int(RECONNECT_INTERVAL)}s)")
    print()

    # Block uvicorn startup until every enabled+autostart backend has tools
    # or resources in registry. The reconnect_loop is already running for
    # any backend that failed `add_backend` above, so this is just a wait —
    # no extra retry plumbing. Clients (e.g. the explorer) that connect
    # AFTER serve() starts get a complete pool, which is what they assume.
    required = [
        b["id"] for b in backends
        if b.get("autostart") and b.get("enabled", True)
    ]
    if required:
        deadline = time.monotonic() + AUTOSTART_ATTACH_TIMEOUT_S
        while True:
            attached_ids = {t.backend.id for t in registry.list_tools()} | {
                r.backend.id for r in registry.list_resources()
            }
            missing = [bid for bid in required if bid not in attached_ids]
            if not missing:
                logger.info(f"autostart: wszystkie wymagane backendy dopięte: {required}")
                break
            if time.monotonic() > deadline:
                _autostart_shutdown(spawned)
                raise RuntimeError(
                    f"autostart: backendy nie dopięte w {AUTOSTART_ATTACH_TIMEOUT_S:.0f}s: "
                    f"{missing} — przerwanie startu gateway"
                )
            await asyncio.sleep(AUTOSTART_ATTACH_POLL_S)

    app = gateway.create_app()
    config_uvicorn = uvicorn.Config(app, host=host, port=port, log_level="info")
    server = uvicorn.Server(config_uvicorn)
    logger.info(f"Gateway nasłuchuje na http://{host}:{port}/mcp/")
    try:
        await server.serve()
    finally:
        await orchestrator.shutdown()
        _autostart_shutdown(spawned)


if __name__ == "__main__":
    asyncio.run(main())
