#!/usr/bin/env python3
"""
MCP server pro PostgreSQL — používá FastMCP + psycopg v3.
Spustit: python mcp_postgres.py

Host:    192.168.1.76:5432
User:    vladimir.buzalka
Default DB: postgres (lze přepínat tool argumentem `db`)
"""

import json
import sys
import time
import traceback
from datetime import datetime, date
from decimal import Decimal
from typing import Optional, Union

import psycopg
from psycopg.rows import dict_row
from mcp.server.fastmcp import FastMCP

PG_HOST = "192.168.1.76"
PG_PORT = 5432
PG_USER = "vladimir.buzalka"
PG_PASSWORD = "Vlado7309208104++"
PG_DEFAULT_DB = "postgres"

WRITE_KEYWORDS = ("insert", "update", "delete", "drop", "truncate",
                  "alter", "create", "grant", "revoke", "comment", "merge")


def log(msg: str):
    print(msg, file=sys.stderr, flush=True)


_pool: dict[str, psycopg.Connection] = {}


def get_conn(db: Optional[str] = None) -> psycopg.Connection:
    """Lazy connection cache keyed by db name. Reconnects if closed."""
    name = db or PG_DEFAULT_DB
    conn = _pool.get(name)
    if conn is not None and not conn.closed:
        return conn
    conn = psycopg.connect(
        host=PG_HOST,
        port=PG_PORT,
        user=PG_USER,
        password=PG_PASSWORD,
        dbname=name,
        connect_timeout=5,
        autocommit=True,
    )
    _pool[name] = conn
    return conn


def serialize(value):
    if isinstance(value, (datetime, date)):
        return value.isoformat()
    if isinstance(value, Decimal):
        return float(value)
    if isinstance(value, (bytes, memoryview)):
        try:
            return bytes(value).decode("utf-8", errors="replace")
        except Exception:
            return repr(value)
    if isinstance(value, dict):
        return {k: serialize(v) for k, v in value.items()}
    if isinstance(value, (list, tuple)):
        return [serialize(v) for v in value]
    return value


def serialize_rows(rows):
    return [{k: serialize(v) for k, v in row.items()} for row in rows]


def is_write(sql: str) -> bool:
    s = sql.lstrip().lower()
    return any(s.startswith(kw) for kw in WRITE_KEYWORDS)


# Verify default DB on startup
try:
    c = get_conn()
    with c.cursor() as cur:
        cur.execute("select version()")
        ver = cur.fetchone()[0]
    log(f"Connected to PostgreSQL ({PG_HOST}:{PG_PORT}) — {ver}")
except Exception as e:
    log(f"PostgreSQL connection failed: {e}")
    sys.exit(1)


mcp = FastMCP("janssen-postgres")


@mcp.tool()
def ping() -> dict:
    """Check if PostgreSQL is reachable. Returns server version, latency, host."""
    try:
        start = time.monotonic()
        conn = get_conn()
        with conn.cursor() as cur:
            cur.execute("select version()")
            version = cur.fetchone()[0]
        latency_ms = round((time.monotonic() - start) * 1000, 1)
        return {
            "status": "ok",
            "version": version,
            "latency_ms": latency_ms,
            "host": PG_HOST,
            "port": PG_PORT,
        }
    except Exception as e:
        return {"status": "error", "error": str(e), "host": PG_HOST}


@mcp.tool()
def list_databases() -> dict:
    """List all non-template databases on the server."""
    try:
        conn = get_conn()
        with conn.cursor(row_factory=dict_row) as cur:
            cur.execute("select datname from pg_database where datistemplate = false order by datname")
            dbs = [r["datname"] for r in cur.fetchall()]
        return {"count": len(dbs), "databases": dbs}
    except Exception:
        log(f"list_databases error: {traceback.format_exc()}")
        raise


@mcp.tool()
def list_schemas(db: Optional[str] = None) -> dict:
    """List schemas in a database (excludes pg_* and information_schema)."""
    try:
        conn = get_conn(db)
        with conn.cursor(row_factory=dict_row) as cur:
            cur.execute("""
                select schema_name
                from information_schema.schemata
                where schema_name not like 'pg\\_%' escape '\\'
                  and schema_name <> 'information_schema'
                order by schema_name
            """)
            schemas = [r["schema_name"] for r in cur.fetchall()]
        return {"db": db or PG_DEFAULT_DB, "count": len(schemas), "schemas": schemas}
    except Exception:
        log(f"list_schemas error: {traceback.format_exc()}")
        raise


@mcp.tool()
def list_tables(db: Optional[str] = None, schema: str = "public") -> dict:
    """List tables (and views) in a schema."""
    try:
        conn = get_conn(db)
        with conn.cursor(row_factory=dict_row) as cur:
            cur.execute("""
                select table_name, table_type
                from information_schema.tables
                where table_schema = %s
                order by table_name
            """, (schema,))
            rows = cur.fetchall()
        return {
            "db": db or PG_DEFAULT_DB,
            "schema": schema,
            "count": len(rows),
            "tables": rows,
        }
    except Exception:
        log(f"list_tables error: {traceback.format_exc()}")
        raise


@mcp.tool()
def describe_table(table: str, db: Optional[str] = None, schema: str = "public") -> dict:
    """Return columns, types, nullability, defaults, indexes and row count for a table."""
    try:
        conn = get_conn(db)
        with conn.cursor(row_factory=dict_row) as cur:
            cur.execute("""
                select column_name, data_type, is_nullable, column_default, character_maximum_length
                from information_schema.columns
                where table_schema = %s and table_name = %s
                order by ordinal_position
            """, (schema, table))
            columns = cur.fetchall()

            cur.execute("""
                select indexname, indexdef
                from pg_indexes
                where schemaname = %s and tablename = %s
                order by indexname
            """, (schema, table))
            indexes = cur.fetchall()

            qualified = f'"{schema}"."{table}"'
            try:
                cur.execute(f"select count(*) as c from {qualified}")
                row_count = cur.fetchone()["c"]
            except Exception as e:
                row_count = f"error: {e}"

        return {
            "db": db or PG_DEFAULT_DB,
            "schema": schema,
            "table": table,
            "row_count": row_count,
            "columns": columns,
            "indexes": indexes,
        }
    except Exception:
        log(f"describe_table error: {traceback.format_exc()}")
        raise


@mcp.tool()
def query(
    sql: str,
    db: Optional[str] = None,
    params_json: Optional[Union[str, list]] = None,
    limit: int = 100,
) -> dict:
    """Run a READ-ONLY SQL query (SELECT / WITH / EXPLAIN / SHOW).
    For writes use `execute` (which requires `confirmed=True`).
    params_json: JSON array of positional parameters for %s placeholders, e.g. '["CZ1-01", 5]'.
    limit: cap on returned rows (max 1000). The query itself is NOT modified — limit is a Python-side cutoff.
    """
    try:
        if is_write(sql):
            return {
                "status": "rejected",
                "reason": "Write/DDL statement detected. Use `execute` (with preview + confirmed=True).",
            }
        limit = min(max(limit, 1), 1000)
        params = json.loads(params_json) if isinstance(params_json, str) else (params_json or [])

        conn = get_conn(db)
        with conn.cursor(row_factory=dict_row) as cur:
            cur.execute(sql, params)
            if cur.description is None:
                return {"status": "ok", "rowcount": cur.rowcount, "rows": []}
            rows = cur.fetchmany(limit)
        return {
            "status": "ok",
            "db": db or PG_DEFAULT_DB,
            "row_count": len(rows),
            "limit": limit,
            "rows": serialize_rows(rows),
        }
    except Exception as e:
        log(f"query error: {traceback.format_exc()}")
        return {"status": "error", "error": str(e), "sql": sql}


@mcp.tool()
def explain(sql: str, db: Optional[str] = None, analyze: bool = False) -> dict:
    """Return EXPLAIN (ANALYZE, BUFFERS) plan for a SELECT-style query. analyze=False is safe; True actually runs the query."""
    try:
        if is_write(sql) and analyze:
            return {"status": "rejected", "reason": "Refuse to EXPLAIN ANALYZE a write statement."}
        prefix = "explain (analyze, buffers, format json) " if analyze else "explain (format json) "
        conn = get_conn(db)
        with conn.cursor() as cur:
            cur.execute(prefix + sql)
            plan = cur.fetchone()[0]
        return {"status": "ok", "analyze": analyze, "plan": plan}
    except Exception as e:
        log(f"explain error: {traceback.format_exc()}")
        return {"status": "error", "error": str(e)}


@mcp.tool()
def preview_execute(
    sql: str,
    db: Optional[str] = None,
    params_json: Optional[Union[str, list]] = None,
) -> dict:
    """Preview a write/DDL statement using a transaction + ROLLBACK. Reports rowcount that WOULD be affected.
    Always call this first and present the result to the user before calling `execute` with confirmed=True.
    """
    try:
        params = json.loads(params_json) if isinstance(params_json, str) else (params_json or [])
        # Use a fresh, autocommit=False connection so we can ROLLBACK safely
        with psycopg.connect(
            host=PG_HOST, port=PG_PORT, user=PG_USER, password=PG_PASSWORD,
            dbname=db or PG_DEFAULT_DB, connect_timeout=5, autocommit=False,
        ) as conn:
            with conn.cursor() as cur:
                cur.execute(sql, params)
                rowcount = cur.rowcount
            conn.rollback()
        return {
            "status": "preview",
            "db": db or PG_DEFAULT_DB,
            "sql": sql,
            "params": params,
            "would_affect_rows": rowcount,
            "note": "Statement was executed inside a transaction and rolled back. Present this to the user and call `execute` with confirmed=True only after explicit approval.",
        }
    except Exception as e:
        log(f"preview_execute error: {traceback.format_exc()}")
        return {"status": "error", "error": str(e), "sql": sql}


@mcp.tool()
def execute(
    sql: str,
    db: Optional[str] = None,
    params_json: Optional[Union[str, list]] = None,
    confirmed: bool = False,
) -> dict:
    """Run a write/DDL statement (INSERT/UPDATE/DELETE/CREATE/ALTER/DROP/...).
    REQUIRES confirmed=True — first call `preview_execute` and obtain explicit user approval.
    """
    if not confirmed:
        return {
            "status": "aborted",
            "reason": "confirmed=False. Call preview_execute first, present the impact to the user, then re-call with confirmed=True.",
        }
    try:
        params = json.loads(params_json) if isinstance(params_json, str) else (params_json or [])
        conn = get_conn(db)
        with conn.cursor() as cur:
            cur.execute(sql, params)
            rowcount = cur.rowcount
        log(f"execute: db={db or PG_DEFAULT_DB} rowcount={rowcount}")
        return {
            "status": "ok",
            "db": db or PG_DEFAULT_DB,
            "rowcount": rowcount,
            "sql": sql,
        }
    except Exception as e:
        log(f"execute error: {traceback.format_exc()}")
        return {"status": "error", "error": str(e), "sql": sql}


@mcp.tool()
def list_extensions(db: Optional[str] = None) -> dict:
    """List installed extensions in the database."""
    try:
        conn = get_conn(db)
        with conn.cursor(row_factory=dict_row) as cur:
            cur.execute("select extname, extversion from pg_extension order by extname")
            rows = cur.fetchall()
        return {"db": db or PG_DEFAULT_DB, "count": len(rows), "extensions": rows}
    except Exception:
        log(f"list_extensions error: {traceback.format_exc()}")
        raise


if __name__ == "__main__":
    log("MCP PostgreSQL server started (FastMCP)")
    mcp.run()
